diff --git a/.gitignore b/.gitignore
index 6ac465db..97c21425 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,4 @@
target/
/.idea/
+.env
+*.iml
diff --git a/pom.xml b/pom.xml
index c40f667e..3b7e09b2 100644
--- a/pom.xml
+++ b/pom.xml
@@ -11,15 +11,22 @@
25
UTF-8
- 6.0.0
+ 5.9.2
3.27.6
5.20.0
25
+ 4.0.16-alpha
org.junit.jupiter
- junit-jupiter
+ junit-jupiter-api
+ ${junit.jupiter.version}
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter-engine
${junit.jupiter.version}
test
@@ -35,6 +42,18 @@
${mockito.version}
test
+
+ org.testfx
+ testfx-junit5
+ ${testfx.version}
+ test
+
+
+ org.testfx
+ testfx-core
+ ${testfx.version}
+ test
+
org.openjfx
javafx-controls
@@ -45,8 +64,45 @@
javafx-fxml
${javafx.version}
+
+ org.openjfx
+ javafx-base
+ ${javafx.version}
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ 2.17.0
+
+
+ io.github.cdimascio
+ dotenv-java
+ 3.1.0
+
+
+ org.jetbrains
+ annotations
+ 24.0.1
+
+
+ org.testng
+ testng
+ RELEASE
+ compile
+
+
+
+ src/main/resources
+
+ **/*.fxml
+ **/*.css
+ **/*.jpg
+ **/*.png
+
+
+
org.openjfx
@@ -63,6 +119,17 @@
true
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.2.5
+
+
+ **/*Test.java
+ **/*IT.java
+
+
+
diff --git a/src/main/java/com/example/ChatNetworkClient.java b/src/main/java/com/example/ChatNetworkClient.java
new file mode 100644
index 00000000..f65f0106
--- /dev/null
+++ b/src/main/java/com/example/ChatNetworkClient.java
@@ -0,0 +1,19 @@
+package com.example;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.util.function.Consumer;
+
+public interface ChatNetworkClient {
+ void send(String baseUrl, NtfyMessage message) throws Exception;
+
+ //Returnerar mitt egna Subscription-interface
+ Subscription subscribe(String baseUrl, String topic, Consumer messageHandler);
+
+ //Inner interface för Subscription
+ interface Subscription extends Closeable {
+ @Override
+ void close() throws IOException;
+ boolean isOpen();
+ }
+}
diff --git a/src/main/java/com/example/HelloController.java b/src/main/java/com/example/HelloController.java
index fdd160a0..ff939345 100644
--- a/src/main/java/com/example/HelloController.java
+++ b/src/main/java/com/example/HelloController.java
@@ -1,22 +1,155 @@
package com.example;
+import javafx.beans.binding.Bindings;
import javafx.fxml.FXML;
import javafx.scene.control.Label;
+import javafx.scene.control.ListView;
+import javafx.scene.control.TextField;
+import javafx.scene.control.Button;
+
+import java.io.IOException;
+import java.util.UUID;
/**
* Controller layer: mediates between the view (FXML) and the model.
*/
public class HelloController {
- private final HelloModel model = new HelloModel();
+ private final HelloModel model;
+ private final ChatNetworkClient httpClient; // Abstraktion (Dependency Inversion)
+ private final String hostName;
+ private ChatNetworkClient.Subscription subscription;
+ private long subscriptionStartTime;
- @FXML
- private Label messageLabel;
+ @FXML private Label messageLabel;
+
+ //Nya FXML-komponenter:
+ @FXML private ListView messageListView; //Visar meddelanden från HelloModel
+ @FXML private TextField messageTextField; //Används för att skriva nya meddelanden
+ @FXML private Button sendButton; //Används för att skicka meddelanden
+ @FXML private Label connectionStatusLabel; // Status för anslutning
+
+ //Konstruktor för Dependency Injection
+ public HelloController(HelloModel model, ChatNetworkClient httpClient, String hostName) {
+ this.model = model;
+ this.httpClient = httpClient;
+ this.hostName = hostName;
+ }
@FXML
private void initialize() {
if (messageLabel != null) {
messageLabel.setText(model.getGreeting());
}
+ //Binder listView till meddelandelistan i modellen
+ messageListView.setItems(model.getMessages());
+
+ //Binder anslutningsstatus till etiketten
+ connectionStatusLabel.textProperty().bind(
+ Bindings.when(model.connectedProperty())
+ .then("Ansluten: ja")
+ .otherwise("Ansluten: nej")
+ );
+ }
+
+ @FXML
+ private void sendMessage() {
+ String messageText = messageTextField.getText();
+ if (!messageText.isEmpty()) {
+ try {
+ NtfyMessage message = createMessage(messageText);
+ httpClient.send(hostName, message);
+ messageTextField.clear();
+ } catch (Exception e) {
+ handleSendError(e);
+ }
+ }
+ }
+
+ private NtfyMessage createMessage(String text) {
+ return new NtfyMessage(
+ UUID.randomUUID().toString(),
+ System.currentTimeMillis(),
+ "message",
+ "mytopic",
+ text
+ );
+ }
+
+ private void handleSendError(Exception e) {
+ System.err.println("Fel vid sändning: " + e.getMessage());
+ }
+
+
+ @FXML
+ private void subscribeToTopic() {
+ unsubscribeFromCurrentTopic(); // Stäng den gamla prenumerationen
+ model.getMessages().clear(); // Rensa gamla meddelanden
+ startNewSubscription(); // Starta ny prenumeration
+ model.setConnected(true); // Uppdatera anslutningsstatus
+ }
+
+ private void unsubscribeFromCurrentTopic() {
+ if (subscription != null) {
+ try {
+ subscription.close();
+ } catch (IOException e) {
+ System.err.println("Kunde inte stänga gammal prenumeration: " + e.getMessage());
+ }
+ }
+ }
+
+ private void startNewSubscription() {
+ subscriptionStartTime = System.currentTimeMillis(); // Spara starttiden
+ subscription = httpClient.subscribe(
+ hostName,
+ "mytopic",
+ this::handleIncomingMessage
+ );
+ }
+
+ private void handleIncomingMessage(NtfyMessage message) {
+ System.out.println("Mottaget meddelande: " + message.id() + ", tid: " + message.time());
+ long messageTimeSeconds = message.time();
+ long currentTimeSeconds = System.currentTimeMillis() / 1000;
+
+ // Hoppa över gamla meddelanden
+ if (messageTimeSeconds < (subscriptionStartTime / 1000)) {
+ System.out.println("Hoppar över (gammalt meddelande).");
+ return;
+ }
+
+ // Hoppa över dubbletter
+ if (isDuplicateMessage(message)) {
+ System.out.println("Hoppar över (dublett).");
+ return;
+ }
+
+ // Lägg till meddelandet i modellen
+ System.out.println("Lägger till i modellen: " + message.message());
+ model.addMessage(message);
+ }
+
+ private boolean isDuplicateMessage(NtfyMessage message) {
+ return model.getMessages().stream()
+ .anyMatch(existingMessage -> existingMessage.id().equals(message.id()));
+ }
+
+
+ @FXML
+ private void unsubscribeFromTopic() {
+ if (subscription != null) {
+ closeSubscription();
+ model.setConnected(false);
+ subscription = null; // Rensa referensen
+ }
+ }
+
+ private void closeSubscription() {
+ try {
+ subscription.close();
+ } catch (IOException e) {
+ System.err.println("Fel vid avprenumeration: " + e.getMessage());
+ }
}
}
diff --git a/src/main/java/com/example/HelloFX.java b/src/main/java/com/example/HelloFX.java
index 96bdc5ca..0b764cbb 100644
--- a/src/main/java/com/example/HelloFX.java
+++ b/src/main/java/com/example/HelloFX.java
@@ -1,19 +1,48 @@
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.stage.Stage;
+import java.util.Objects;
+
public class HelloFX extends Application {
@Override
public void start(Stage stage) throws Exception {
- FXMLLoader fxmlLoader = new FXMLLoader(HelloFX.class.getResource("hello-view.fxml"));
+ Dotenv dotenv = Dotenv.load(); //Ladda .env-filen
+
+ String hostName = dotenv.get("HOST_NAME");
+
+ //Validering (valfritt)
+ if (hostName == null) {
+ throw new IllegalStateException(
+ "Kunde inte läsa HOST_NAME från .env-filen. " +
+ "Kontrollera att filen finns i projektets rotmapp!"
+ );
+ }
+
+ System.out.println("Ansluter till ntfy på: " + hostName); //Debug-logg
+
+ HelloModel model = new HelloModel();
+ ChatNetworkClient httpClient = new NtfyHttpClient();
+
+ FXMLLoader fxmlLoader = new FXMLLoader(
+ HelloFX.class.getResource("/com/example/hello-view.fxml")
+ );
+ fxmlLoader.setControllerFactory(c -> new HelloController(model, httpClient, hostName));
+
Parent root = fxmlLoader.load();
Scene scene = new Scene(root, 640, 480);
- stage.setTitle("Hello MVC");
+
+ scene.getStylesheets().add(
+ Objects.requireNonNull(getClass().getResource("/com/example/styles.css")).toExternalForm()
+ );
+
+ stage.setTitle("Chat App");
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..fb611a3d 100644
--- a/src/main/java/com/example/HelloModel.java
+++ b/src/main/java/com/example/HelloModel.java
@@ -1,5 +1,12 @@
package com.example;
+import javafx.application.Platform;
+import javafx.beans.property.BooleanProperty;
+import javafx.beans.property.ReadOnlyBooleanProperty;
+import javafx.beans.property.SimpleBooleanProperty;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+
/**
* Model layer: encapsulates application data and business logic.
*/
@@ -7,9 +14,53 @@ public class HelloModel {
/**
* Returns a greeting based on the current Java and JavaFX versions.
*/
+
+ private final ObservableList messages = FXCollections.observableArrayList();
+ private final BooleanProperty connected = new SimpleBooleanProperty(false);
+ private boolean isTesting = false; // <-- Lägg till denna rad
+
public String getGreeting() {
String javaVersion = System.getProperty("java.version");
String javafxVersion = System.getProperty("javafx.version");
return "Hello, JavaFX " + javafxVersion + ", running on Java " + javaVersion + ".";
}
+
+ public ObservableList getMessages() {
+ return messages;
+ }
+
+
+ public ReadOnlyBooleanProperty connectedProperty() {
+ return connected;
+ }
+
+ public void setTesting(boolean testing) { // <-- Lägg till denna metod
+ this.isTesting = testing;
+ }
+
+ // Ersätt den gamla runOnFX-metoden med denna:
+ private void runOnFX(Runnable task) {
+ if (isTesting) { // <-- Ny logik för tester
+ task.run();
+ } else if (Platform.isFxApplicationThread()) {
+ task.run();
+ } else {
+ try {
+ Platform.runLater(task);
+ } catch (IllegalThreadStateException notInitialized) {
+ task.run(); // Fallback för enhetstester (om JavaFX inte är initierat)
+ }
+ }
+
+ }
+
+ public void addMessage(NtfyMessage message) {
+ runOnFX(() -> messages.add(message));
+ }
+
+ public void setConnected(boolean connected) {
+ runOnFX(() -> this.connected.set(connected));
+ }
+
+
}
diff --git a/src/main/java/com/example/NtfyHttpClient.java b/src/main/java/com/example/NtfyHttpClient.java
new file mode 100644
index 00000000..1461d5a6
--- /dev/null
+++ b/src/main/java/com/example/NtfyHttpClient.java
@@ -0,0 +1,97 @@
+package com.example;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import javafx.application.Platform;
+
+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.concurrent.atomic.AtomicBoolean;
+import java.util.function.Consumer;
+
+public class NtfyHttpClient implements ChatNetworkClient{
+ private final HttpClient http; //Singleton (återanvänds för alla anrop)
+ private final ObjectMapper mapper;
+
+ public NtfyHttpClient() {
+ this.http = HttpClient.newHttpClient(); //Implicit Singleton
+ this.mapper = new ObjectMapper();
+ }
+
+ @Override
+ public void send(String baseUrl, NtfyMessage message) throws Exception{
+ //Builder Pattern för HttpRequest
+ HttpRequest request = HttpRequest.newBuilder()
+ .POST(HttpRequest.BodyPublishers.ofString(message.message()))
+ .header("Content-Type", "application/json")
+ .uri(URI.create(baseUrl + "/" + message.topic()))
+ .build();
+
+ //Debug-loggning
+ System.out.println("Skickar till: " + baseUrl + "/" + message.topic());
+ System.out.println("Meddelande: " + message.message());
+
+ //Synkront anrop (blockerande)
+ HttpResponse response = http.send(
+ request,
+ HttpResponse.BodyHandlers.discarding()
+ );
+ //validera statuskoden
+ if (response.statusCode() >= 400) {
+ throw new IOException("Fel vid sändning: HTTP " + response.statusCode());
+ }
+ System.out.println("Meddelandet skickat! Statuskod: " + response.statusCode());
+ }
+
+ public Subscription subscribe(String baseUrl, String topic, Consumer messageHandler) {
+ AtomicBoolean isOpen = new AtomicBoolean(true); //Spåra prenumerationens status
+
+ http.sendAsync(
+ HttpRequest.newBuilder()
+ .GET()
+ .uri(URI.create(baseUrl + "/" + topic + "/json"))
+ .build(),
+ HttpResponse.BodyHandlers.ofLines()
+ ).thenAccept(response -> {
+ System.out.println("Prenumeration startad för topic: " + topic); //Debug-loggning
+
+ //Logga varje rad som mottas från servern
+ response.body()
+ .peek(line -> System.out.println("Mottagen rad från servern: " + line))
+ .takeWhile(line -> isOpen.get()) //Avbryt strömmen om isOpen = false
+ .map(line-> {
+ try {
+ NtfyMessage message = mapper.readValue(line, NtfyMessage.class);
+ System.out.println("Parsat meddelande: " + message); //Debug-loggning
+ return message;
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ })
+ .filter(message -> message.event().equals("message"))
+ .forEach(message -> {
+ if (isOpen.get()) { //Endast om prenumerationen är öppen
+ Platform.runLater(() -> messageHandler.accept(message));
+ }
+ });
+ }).exceptionally(error -> {
+ System.err.println("Fel vid prenumeration: " + error.getMessage());
+ return null;
+ });
+
+ //Command Pattern: Returnera ett Subscription-objekt
+ return new Subscription() {
+ @Override
+ public void close() throws IOException {
+ isOpen.set(false); //Stäng strömmen
+ }
+
+ @Override
+ public boolean isOpen() {
+ return isOpen.get();
+ }
+ };
+ }
+}
diff --git a/src/main/java/com/example/NtfyMessage.java b/src/main/java/com/example/NtfyMessage.java
new file mode 100644
index 00000000..bb8a6a40
--- /dev/null
+++ b/src/main/java/com/example/NtfyMessage.java
@@ -0,0 +1,19 @@
+package com.example;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import org.jetbrains.annotations.NotNull;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public record NtfyMessage(
+ String id,
+ Long time,
+ String event,
+ String topic,
+ String message) {
+
+ @Override
+ @NotNull
+ public String toString(){
+ return message != null ? message : String.format("NtfyMessage[event=%s]", event);
+ }
+}
diff --git a/src/main/java/com/example/Subscription.java b/src/main/java/com/example/Subscription.java
new file mode 100644
index 00000000..73328460
--- /dev/null
+++ b/src/main/java/com/example/Subscription.java
@@ -0,0 +1,11 @@
+package com.example;
+
+import java.io.Closeable;
+import java.io.IOException;
+
+public interface Subscription extends Closeable {
+
+ @Override
+ void close() throws IOException;
+ boolean isOpen();
+}
diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java
index 71574a27..4562bb52 100644
--- a/src/main/java/module-info.java
+++ b/src/main/java/module-info.java
@@ -1,6 +1,11 @@
module hellofx {
requires javafx.controls;
requires javafx.fxml;
+ requires com.fasterxml.jackson.annotation;
+ requires java.net.http;
+ requires io.github.cdimascio.dotenv.java;
+ requires com.fasterxml.jackson.databind;
+ requires org.jetbrains.annotations;
opens com.example to javafx.fxml;
exports com.example;
diff --git a/src/main/resources/com/example/background.jpg b/src/main/resources/com/example/background.jpg
new file mode 100644
index 00000000..e3778815
Binary files /dev/null and b/src/main/resources/com/example/background.jpg differ
diff --git a/src/main/resources/com/example/hello-view.fxml b/src/main/resources/com/example/hello-view.fxml
index 20a7dc82..5d388fec 100644
--- a/src/main/resources/com/example/hello-view.fxml
+++ b/src/main/resources/com/example/hello-view.fxml
@@ -1,9 +1,37 @@
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/com/example/styles.css b/src/main/resources/com/example/styles.css
new file mode 100644
index 00000000..53164fcf
--- /dev/null
+++ b/src/main/resources/com/example/styles.css
@@ -0,0 +1,36 @@
+.root-pane {
+ -fx-background-image: url('background.jpg');
+ -fx-background-size: cover;
+ -fx-background-repeat: no-repeat;
+}
+
+.list-view {
+ -fx-background-color: transparent;
+ -fx-border-color: rgba(255, 215, 0, 0.5);
+ -fx-border-width: 2px;
+ -fx-border-radius: 5px;
+}
+
+.list-view .list-cell {
+ -fx-background-color: rgba(0, 0, 0, 0.5);
+ -fx-text-fill: white;
+ -fx-padding: 8px;
+ -fx-border-radius: 5px;
+ -fx-border-color: rgba(255, 215, 0, 0.3);
+}
+
+.list-view .list-cell:odd {
+ -fx-background-color: rgba(20, 20, 50, 0.5);
+}
+
+.list-view .list-cell:even {
+ -fx-background-color: rgba(50, 50, 80, 0.5);
+}
+
+.label {
+ -fx-text-fill: white;
+}
+
+.button {
+ -fx-pref-width: 120px;
+}
\ No newline at end of file
diff --git a/src/test/java/com/example/HelloModelTest.java b/src/test/java/com/example/HelloModelTest.java
new file mode 100644
index 00000000..7b6b43ac
--- /dev/null
+++ b/src/test/java/com/example/HelloModelTest.java
@@ -0,0 +1,34 @@
+package com.example;
+
+import org.junit.jupiter.api.Test;
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class HelloModelTest {
+
+ @Test
+ void testGetMessages_ReturnsEmptyListInitially() {
+ HelloModel model = new HelloModel();
+ assertThat(model.getMessages()).isEmpty();
+ }
+
+
+ @Test
+ void testAddMessage_AddsMessageToList() {
+ HelloModel model = new HelloModel();
+ model.setTesting(true); //Aktivera testläge
+ NtfyMessage testMessage = new NtfyMessage(
+ "123",
+ System.currentTimeMillis() / 1000,
+ "message",
+ "mytopic",
+ "Testmeddelande"
+ );
+ model.addMessage(testMessage);
+ assertThat(model.getMessages())
+ .hasSize(1)
+ .containsExactly(testMessage);
+
+
+ }
+
+}