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..203cdfa6 100644 --- a/pom.xml +++ b/pom.xml @@ -17,12 +17,29 @@ 25 + + + tools.jackson.core + jackson-databind + 3.0.1 + + + io.github.cdimascio + dotenv-java + 3.2.0 + org.junit.jupiter junit-jupiter ${junit.jupiter.version} test + + org.wiremock + wiremock + 4.0.0-beta.15 + test + org.assertj assertj-core @@ -45,6 +62,12 @@ javafx-fxml ${javafx.version} + + org.awaitility + awaitility + 4.3.0 + test + @@ -63,6 +86,15 @@ true + + org.apache.maven.plugins + maven-compiler-plugin + + 25 + 25 + --enable-preview + + diff --git a/src/main/java/com/example/HelloController.java b/src/main/java/com/example/HelloController.java index fdd160a0..f630db9c 100644 --- a/src/main/java/com/example/HelloController.java +++ b/src/main/java/com/example/HelloController.java @@ -1,22 +1,148 @@ package com.example; +import javafx.application.Platform; +import javafx.collections.ListChangeListener; import javafx.fxml.FXML; -import javafx.scene.control.Label; +import javafx.geometry.Pos; +import javafx.scene.control.*; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; /** * Controller layer: mediates between the view (FXML) and the model. + * Handles updates of the chat-window. */ public class HelloController { - private final HelloModel model = new HelloModel(); + //En model skapas som i bakgrunden är en lista och håller koll på meddelanden + private final HelloModel model = new HelloModel(new NtfyConnectionImpl()); + //@FXML kopplingar @FXML - private Label messageLabel; + private Button connectToServer; + @FXML + private Button disconnectFromServer; + + //Kopplar ett textfält från FXML där användaren skriver ett meddelande + @FXML + private TextField messageInput; + + //Kopplar en knapp från FXML som klickas på för att skicka meddelandet + @FXML + private Button sendButton; + + + //Ytan för alla meddelanden som visas + @FXML + private ListView chatBox; + + //Metoden körs automatiskt när appen startar @FXML private void initialize() { - if (messageLabel != null) { - messageLabel.setText(model.getGreeting()); + //todo: Initialisera uppkopplingsknapparna för server-anslutning + //todo: metod för nätverksuppkoppling? + + //Sätter ursprungstillståndet (default) för skicka-knappen + + updateSendButtonState(); + + //Lägger till en lyssnare för att uppdatera knappen vid inmatning av text + messageInput.textProperty().addListener((observable, oldValue, newValue) -> updateSendButtonState()); + + //Om användaren trycker på Enter eller klickar med musen -> skicka meddelandet + messageInput.setOnAction((event) -> sendMessageToModel()); + sendButton.setOnAction(event -> sendMessageToModel()); + + disconnectFromServer.setOnAction(event -> setDisconnectFromServer()); + connectToServer.setOnAction(event -> setConnectToServer()); + + disconnectFromServer.setDisable(true); + + //model.receiveMessage(); + //Styr hur varje meddelande ska visas i chatboxen + chatBox.setCellFactory(listView -> new ListCell<>() { + @Override + protected void updateItem(NtfyMessageDto item, boolean empty) { + super.updateItem(item, empty); + //Kräver en null check då JavaFX återanvänder cellerna + if (item == null || empty) { + setText(null); + setGraphic(null); + } + else{ + //Skapar en label med meddelande-texten och sätter en stil från css + Label label = new Label(item.message()); + label.getStyleClass().add("message-bubble"); + + String time = item.formattedTime(); + Label labelTime = new Label(time); + labelTime.getStyleClass().add("time-stamp"); + + //Layout + VBox messageBox = new VBox(label, labelTime); + messageBox.setSpacing(2); + + //Vänster eller höger i ListView + HBox hbox = new HBox(messageBox); + hbox.setMaxWidth(chatBox.getWidth()-20); + + String messagePosition = item.message(); + if (messagePosition != null && messagePosition.startsWith("User:")) { + hbox.setAlignment(javafx.geometry.Pos.CENTER_RIGHT); + label.getStyleClass().add("outgoing-message"); + } else { + hbox.setAlignment(javafx.geometry.Pos.CENTER_LEFT); + label.getStyleClass().add("incoming-message"); + } + setGraphic(hbox); + } + } + }); + //Kopplar Listan i view med ObservableList i HelloModel + chatBox.setItems(model.getMessages()); + //Flyttar Platform.runlater till controller på grund av runtimeexeption när tester körs, även för en mer solid MVC + model.getMessages().addListener((ListChangeListener) changes -> { + chatBox.refresh(); + }); + } + + private void sendMessageToModel() { + String outgoingMessage = messageInput.getText().trim(); + //Kontrollerar om text-fältet är tomt + if (!outgoingMessage.isEmpty()) { + model.sendMessage("User: " + outgoingMessage); + //tömmer sedan fältet där text matas in(prompt-meddelande visas igen) + messageInput.clear(); } } -} + + private void updateSendButtonState() { + // Kollar om texten, efter att ha tagit bort ledande/efterföljande mellanslag, är tom. + boolean isTextPresent = !messageInput.getText().trim().isEmpty(); + + //Nytt villkor för button för att ej kunna skicka meddelanden till servern om ej connectad + boolean isConnected = !disconnectFromServer.isDisabled(); + + // Sätt disable till TRUE om det INTE finns text. + sendButton.setDisable(!isTextPresent || !isConnected); + } + + //Starta prenumeration via model och uppdaterar button + public void setConnectToServer() { + if(disconnectFromServer.isDisable()) { + model.receiveMessage(); + connectToServer.setDisable(true); + disconnectFromServer.setDisable(false); + updateSendButtonState(); + } + } + + //Stoppar prenumerationen och uppdaterar button + public void setDisconnectFromServer() { + model.stopSubscription(); + connectToServer.setDisable(false); + disconnectFromServer.setDisable(true); + updateSendButtonState(); + } +} \ 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..3578b648 100644 --- a/src/main/java/com/example/HelloFX.java +++ b/src/main/java/com/example/HelloFX.java @@ -1,19 +1,24 @@ package com.example; +import io.github.cdimascio.dotenv.Dotenv; import javafx.application.Application; import javafx.fxml.FXMLLoader; import javafx.scene.Parent; import javafx.scene.Scene; +import javafx.scene.image.Image; 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"); + Scene scene = new Scene(root, 540, 650); + stage.setTitle("CatCode Messenger"); + stage.getIcons().add(new Image(getClass().getResourceAsStream("/coolCat.png"))); + 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..67bbddf2 100644 --- a/src/main/java/com/example/HelloModel.java +++ b/src/main/java/com/example/HelloModel.java @@ -1,15 +1,86 @@ package com.example; +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 java.io.IOException; + + /** * Model layer: encapsulates application data and business logic. */ public class HelloModel { /** - * Returns a greeting based on the current Java and JavaFX versions. + * Handles and returns a list of messages observed by JavaFX + * Stores, changes and returns data. */ - public String getGreeting() { - String javaVersion = System.getProperty("java.version"); - String javafxVersion = System.getProperty("javafx.version"); - return "Hello, JavaFX " + javafxVersion + ", running on Java " + javaVersion + "."; + + //Lista som håller alla meddelanden + //FXCollections.observableArrayList() = Nyckel som gör listan ändrings-bar och uppdaterar GUIt + private final ObservableList messages = FXCollections.observableArrayList(); + +//Kopplar upp till nätverket , används för att skicka och ta emot meddelanden + private final NtfyConnection connection; + //Innehåller meddelandet som ska skickas, kopplat till GUI via SimpleStringProperty + private final StringProperty messageToSend = new SimpleStringProperty(); + //Fält för att kunna styra anslutningen + private Subscription subscription = null; + + //Konstruktorn tar emot nätverkskoppling, antingen ett test via spy eller en riktig via impl + public HelloModel(NtfyConnection connection) { + + this.connection = connection; + //subscription = receiveMessage(); //subscription startar automatiskt när modellen skapas + } + + //getter från private, används av controller för att koppla til ListView + public ObservableList getMessages() { + return messages; + } + //test + public String getMessageToSend() { + return messageToSend.get(); + } +//Getter från private för meddelandet som ska skickas + public StringProperty messageToSendProperty() { + return messageToSend; + } +//Sätter meddelande för tester + public void setMessageToSend(String message) { + messageToSend.set(message); + } + + //Sätter meddelandet till inkommande parameter från test, eller controller (connection skickar till nätverket) + public void sendMessage(String message) { + + messageToSend.set(message); + connection.send(messageToSend.get()); + + } + + //Startar en prenumeration på inkommande meddelnaden, + //Returnerar ett Subscription-objekt så den kan stoppas + public Subscription receiveMessage() { +if(subscription != null && subscription.isOpen()) { + return this.subscription; +} + return subscription = connection.receive(messages::add); + + + } + + public void stopSubscription() { + if (subscription != null && subscription.isOpen()) + try{ + subscription.close(); + } catch(IOException e) { + System.out.println("Error closing subscription" + e.getMessage()); + } } } + + diff --git a/src/main/java/com/example/NtfyConnection.java b/src/main/java/com/example/NtfyConnection.java new file mode 100644 index 00000000..39fe8105 --- /dev/null +++ b/src/main/java/com/example/NtfyConnection.java @@ -0,0 +1,17 @@ +package com.example; + + +import java.nio.file.Path; +import java.util.function.Consumer; + +public interface NtfyConnection { + + //Skicka ett meddelande till servern + boolean send(String message); + + boolean sendFile(Path file, String messageWithFile); + + //Startar en prenumeration och tar emot en consumer som ska köras varje gång ett meddelande kommer + Subscription receive(Consumer consumer); + +} diff --git a/src/main/java/com/example/NtfyConnectionImpl.java b/src/main/java/com/example/NtfyConnectionImpl.java new file mode 100644 index 00000000..4765ff36 --- /dev/null +++ b/src/main/java/com/example/NtfyConnectionImpl.java @@ -0,0 +1,88 @@ +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.nio.file.Path; +import java.util.Objects; +import java.util.function.Consumer; + +public class NtfyConnectionImpl implements NtfyConnection { + //Adressen till servern + private final String hostName; + //För att skicka och ta emot HTTP-meddelanden + private final HttpClient http = HttpClient.newHttpClient(); + //För att konvertera JSON till java objekt + 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; + } + + //Skickar ett meddelande till servern via POST + @Override + public boolean send(String message) { + //Send message to client - HTTP meddelande + String inputMessage = Objects.requireNonNull(message); + HttpRequest httpRequest = HttpRequest.newBuilder() + .POST(HttpRequest.BodyPublishers.ofString(inputMessage)) + .header("Cache", "no") + .uri(URI.create(hostName + "/catChat")) + .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("Sending message interrupted"); + } + return false; + } + + @Override + public boolean sendFile(Path file, String messageWithFile) { + return false; + } + + + //Skapar en asynkron (flera trådar) GET-stream + //VArje rad ändras till ett NtfyMessageDTO och skickas till messageHandler, hopp in i model + //Returnerar ett objekt av Subscription som kan stoppa streamen(connected.cancel(true)) + @Override + public Subscription receive(Consumer messageHandler) { + HttpRequest httpRequest = HttpRequest.newBuilder() + .GET() + .uri(URI.create(hostName + "/catChat/json")) + .build(); + + var connected = http.sendAsync(httpRequest, HttpResponse.BodyHandlers.ofLines()) + .thenAccept(response -> response.body() + .map(s -> mapper.readValue(s, NtfyMessageDto.class)) + .filter(message -> message.event().equals("message")) + .peek(System.out::println) + .forEach(message -> Platform.runLater(()-> messageHandler.accept(message)))); + return new Subscription() { + @Override + public void close() { + connected.cancel(true); + } + + @Override + public boolean isOpen() { + return !connected.isDone(); + } + }; + } +} diff --git a/src/main/java/com/example/NtfyMessageDto.java b/src/main/java/com/example/NtfyMessageDto.java new file mode 100644 index 00000000..c72e02e8 --- /dev/null +++ b/src/main/java/com/example/NtfyMessageDto.java @@ -0,0 +1,25 @@ +package com.example; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record NtfyMessageDto(String id, Long time, String event, String topic, String message) { + //Convenience Konstruktor - som en genväg till record fält +// public NtfyMessageDto(String message) { +// this("thisID", System.currentTimeMillis()/1000, "message", "catChat", message); +// } + + + public String formattedTime() { + if (time == null) return ""; + return DateTimeFormatter.ofPattern("HH:mm") + .format(Instant.ofEpochSecond(time) + .atZone(ZoneId.of("Europe/Stockholm"))); + + } + +} diff --git a/src/main/java/com/example/Subscription.java b/src/main/java/com/example/Subscription.java new file mode 100644 index 00000000..7fb9602b --- /dev/null +++ b/src/main/java/com/example/Subscription.java @@ -0,0 +1,13 @@ +package com.example; + +import java.io.Closeable; +import java.io.IOException; + +public interface Subscription extends Closeable { + //Stoppar en subscription + @Override + void close() throws IOException; + + //Meddelar om Subscription är öppen + boolean isOpen(); +} 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/cat_pattern.png b/src/main/resources/cat_pattern.png new file mode 100644 index 00000000..e283fe62 Binary files /dev/null and b/src/main/resources/cat_pattern.png differ diff --git a/src/main/resources/com/example/hello-view.fxml b/src/main/resources/com/example/hello-view.fxml index 20a7dc82..596bcc8b 100644 --- a/src/main/resources/com/example/hello-view.fxml +++ b/src/main/resources/com/example/hello-view.fxml @@ -1,9 +1,31 @@ - - - - - - - + + + + + + + + + + +