Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 20 additions & 7 deletions src/main/java/com/example/HelloFX.java
Original file line number Diff line number Diff line change
@@ -1,25 +1,38 @@
package com.example;

import com.example.util.EnvLoader;
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 HelloFX extends Application {

/**
* Initializes the primary application window from the ChatView.fxml layout and displays it.
*
* @param stage the primary stage to initialize and show
* @throws IOException if the FXML resource cannot be loaded
*/
@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");
public void start(Stage stage) throws IOException {
FXMLLoader fxmlLoader = new FXMLLoader(HelloFX.class.getResource("ChatView.fxml"));
Scene scene = new Scene(fxmlLoader.load());

stage.setTitle("Java25 Chat App");
stage.setScene(scene);
stage.show();
}

/**
* Application entry point that loads environment configuration and launches the JavaFX application.
*
* @param args command-line arguments passed to the application
*/
public static void main(String[] args) {
EnvLoader.load();
launch();
}

}
13 changes: 8 additions & 5 deletions src/main/java/com/example/HelloModel.java
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
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.
* Builds a greeting string that includes the JavaFX and Java runtime versions.
*
* <p>The method reads the system properties "javafx.version" and "java.version" and inserts their
* values into the greeting text.
*
* @return the greeting in the form "Hello, JavaFX {javafxVersion}, running on Java {javaVersion}."
*/
public String getGreeting() {
String javaVersion = System.getProperty("java.version");
String javafxVersion = System.getProperty("javafx.version");
return "Hello, JavaFX " + javafxVersion + ", running on Java " + javaVersion + ".";
}
}
}
176 changes: 176 additions & 0 deletions src/main/java/com/example/controller/ChatController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package com.example.controller;

import com.example.model.ChatModel;
import com.example.model.NtfyMessage;
import javafx.fxml.FXML;
import javafx.scene.control.ListView;
import javafx.scene.control.TextField;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.concurrent.Task;
import javafx.geometry.Pos;
import javafx.stage.FileChooser;

import java.io.File;

public class ChatController {

@FXML
private ListView<String> messageListView;

@FXML
private TextField messageInput;

@FXML
private Label statusLabel;

private ChatModel model;

/**
* Initializes controller state and configures the message list view and its listeners.
*
* Instantiates the ChatModel, updates the status display to online, installs a custom cell
* factory to control presentation of message strings, and registers a listener on the model's
* message list that appends newly received NtfyMessage text (prefixed with an emoji) to the
* ListView and scrolls to the newest item.
*/
@FXML
public void initialize() {
this.model = new ChatModel();

updateStatusOnline();

messageListView.setCellFactory(listView -> new ListCell<String>() {
@Override
protected void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null) {
setText(null);
setGraphic(null);
setStyle("");
} else {
setText(item);
setAlignment(Pos.CENTER_LEFT);
setStyle("-fx-background-color: rgba(255, 250, 240, 0.6); " +
"-fx-text-fill: #3d3d3d; " +
"-fx-padding: 14 18 14 18; " +
"-fx-border-color: rgba(214, 69, 69, 0.15); " +
"-fx-border-width: 0 0 0 3; " +
"-fx-font-size: 13px; " +
"-fx-background-radius: 0; " +
"-fx-border-radius: 0; " +
"-fx-effect: dropshadow(gaussian, rgba(0,0,0,0.05), 3, 0, 0, 2);");
}
}
});

model.getMessages().addListener((javafx.collections.ListChangeListener.Change<? extends NtfyMessage> change) -> {
while (change.next()) {
if (change.wasAdded()) {
for (NtfyMessage msg : change.getAddedSubList()) {
String formatted = "🌸 " + msg.message();
messageListView.getItems().add(formatted);
}
messageListView.scrollTo(messageListView.getItems().size() - 1);
}
}
});
}

/**
* Sends the current text from the message input through the model.
*
* If the input contains non-empty text, initiates a background send operation.
* On success the input is cleared and the status is updated to online.
* On failure an error message is written to standard error and the status is updated to offline.
*/
@FXML
private void handleSendButtonAction() {
String text = messageInput.getText();
if (text != null && !text.trim().isEmpty()) {
Task<Void> task = new Task<>() {
@Override
protected Void call() throws Exception {
model.sendMessage(text.trim());
return null;
}
};

task.setOnSucceeded(e -> {
messageInput.clear();
updateStatusOnline();
});

task.setOnFailed(e -> {
System.err.println("Send failed: " + task.getException().getMessage());
updateStatusOffline();
});

new Thread(task).start();
}
}

/**
* Opens a file chooser for the user to pick a file and sends the selected file via the model.
*
* If the user selects a file, the file is sent on a background thread; on success a confirmation
* message is printed to standard output and on failure an error message is printed to standard error.
* If the user cancels the dialog, no action is taken.
*/
@FXML
private void handleAttachFileAction() {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Select File");
File file = fileChooser.showOpenDialog(messageInput.getScene().getWindow());

if (file != null) {
Task<Void> task = new Task<>() {
@Override
protected Void call() throws Exception {
model.sendFile(file);
return null;
}
};

task.setOnSucceeded(e -> {
System.out.println("✅ File info sent: " + file.getName());
});

task.setOnFailed(e -> {
System.err.println("❌ File send failed: " + task.getException().getMessage());
});

new Thread(task).start();
}
}

/**
* Updates the status label to show an "online" state.
*
* If a status label is present, sets its text to "● online" and applies a red text color,
* 10px font size, and a subtle drop shadow; does nothing if the label is null.
*/
private void updateStatusOnline() {
if (statusLabel != null) {
statusLabel.setText("● online");
statusLabel.setStyle("-fx-text-fill: #c93939; " +
"-fx-font-size: 10px; " +
"-fx-effect: dropshadow(gaussian, rgba(0,0,0,0.3), 2, 0, 0, 1);");
}
}

/**
* Update the status label to indicate the application is offline and apply muted styling.
*
* If the status label exists, sets its text to "● offline" and applies a muted brownish color,
* a small font size, and a subtle drop shadow.
*/
private void updateStatusOffline() {
if (statusLabel != null) {
statusLabel.setText("● offline");
statusLabel.setStyle("-fx-text-fill: #6b5d54; " +
"-fx-font-size: 10px; " +
"-fx-effect: dropshadow(gaussian, rgba(0,0,0,0.3), 2, 0, 0, 1);");
}
}
}
114 changes: 114 additions & 0 deletions src/main/java/com/example/model/ChatModel.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package com.example.model;

import com.example.network.ChatNetworkClient;
import com.example.network.NtfyHttpClient;
import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;

import java.io.File;

public class ChatModel {

private final ObservableList<NtfyMessage> messages = FXCollections.observableArrayList();
private final ChatNetworkClient networkClient;
private ChatNetworkClient.Subscription subscription;
private final String baseUrl;
private final String topic;

/**
* Constructs a ChatModel, initializing the network client, selecting the NTFY base URL,
* setting the topic to "myChatTopic", and establishing the subscription.
*
* <p>The base URL is chosen in this order of precedence: the system property
* "NTFY_BASE_URL", the environment variable "NTFY_BASE_URL", and finally the default
* "http://localhost:8080". This constructor prints the chosen URL and calls {@code connect()}
* to begin receiving messages.
*/
public ChatModel() {
this.networkClient = new NtfyHttpClient();

String url = System.getProperty("NTFY_BASE_URL");
if (url == null || url.isBlank()) {
url = System.getenv("NTFY_BASE_URL");
}
if (url == null || url.isBlank()) {
url = "http://localhost:8080";
}

this.baseUrl = url;
this.topic = "myChatTopic";

System.out.println("Using NTFY URL: " + this.baseUrl);
connect();
}

/**
* Provides the observable list of chat messages.
*
* @return the observable list of NtfyMessage instances; changes to this list are observable and reflect the model's current messages
*/
public ObservableList<NtfyMessage> getMessages() {
return messages;
}

/**
* Establishes a subscription to the configured Ntfy topic and begins receiving messages.
*
* Incoming messages are appended to the model's observable messages list on the JavaFX
* application thread. Subscription errors are written to standard error.
*/
public void connect() {
this.subscription = networkClient.subscribe(
baseUrl,
topic,
msg -> Platform.runLater(() -> messages.add(msg)),
error -> System.err.println("Error: " + error.getMessage())
);
}

/**
* Publish a text message to the configured chat topic.
*
* @param text the message content to send
* @throws Exception if sending the message fails
*/
public void sendMessage(String text) throws Exception {
NtfyMessage message = new NtfyMessage(topic, text);
networkClient.send(baseUrl, message);
}

/**
* Sends the given file to the configured chat topic and posts a chat message describing the file.
*
* Validates that the file exists, uploads it via the network client, then sends a message containing
* the file name and a human-readable file size.
*
* @param file the file to send; must be non-null and exist on disk
* @throws IllegalArgumentException if {@code file} is null or does not exist
* @throws Exception if the upload or subsequent message send fails
*/
public void sendFile(File file) throws Exception {
if (file == null || !file.exists()) {
throw new IllegalArgumentException("File not found");
}

networkClient.sendFile(baseUrl, topic, file);

String fileMessage = "📎 " + file.getName() + " (" + formatFileSize(file.length()) + ")";
sendMessage(fileMessage);
}

/**
* Format a byte count into a human-readable string using B, KB, MB, or GB.
*
* @param size the size in bytes
* @return a formatted size string (e.g., "512 B", "1.5 KB", "2.0 MB", or "3.2 GB")
*/
private String formatFileSize(long size) {
if (size < 1024) return size + " B";
if (size < 1024 * 1024) return String.format("%.1f KB", size / 1024.0);
if (size < 1024 * 1024 * 1024) return String.format("%.1f MB", size / (1024.0 * 1024));
return String.format("%.1f GB", size / (1024.0 * 1024 * 1024));
}
}
Loading