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..deb79eb1 100644 --- a/pom.xml +++ b/pom.xml @@ -45,6 +45,21 @@ 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..ea0630b5 100644 --- a/src/main/java/com/example/HelloController.java +++ b/src/main/java/com/example/HelloController.java @@ -1,22 +1,147 @@ package com.example; +import javafx.application.Platform; +import javafx.collections.ListChangeListener; +import javafx.event.ActionEvent; import javafx.fxml.FXML; -import javafx.scene.control.Label; +import javafx.scene.Node; +import javafx.scene.control.*; +import javafx.scene.input.DragEvent; +import javafx.scene.input.Dragboard; +import javafx.scene.input.TransferMode; +import javafx.scene.layout.HBox; +import javafx.stage.FileChooser; + +import javax.swing.event.HyperlinkListener; +import javax.tools.Tool; +import java.awt.Desktop; +import java.io.File; +import java.io.FileNotFoundException; +import java.net.URI; +import java.nio.file.Path; +import java.time.format.DateTimeFormatter; +import java.util.List; -/** - * 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()); + + private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm"); + + @FXML private ListView messageView; + @FXML private TextArea messageInput; + + @FXML private void initialize() { + messageView.setItems(model.getMessages()); + messageView.setCellFactory(lv -> new ListCell<>() { + @Override + protected void updateItem(NtfyMessageDto item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + setText(null); + setGraphic(null); + return; + } + String text = item.message(); + var att = item.attachment(); + + if (att != null && att.url() != null && !att.url().isBlank()) { + Label msg = new Label(text != null ? text + " ": ""); + msg.setWrapText(true); + + String linkText = att.name() != null && !att.name().isBlank() + ? att.name() + : "Attachment"; + Hyperlink link = new Hyperlink(linkText); + if (att.size() > 0) { + link.setTooltip(new Tooltip(humanSize(att.size()) + (att.type() != null ? " - " + att.type() : ""))); + } + link.setOnAction(e -> openInBrowser(att.url())); - @FXML - private Label messageLabel; + HBox row = new HBox(8.0, (Node) msg, (Node) link); + row.setFillHeight(true); + + setText(null); + setGraphic(row); + } else { + setText(text != null ? text : ""); + setGraphic(null); + } + } + }); + + model.getMessages().addListener((ListChangeListener) + c -> Platform.runLater(() -> { + if (!messageView.getItems(). isEmpty()) { + messageView.scrollTo(messageView.getItems().size() - 1); + } + }) + ); + messageView.setOnDragOver(this::handleDragOver); + messageView.setOnDragDropped(this::handleDragDropped); + model.loadInitialMessagesAsync(); + } + + @FXML public void sendFile(ActionEvent actionEvent) throws FileNotFoundException { + FileChooser chooser = new FileChooser(); + chooser.setTitle("Välj fil att skicka"); + File file = chooser.showOpenDialog(messageView.getScene().getWindow()); + if (file != null) { + Path path = file.toPath(); + model.sendFile(path); + } + } - @FXML - private void initialize() { - if (messageLabel != null) { - messageLabel.setText(model.getGreeting()); + public void sendMessage(ActionEvent actionEvent) { + String text = messageInput != null ? messageInput.getText() : ""; + if (text != null && !text.isBlank() && model.sendMessage(text)) { + messageInput.clear(); } } + + private void handleDragOver(DragEvent e) { + Dragboard db = e.getDragboard(); + if (db.hasFiles()) { + e.acceptTransferModes(TransferMode.COPY); + } + e.consume(); + } + + private void handleDragDropped(DragEvent e) { + Dragboard db = e.getDragboard(); + boolean success = false; + if (db.hasFiles()) { + List files = db.getFiles(); + for (File f : files) { + try { + model.sendFile(f.toPath()); + } catch (FileNotFoundException ex) { + throw new RuntimeException(ex); + } + } + success = true; + } + e.setDropCompleted(success); + e.consume(); + } + + private void openInBrowser(String url) { + try { + if (Desktop.isDesktopSupported()) { + Desktop.getDesktop().browse(URI.create(url)); + } else { + // fallback: ingen Desktop, gör inget (kan ersättas med HostServices) + } + } catch (Exception ignored) { + } + } + + private static String humanSize(long bytes) { + // kort & enkel + String[] units = {"B","KB","MB","GB","TB"}; + double v = bytes; + int i = 0; + while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } + return String.format("%.1f %s", v, units[i]); + } } 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..c1f6f240 100644 --- a/src/main/java/com/example/HelloModel.java +++ b/src/main/java/com/example/HelloModel.java @@ -1,15 +1,65 @@ package com.example; -/** - * Model layer: encapsulates application data and business logic. - */ +import javafx.application.Platform; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import java.io.FileNotFoundException; +import java.nio.file.Path; +import java.util.concurrent.CompletableFuture; + 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 + "."; + + private final NtfyConnection connection; + private final ObservableList messages = FXCollections.observableArrayList(); + //private final StringProperty messageToSend = new SimpleStringProperty(); + + public HelloModel(NtfyConnection connection) { + this.connection = connection; } + + public ObservableList getMessages() { + return messages; + } + + /*public String getMessageToSend() { + return messageToSend.get(); + }*/ + + public void loadInitialMessagesAsync() { + CompletableFuture + .supplyAsync(connection::fetchHistory) + .thenAccept(list -> Platform.runLater(() -> { + messages.setAll(list); + subscribeLive(); // start streaming after history + })) + .exceptionally(ex -> null); + } + + private void subscribeLive() { + connection.receive(m -> Platform.runLater(() -> messages.add(m))); + } + + /*public StringProperty messageToSendProperty() { + return messageToSend; + } + + public void setMessageToSend(String message) { + messageToSend.set(message); + }*/ + + public boolean sendMessage(String text) { + return connection.send(text); + } + + public boolean sendFile(Path path) throws FileNotFoundException { + return connection.sendFile(path); + } + + public void receiveMessage() { + connection.receive(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..cf3a5918 --- /dev/null +++ b/src/main/java/com/example/NtfyConnection.java @@ -0,0 +1,17 @@ +package com.example; + +import java.io.FileNotFoundException; +import java.nio.file.Path; +import java.util.List; +import java.util.function.Consumer; + +public interface NtfyConnection { + + boolean send(String message); + + void receive(Consumer messageHandler); + + List fetchHistory(); + + boolean sendFile(Path path) throws FileNotFoundException; +} diff --git a/src/main/java/com/example/NtfyConnectionImpl.java b/src/main/java/com/example/NtfyConnectionImpl.java new file mode 100644 index 00000000..f95d408a --- /dev/null +++ b/src/main/java/com/example/NtfyConnectionImpl.java @@ -0,0 +1,117 @@ +package com.example; + +import io.github.cdimascio.dotenv.Dotenv; +import tools.jackson.databind.ObjectMapper; + +import java.io.FileNotFoundException; +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.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +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(); + hostName = Objects.requireNonNull(dotenv.get("HOST_NAME")); + } + + public NtfyConnectionImpl(String hostName) { + this.hostName = hostName; + } + + @Override + public boolean send(String message) { + HttpRequest httpRequest = HttpRequest.newBuilder() + .POST(HttpRequest.BodyPublishers.ofString(message)) + .header("Content-Type", "text/plain; charset=utf-8") + .uri(URI.create(hostName + "/mytopic")) + .build(); + + try { + http.send(httpRequest, HttpResponse.BodyHandlers.ofString()); + 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; + } + + public boolean sendFile(Path file) throws FileNotFoundException { + String filename = file.getFileName().toString(); + + HttpRequest httpRequest = HttpRequest.newBuilder() + .PUT(HttpRequest.BodyPublishers.ofFile(file)) + .header("Filename", filename) + .uri(URI.create(hostName + "/mytopic")) + .build(); + + try { + http.send(httpRequest, HttpResponse.BodyHandlers.ofString()); + return true; + } catch (IOException e) { + System.out.println("Error sending file"); + } catch (InterruptedException e) { + System.out.println("Interrupted sending file"); + Thread.currentThread().interrupt(); + } + return false; + } + + @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(this::tryParse) + .filter(m -> "message".equals(m.event())) + .forEach(messageHandler)); + } + + @Override + public List fetchHistory() { + HttpRequest request = HttpRequest.newBuilder() + .GET() + .uri(URI.create(hostName + "/mytopic/json?poll=1&since=all")) + .build(); + List list = new ArrayList<>(); + try { + HttpResponse> response = + http.send(request, HttpResponse.BodyHandlers.ofLines()); + response.body() + .map(this::tryParse) + .filter(Objects::nonNull) + .filter(m -> "message".equals(m.event())) + .forEach(list::add); + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) Thread.currentThread().interrupt(); + } + return list; + } + + private NtfyMessageDto tryParse(String line) { + try { + return mapper.readValue(line, NtfyMessageDto.class); + } catch (Exception ignored) { + 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..746c26ae --- /dev/null +++ b/src/main/java/com/example/NtfyMessageDto.java @@ -0,0 +1,22 @@ +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, + Attachment attachment +) { + @JsonIgnoreProperties(ignoreUnknown = true) + public record Attachment( + String name, + String url, + String type, + Long size, + Long expires + ) {} +} diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 71574a27..04f3f874 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -1,6 +1,13 @@ module hellofx { requires javafx.controls; requires javafx.fxml; + requires io.github.cdimascio.dotenv.java; + requires java.net.http; + requires java.sql; + requires tools.jackson.databind; + requires java.desktop; + requires javafx.graphics; + requires java.compiler; 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..e3523ec6 100644 --- a/src/main/resources/com/example/hello-view.fxml +++ b/src/main/resources/com/example/hello-view.fxml @@ -1,9 +1,127 @@ - - - - - - - + + + + + + + + + + + + +