From c0feda4ba84c0a66bcf6b7ef239a1d87a9d96c94 Mon Sep 17 00:00:00 2001 From: jzitnik-dev Date: Fri, 29 May 2026 09:30:17 +0200 Subject: [PATCH] feat: Implemented fetching and a lot of other stuff --- .idea/vcs.xml | 7 + TestAvg.java | 16 + jecnaapi | 1 + pom.xml | 14 +- src/main/java/cz/jzitnik/Main.java | 59 +++- .../java/cz/jzitnik/auth/CredentialStore.java | 125 ++++++++ .../controllers/DashboardBaseController.java | 85 +++++ .../controllers/DashboardController.java | 26 ++ .../jzitnik/controllers/GradesController.java | 301 ++++++++++++++++++ .../jzitnik/controllers/HomeController.java | 7 +- .../jzitnik/controllers/LoginController.java | 54 +++- .../java/cz/jzitnik/query/QueryClient.java | 77 +++++ .../java/cz/jzitnik/query/QueryOptions.java | 18 ++ .../java/cz/jzitnik/query/QueryResult.java | 21 ++ src/main/java/cz/jzitnik/router/Route.java | 1 + src/main/java/cz/jzitnik/router/Router.java | 20 +- src/main/java/cz/jzitnik/state/AppState.java | 59 +++- .../java/cz/jzitnik/state/StateManager.java | 53 ++- .../java/cz/jzitnik/ui/AuroraBackground.java | 11 - src/main/resources/dashboard_generic.fxml | 32 ++ src/main/resources/dashboard_modern.fxml | 51 +++ src/main/resources/grades.fxml | 40 +++ src/main/resources/login.fxml | 9 +- src/main/resources/styles/dashboard.css | 124 ++++++++ 24 files changed, 1129 insertions(+), 82 deletions(-) create mode 100644 .idea/vcs.xml create mode 100644 TestAvg.java create mode 120000 jecnaapi create mode 100644 src/main/java/cz/jzitnik/auth/CredentialStore.java create mode 100644 src/main/java/cz/jzitnik/controllers/DashboardBaseController.java create mode 100644 src/main/java/cz/jzitnik/controllers/DashboardController.java create mode 100644 src/main/java/cz/jzitnik/controllers/GradesController.java create mode 100644 src/main/java/cz/jzitnik/query/QueryClient.java create mode 100644 src/main/java/cz/jzitnik/query/QueryOptions.java create mode 100644 src/main/java/cz/jzitnik/query/QueryResult.java create mode 100644 src/main/resources/dashboard_generic.fxml create mode 100644 src/main/resources/dashboard_modern.fxml create mode 100644 src/main/resources/grades.fxml create mode 100644 src/main/resources/styles/dashboard.css diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..759214f --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/TestAvg.java b/TestAvg.java new file mode 100644 index 0000000..7d0fbb8 --- /dev/null +++ b/TestAvg.java @@ -0,0 +1,16 @@ +import io.github.tomhula.jecnaapi.data.grade.*; +import io.github.tomhula.jecnaapi.util.Name; +import kotlinx.datetime.LocalDate; +import java.util.Map; +import java.util.List; + +public class TestAvg { + public static void main(String[] args) { + // Let's see if we can create a Grade + Grade g1 = new Grade(1, false, new Name("Jan", "Novak"), "Test", new LocalDate(2023, 1, 1), 0); + Grade g2 = new Grade(2, true, new Name("Jan", "Novak"), "Test", new LocalDate(2023, 1, 1), 0); + + System.out.println("Grade 1: " + g1.getValue() + " small: " + g1.getSmall()); + System.out.println("Grade 2: " + g2.getValue() + " small: " + g2.getSmall()); + } +} diff --git a/jecnaapi b/jecnaapi new file mode 120000 index 0000000..b25f75e --- /dev/null +++ b/jecnaapi @@ -0,0 +1 @@ +/home/kuba/Coding/jecnam/JecnaAPI/ \ No newline at end of file diff --git a/pom.xml b/pom.xml index 6f35dc0..675a3ee 100644 --- a/pom.xml +++ b/pom.xml @@ -21,7 +21,6 @@ javafx-maven-plugin 0.0.8 - cz.jzitnik.Main @@ -33,7 +32,7 @@ org.openjfx javafx-controls - 21.0.1 + 21.0.1 org.openjfx @@ -54,5 +53,16 @@ reflections 0.10.2 + + + io.github.tomhula + jecnaapi-jvm + 10.2.0 + + + io.github.tomhula + jecnaapi-java-jvm + 10.2.0 + \ No newline at end of file diff --git a/src/main/java/cz/jzitnik/Main.java b/src/main/java/cz/jzitnik/Main.java index 469e0ef..9f00aec 100644 --- a/src/main/java/cz/jzitnik/Main.java +++ b/src/main/java/cz/jzitnik/Main.java @@ -1,47 +1,80 @@ package cz.jzitnik; import atlantafx.base.theme.PrimerDark; +import cz.jzitnik.auth.CredentialStore; +import cz.jzitnik.controllers.DashboardController; +import cz.jzitnik.controllers.LoginController; +import cz.jzitnik.router.Router; +import cz.jzitnik.state.AppState; import cz.jzitnik.state.StateManager; import javafx.application.Application; +import javafx.application.Platform; import javafx.scene.Scene; import javafx.scene.layout.StackPane; import javafx.scene.shape.Rectangle; import javafx.stage.Stage; +import java.util.Objects; +import java.util.Optional; +import cz.jzitnik.query.QueryClient; +import cz.jzitnik.query.QueryOptions; + public class Main extends Application { @Override public void start(Stage stage) throws Exception { Application.setUserAgentStylesheet(new PrimerDark().getUserAgentStylesheet()); - // 1. Initialize State Manager - // This will find all @State annotated classes inside the cz.jzitnik package and create singletons StateManager.getInstance().initialize("cz.jzitnik"); - // 2. Set up routing container (this is our root) StackPane root = new StackPane(); root.setStyle("-fx-background-color: -color-bg-default;"); - // Ensure contents never bleed out and cause weird window scaling Rectangle clip = new Rectangle(); clip.widthProperty().bind(root.widthProperty()); clip.heightProperty().bind(root.heightProperty()); root.setClip(clip); - // 3. Initialize Router - cz.jzitnik.router.Router router = cz.jzitnik.router.Router.getInstance(); + Router router = Router.getInstance(); router.setRootContainer(root); - router.registerRoute("/login", cz.jzitnik.controllers.LoginController.class); - router.registerRoute("/home", cz.jzitnik.controllers.HomeController.class); - - // Initial screen - router.navigate("/login"); - // 4. Display - Scene scene = new Scene(root, 800, 600); + router.registerAnnotatedRoutes("cz.jzitnik.controllers"); + + try { + Optional creds = CredentialStore.loadCredentials(); + if (creds.isPresent()) { + AppState appState = StateManager.getInstance().getState(AppState.class); + if (appState != null) { + new Thread(() -> { + boolean ok = appState.login(creds.get().username, creds.get().password); + if (ok) { + Platform.runLater(() -> router.navigate("/dashboard")); + } else { + CredentialStore.clearCredentials(); + Platform.runLater(() -> router.navigate("/login")); + } + }, "auto-login").start(); + } else { + router.navigate("/login"); + } + } else { + router.navigate("/login"); + } + } catch (Throwable t) { + router.navigate("/login"); + } + + Scene scene = new Scene(root, 1000, 700); + scene.getStylesheets().add(Objects.requireNonNull(Main.class.getResource("/styles/dashboard.css")).toExternalForm()); stage.setTitle("JecnaClient"); stage.setScene(scene); stage.show(); + + QueryClient qc = new QueryClient(); + AppState state = StateManager.getInstance().getState(AppState.class); + if (state != null) { + state.setQueryClient(qc); + } } public static void main(String[] args) { diff --git a/src/main/java/cz/jzitnik/auth/CredentialStore.java b/src/main/java/cz/jzitnik/auth/CredentialStore.java new file mode 100644 index 0000000..0c9c683 --- /dev/null +++ b/src/main/java/cz/jzitnik/auth/CredentialStore.java @@ -0,0 +1,125 @@ +/* +AI GENERATED SLOP +*/ + +package cz.jzitnik.auth; + +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.nio.file.attribute.PosixFilePermission; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.EnumSet; +import java.util.Optional; +import java.util.Set; + +public class CredentialStore { + private static final Path DIR = Paths.get(System.getProperty("user.home"), ".jecnaclient"); + private static final Path KEY_FILE = DIR.resolve("secret.key"); + private static final Path CRED_FILE = DIR.resolve("credentials.enc"); + private static final int KEY_SIZE = 256; + private static final SecureRandom RAND = new SecureRandom(); + + public static final class Credentials { + public final String username; + public final String password; + + public Credentials(String u, String p) { + this.username = u; + this.password = p; + } + } + + public static void saveCredentials(String username, String password) throws Exception { + if (username == null || password == null) return; + ensureDir(); + + SecretKey key = loadOrCreateKey(); + + byte[] iv = new byte[12]; + RAND.nextBytes(iv); + + Cipher c = Cipher.getInstance("AES/GCM/NoPadding"); + GCMParameterSpec spec = new GCMParameterSpec(128, iv); + c.init(Cipher.ENCRYPT_MODE, key, spec); + + String payload = username + "\n" + password; + byte[] ct = c.doFinal(payload.getBytes(StandardCharsets.UTF_8)); + + byte[] out = new byte[iv.length + ct.length]; + System.arraycopy(iv, 0, out, 0, iv.length); + System.arraycopy(ct, 0, out, iv.length, ct.length); + + Files.write(CRED_FILE, Base64.getEncoder().encode(out), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + trySetOwnerOnly(CRED_FILE); + } + + public static Optional loadCredentials() throws Exception { + if (!Files.exists(CRED_FILE)) return Optional.empty(); + if (!Files.exists(KEY_FILE)) return Optional.empty(); + + SecretKey key = loadOrCreateKey(); + + byte[] raw = Files.readAllBytes(CRED_FILE); + byte[] decoded = Base64.getDecoder().decode(raw); + if (decoded.length < 12) return Optional.empty(); + + byte[] iv = new byte[12]; + System.arraycopy(decoded, 0, iv, 0, 12); + byte[] ct = new byte[decoded.length - 12]; + System.arraycopy(decoded, 12, ct, 0, ct.length); + + Cipher c = Cipher.getInstance("AES/GCM/NoPadding"); + GCMParameterSpec spec = new GCMParameterSpec(128, iv); + c.init(Cipher.DECRYPT_MODE, key, spec); + byte[] plain = c.doFinal(ct); + String s = new String(plain, StandardCharsets.UTF_8); + int nl = s.indexOf('\n'); + if (nl < 0) return Optional.empty(); + String u = s.substring(0, nl); + String p = s.substring(nl + 1); + return Optional.of(new Credentials(u, p)); + } + + public static void clearCredentials() { + try { + Files.deleteIfExists(CRED_FILE); + } catch (IOException ignored) {} + } + + private static void ensureDir() throws IOException { + if (!Files.exists(DIR)) { + Files.createDirectories(DIR); + trySetOwnerOnly(DIR); + } + } + + private static SecretKey loadOrCreateKey() throws Exception { + if (Files.exists(KEY_FILE)) { + byte[] k = Files.readAllBytes(KEY_FILE); + return new SecretKeySpec(k, "AES"); + } + KeyGenerator kg = KeyGenerator.getInstance("AES"); + kg.init(KEY_SIZE, RAND); + SecretKey key = kg.generateKey(); + Files.write(KEY_FILE, key.getEncoded(), StandardOpenOption.CREATE_NEW); + trySetOwnerOnly(KEY_FILE); + return key; + } + + private static void trySetOwnerOnly(Path p) { + try { + // POSIX systems + Set perms = EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE); + Files.setPosixFilePermissions(p, perms); + } catch (Throwable ignored) { + // Windows or other systems: best-effort, ignore + } + } +} diff --git a/src/main/java/cz/jzitnik/controllers/DashboardBaseController.java b/src/main/java/cz/jzitnik/controllers/DashboardBaseController.java new file mode 100644 index 0000000..43922f2 --- /dev/null +++ b/src/main/java/cz/jzitnik/controllers/DashboardBaseController.java @@ -0,0 +1,85 @@ +package cz.jzitnik.controllers; + +import cz.jzitnik.router.Routable; +import cz.jzitnik.router.Route; +import cz.jzitnik.state.AppState; +import cz.jzitnik.state.InjectState; +import javafx.fxml.FXML; +import javafx.scene.control.Label; + +import java.util.Map; +import cz.jzitnik.router.Router; +import javafx.fxml.FXML; +import javafx.scene.control.Button; + +public class DashboardBaseController implements Routable { + + @InjectState + protected AppState appState; + + @FXML + protected Label welcomeLabel; + + @FXML + protected Label classLabel; + + @FXML + protected void onNavigateToGrades() { Router.getInstance().navigate("/grades"); } + + @FXML + protected void onNavigateToTimetable() { Router.getInstance().navigate("/timetable"); } + + @FXML + protected void onNavigateToTeachers() { Router.getInstance().navigate("/teachers"); } + + @FXML + protected void onNavigateToRooms() { Router.getInstance().navigate("/rooms"); } + + @FXML + protected void onNavigateToAbsences() { Router.getInstance().navigate("/absences"); } + + @FXML + protected void onNavigateToSpecial() { Router.getInstance().navigate("/special"); } + + @FXML + protected void onDoNothing() { + } + + @FXML + protected void onBack() { Router.getInstance().navigate("/home"); } + + @Override + public void onNavigate(Map props) { + if (welcomeLabel != null && classLabel != null) { + welcomeLabel.setText(" "); + classLabel.setText(" "); + welcomeLabel.getStyleClass().add("skeleton-text"); + classLabel.getStyleClass().add("skeleton-text"); + + cz.jzitnik.query.QueryOptions opts = cz.jzitnik.query.QueryOptions.defaultOptions(); + appState.getQueryClient().fetch("user:profile", () -> { + try { + return appState.getClient().getStudentProfile().join(); + } catch (Exception e) { + return null; + } + }, opts).thenAccept(result -> { + javafx.application.Platform.runLater(() -> { + welcomeLabel.getStyleClass().remove("skeleton-text"); + classLabel.getStyleClass().remove("skeleton-text"); + + if (result.isSuccess() && result.getData().isPresent()) { + var profile = result.getData().get(); + welcomeLabel.setText(profile.getFullName()); + classLabel.setText(profile.getClassName()); + } else { + String username = appState.getUsername(); + if (username == null) username = "User"; + welcomeLabel.setText(username); + classLabel.setText("Unknown Class"); + } + }); + }); + } + } +} diff --git a/src/main/java/cz/jzitnik/controllers/DashboardController.java b/src/main/java/cz/jzitnik/controllers/DashboardController.java new file mode 100644 index 0000000..7eb1aed --- /dev/null +++ b/src/main/java/cz/jzitnik/controllers/DashboardController.java @@ -0,0 +1,26 @@ +package cz.jzitnik.controllers; + +import cz.jzitnik.auth.CredentialStore; +import cz.jzitnik.router.Route; +import cz.jzitnik.router.Router; +import javafx.fxml.FXML; + +import java.util.Map; + +@Route(path = "/dashboard", fxml = "/dashboard_modern.fxml") +public class DashboardController extends DashboardBaseController { + + @Override + public void onNavigate(Map props) { + super.onNavigate(props); + } + + @FXML + public void onLogoutClick() { + appState.clear(); + try { + CredentialStore.clearCredentials(); + } catch (Throwable ignored) {} + Router.getInstance().navigate("/login"); + } +} diff --git a/src/main/java/cz/jzitnik/controllers/GradesController.java b/src/main/java/cz/jzitnik/controllers/GradesController.java new file mode 100644 index 0000000..0571c7d --- /dev/null +++ b/src/main/java/cz/jzitnik/controllers/GradesController.java @@ -0,0 +1,301 @@ +package cz.jzitnik.controllers; + +import cz.jzitnik.router.Route; +import cz.jzitnik.router.Router; +import cz.jzitnik.state.AppState; +import cz.jzitnik.query.QueryOptions; +import cz.jzitnik.query.QueryResult; +import io.github.tomhula.jecnaapi.data.grade.GradesPage; +import io.github.tomhula.jecnaapi.data.grade.Subject; +import io.github.tomhula.jecnaapi.data.grade.Grade; +import javafx.application.Platform; +import javafx.fxml.FXML; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.*; +import javafx.scene.layout.*; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Route(path = "/grades", fxml = "/grades.fxml") +public class GradesController extends DashboardBaseController { + + @FXML + private VBox contentBox; + + @FXML + private ProgressIndicator loadingIndicator; + + @FXML + private ScrollPane scrollPane; + + private GradesPage currentPage; + private final Map> predictions = new HashMap<>(); + + public record PredictedGrade(int value, boolean isSmall) {} + + @Override + public void onNavigate(Map props) { + super.onNavigate(props); + predictions.clear(); + loadGrades(); + } + + private void loadGrades() { + loadingIndicator.setVisible(true); + scrollPane.setVisible(false); + contentBox.getChildren().clear(); + + appState.getQueryClient().fetch("grades:page", () -> { + try { + return appState.getClient().getGradesPage().join(); + } catch (Exception e) { + e.printStackTrace(); + return null; + } + }, QueryOptions.defaultOptions()).thenAccept(this::handleGradesResult); + } + + private void handleGradesResult(QueryResult result) { + Platform.runLater(() -> { + loadingIndicator.setVisible(false); + + if (result.isSuccess() && result.getData().isPresent()) { + scrollPane.setVisible(true); + currentPage = result.getData().get(); + renderAllSubjects(); + } else { + Label errorLabel = new Label("Failed to load grades."); + errorLabel.getStyleClass().addAll("text-danger", "title-3"); + contentBox.getChildren().add(errorLabel); + contentBox.setAlignment(Pos.CENTER); + scrollPane.setVisible(true); + } + }); + } + + private void renderAllSubjects() { + contentBox.getChildren().clear(); + contentBox.setAlignment(Pos.TOP_CENTER); + + if (currentPage.getSubjects() == null || currentPage.getSubjects().isEmpty()) { + Label emptyLabel = new Label("No grades available."); + emptyLabel.getStyleClass().add("text-muted"); + contentBox.getChildren().add(emptyLabel); + return; + } + + for (Subject subject : currentPage.getSubjects()) { + VBox card = createSubjectCard(subject); + contentBox.getChildren().add(card); + } + } + + private Float calculateAverage(Subject subject, List preds) { + float sum = 0; + float count = 0; + + if (subject.getGrades().isNotEmpty()) { + for (String part : subject.getGrades().getSubjectParts()) { + for (Grade g : subject.getGrades().get(part)) { + if (g.getValue() > 0 && g.getValue() <= 5) { + float weight = g.getSmall() ? 0.5f : 1.0f; + sum += g.getValue() * weight; + count += weight; + } + } + } + } + + if (preds != null) { + for (PredictedGrade pg : preds) { + float weight = pg.isSmall() ? 0.5f : 1.0f; + sum += pg.value() * weight; + count += weight; + } + } + + return count > 0 ? sum / count : null; + } + + private VBox createSubjectCard(Subject subject) { + String subjectNameFull = subject.getName().getFull(); + List preds = predictions.getOrDefault(subjectNameFull, new ArrayList<>()); + + VBox card = new VBox(); + card.getStyleClass().addAll("card", "subject-card"); + card.setSpacing(10); + card.setMaxWidth(800); + + // Header: Subject Name and Average + HBox header = new HBox(); + header.setAlignment(Pos.CENTER_LEFT); + + Label subjectName = new Label(subjectNameFull); + subjectName.getStyleClass().addAll("title-3"); + + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + + header.getChildren().addAll(subjectName, spacer); + + Float avg = calculateAverage(subject, preds); + if (avg != null && !avg.isNaN()) { + Label averageLabel = new Label(String.format("Průměr: %.2f", avg)); + averageLabel.getStyleClass().addAll("text-muted", "text-bold"); + if (!preds.isEmpty()) { + averageLabel.setText(averageLabel.getText() + " (s predikcí)"); + averageLabel.setStyle("-fx-text-fill: -color-accent-emphasis;"); + } + header.getChildren().add(averageLabel); + } + + // Body: Grades FlowPane + FlowPane gradesPane = new FlowPane(); + gradesPane.setHgap(8); + gradesPane.setVgap(8); + gradesPane.setAlignment(Pos.CENTER_LEFT); + + boolean hasAnyGrades = false; + if (subject.getGrades().isNotEmpty()) { + for (String part : subject.getGrades().getSubjectParts()) { + for (Grade grade : subject.getGrades().get(part)) { + gradesPane.getChildren().add(createGradeBadge(grade.getValue(), grade.getSmall(), grade.getValueChar(), false, grade.getDescription(), grade.getTeacher() != null ? grade.getTeacher().getShort() : null, grade.getReceiveDate() != null ? grade.getReceiveDate().toString() : null)); + hasAnyGrades = true; + } + } + } + + for (int i = 0; i < preds.size(); i++) { + PredictedGrade pg = preds.get(i); + StackPane pBadge = createGradeBadge(pg.value(), pg.isSmall(), ' ', true, "Predikce", null, null); + + // Add click to remove + int indexToRemove = i; + pBadge.setOnMouseClicked(e -> { + preds.remove(indexToRemove); + if (preds.isEmpty()) predictions.remove(subjectNameFull); + renderAllSubjects(); + }); + Tooltip.install(pBadge, new Tooltip("Predikovaná známka. Kliknutím smažete.")); + + gradesPane.getChildren().add(pBadge); + hasAnyGrades = true; + } + + if (!hasAnyGrades) { + Label noGrades = new Label("Zatím bez známek"); + noGrades.getStyleClass().addAll("text-muted", "text-small"); + gradesPane.getChildren().add(noGrades); + } + + // Footer: Add Prediction Tool + StackPane addBadge = new StackPane(); + Label addLabel = new Label("+"); + addLabel.getStyleClass().add("text-muted"); + addBadge.getChildren().add(addLabel); + addBadge.getStyleClass().addAll("grade-badge", "grade-add-btn"); + Tooltip.install(addBadge, new Tooltip("Přidat predikci")); + addBadge.setOnMouseClicked(e -> showPredictionDialog(subjectNameFull, preds)); + + gradesPane.getChildren().add(addBadge); + + card.getChildren().addAll(header, gradesPane); + return card; + } + + private void showPredictionDialog(String subjectNameFull, List preds) { + Dialog dialog = new Dialog<>(); + dialog.setTitle("Nová predikce"); + dialog.setHeaderText("Přidat predikovanou známku pro " + subjectNameFull); + + ButtonType addButtonType = new ButtonType("Přidat", ButtonBar.ButtonData.OK_DONE); + dialog.getDialogPane().getButtonTypes().addAll(addButtonType, ButtonType.CANCEL); + + GridPane grid = new GridPane(); + grid.setHgap(10); + grid.setVgap(10); + grid.setPadding(new Insets(20, 50, 10, 10)); + + ComboBox gradeCombo = new ComboBox<>(); + gradeCombo.getItems().addAll(1, 2, 3, 4, 5); + gradeCombo.getSelectionModel().selectFirst(); + + CheckBox smallCheck = new CheckBox("Malá známka"); + + grid.add(new Label("Známka:"), 0, 0); + grid.add(gradeCombo, 1, 0); + grid.add(smallCheck, 1, 1); + + dialog.getDialogPane().setContent(grid); + + dialog.setResultConverter(dialogButton -> { + if (dialogButton == addButtonType) { + return new PredictedGrade(gradeCombo.getValue(), smallCheck.isSelected()); + } + return null; + }); + + dialog.showAndWait().ifPresent(pg -> { + preds.add(pg); + predictions.put(subjectNameFull, preds); + renderAllSubjects(); + }); + } + + private StackPane createGradeBadge(int value, boolean isSmall, char valueChar, boolean isPredicted, String desc, String teacher, String date) { + StackPane badge = new StackPane(); + String valStr = String.valueOf(valueChar); + if (value != 0) { + valStr = String.valueOf(value); + } + + Label gradeLabel = new Label(valStr); + gradeLabel.getStyleClass().add("grade-text"); + + badge.getChildren().add(gradeLabel); + + badge.getStyleClass().add("grade-badge"); + if (isSmall) { + badge.getStyleClass().add("grade-small"); + } + if (isPredicted) { + badge.getStyleClass().add("grade-predicted"); + } + + // Color coding + if (value == 1) badge.getStyleClass().add("grade-1"); + else if (value == 2) badge.getStyleClass().add("grade-2"); + else if (value == 3) badge.getStyleClass().add("grade-3"); + else if (value == 4) badge.getStyleClass().add("grade-4"); + else if (value == 5) badge.getStyleClass().add("grade-5"); + else badge.getStyleClass().add("grade-other"); + + // Tooltip + StringBuilder tooltipText = new StringBuilder(); + if (desc != null && !desc.isEmpty()) { + tooltipText.append(desc).append("\n"); + } + if (teacher != null && !teacher.isEmpty()) { + tooltipText.append("Učitel: ").append(teacher).append("\n"); + } + if (date != null && !date.isEmpty()) { + tooltipText.append("Datum: ").append(date); + } + + if (!tooltipText.isEmpty() && !isPredicted) { + Tooltip tooltip = new Tooltip(tooltipText.toString().trim()); + Tooltip.install(badge, tooltip); + } + + return badge; + } + + @FXML + protected void onBackToDashboard() { + Router.getInstance().navigate("/dashboard"); + } +} \ No newline at end of file diff --git a/src/main/java/cz/jzitnik/controllers/HomeController.java b/src/main/java/cz/jzitnik/controllers/HomeController.java index 7334aff..d977025 100644 --- a/src/main/java/cz/jzitnik/controllers/HomeController.java +++ b/src/main/java/cz/jzitnik/controllers/HomeController.java @@ -10,7 +10,7 @@ import javafx.scene.control.Label; import java.util.Map; -@Route(fxml = "/home.fxml") +@Route(path = "/home", fxml = "/home.fxml") public class HomeController implements Routable { @InjectState @@ -21,20 +21,15 @@ public class HomeController implements Routable { @Override public void onNavigate(Map props) { - // Because of dependency injection, appState is already fully populated String username = appState.getUsername(); if (username == null) username = "User"; welcomeLabel.setText("Welcome, " + username + "!"); - System.out.println("Global Auth Token is: " + appState.getToken()); } @FXML public void onLogoutClick() { - // Clear global state appState.clear(); - - // Redirect Router.getInstance().navigate("/login"); } } diff --git a/src/main/java/cz/jzitnik/controllers/LoginController.java b/src/main/java/cz/jzitnik/controllers/LoginController.java index b18dd98..186d626 100644 --- a/src/main/java/cz/jzitnik/controllers/LoginController.java +++ b/src/main/java/cz/jzitnik/controllers/LoginController.java @@ -6,12 +6,12 @@ import cz.jzitnik.router.Router; import cz.jzitnik.state.AppState; import cz.jzitnik.state.InjectState; import javafx.fxml.FXML; -import javafx.scene.control.PasswordField; -import javafx.scene.control.TextField; +import javafx.scene.control.*; import java.util.Map; +import javafx.application.Platform; -@Route(fxml = "/login.fxml") +@Route(path = "/login", fxml = "/login.fxml") public class LoginController implements Routable { @InjectState @@ -23,24 +23,48 @@ public class LoginController implements Routable { @FXML private PasswordField passwordField; + @FXML + private Button loginButton; + + @FXML + private ProgressIndicator loadingIndicator; + @Override public void onNavigate(Map props) { - // Here you can use props if the screen was opened with them } @FXML public void onLoginClick() { - String username = usernameField.getText(); - - // Save the username globally into the app state - if (username != null && !username.isEmpty()) { - appState.setUsername(username); - appState.setToken("dummy_token_123"); - } else { - appState.setUsername("Guest"); + final String username = usernameField.getText(); + final String password = passwordField.getText(); + + if (username == null || username.isEmpty() || password == null || password.isEmpty()) { + Alert a = new Alert(Alert.AlertType.ERROR, "Please enter username and password"); + a.showAndWait(); + return; } - - // Redirect to the home screen (no longer need to pass username in props, it's global now!) - Router.getInstance().navigate("/home"); + + loginButton.setDisable(true); + loadingIndicator.setVisible(true); + + new Thread(() -> { + boolean ok = appState.login(username, password); + Platform.runLater(() -> { + loginButton.setDisable(false); + loadingIndicator.setVisible(false); + + if (ok) { + Router.getInstance().navigate("/dashboard"); + } else { + Alert a = new Alert(Alert.AlertType.ERROR, "Login failed: wrong credentials or error"); + a.showAndWait(); + } + }); + }, "login-thread").start(); + } + + @FXML + public void onUsernameAction() { + passwordField.requestFocus(); } } diff --git a/src/main/java/cz/jzitnik/query/QueryClient.java b/src/main/java/cz/jzitnik/query/QueryClient.java new file mode 100644 index 0000000..366acfa --- /dev/null +++ b/src/main/java/cz/jzitnik/query/QueryClient.java @@ -0,0 +1,77 @@ +package cz.jzitnik.query; + +import java.util.Map; +import java.util.concurrent.*; +import java.util.function.Supplier; + +public class QueryClient { + + private final Map> cache = new ConcurrentHashMap<>(); + private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); + + public CompletableFuture> fetch(String key, Supplier fetcher, QueryOptions opts) { + @SuppressWarnings("unchecked") CachedItem item = (CachedItem) cache.get(key); + long now = System.currentTimeMillis(); + + if (item != null) { + // if cache is fresh + if (now - item.fetchedAt <= opts.staleTime) { + return CompletableFuture.completedFuture(new QueryResult<>(item.data, null)); + } + } + + // perform fetch in background + CompletableFuture> future = CompletableFuture.supplyAsync(() -> { + int attempts = 0; + Throwable lastErr = null; + while (attempts <= opts.retryAttempts) { + try { + T data = fetcher.get(); + CachedItem ci = new CachedItem<>(data, System.currentTimeMillis()); + cache.put(key, ci); + // schedule eviction + if (opts.cacheTime > 0) { + scheduler.schedule(() -> cache.remove(key), opts.cacheTime, TimeUnit.MILLISECONDS); + } + return new QueryResult<>(data, null); + } catch (Throwable t) { + lastErr = t; + attempts++; + } + } + return new QueryResult<>(null, lastErr); + }); + + if (opts.refetchInterval > 0) { + scheduler.scheduleAtFixedRate(() -> { + try { + fetcher.get(); + } catch (Throwable ignored) {} + }, opts.refetchInterval, opts.refetchInterval, TimeUnit.MILLISECONDS); + } + + return future; + } + + public void set(String key, T value) { + cache.put(key, new CachedItem<>(value, System.currentTimeMillis())); + } + + public void invalidate(String key) { + cache.remove(key); + } + + public void shutdown() { + scheduler.shutdownNow(); + } + + private static class CachedItem { + final T data; + final long fetchedAt; + + CachedItem(T data, long fetchedAt) { + this.data = data; + this.fetchedAt = fetchedAt; + } + } +} diff --git a/src/main/java/cz/jzitnik/query/QueryOptions.java b/src/main/java/cz/jzitnik/query/QueryOptions.java new file mode 100644 index 0000000..b2eacc0 --- /dev/null +++ b/src/main/java/cz/jzitnik/query/QueryOptions.java @@ -0,0 +1,18 @@ +package cz.jzitnik.query; + +public class QueryOptions { + // time until data is considered stale (ms) + public long staleTime = 5 * 60 * 1000; // 5 minutes + // time until unused cache entry is evicted (ms) + public long cacheTime = 30 * 60 * 1000; // 30 minutes + // if >0, interval to refetch in background (ms) + public long refetchInterval = 0; + // number of retry attempts on failure + public int retryAttempts = 0; + + public QueryOptions() {} + + public static QueryOptions defaultOptions() { + return new QueryOptions(); + } +} diff --git a/src/main/java/cz/jzitnik/query/QueryResult.java b/src/main/java/cz/jzitnik/query/QueryResult.java new file mode 100644 index 0000000..6754113 --- /dev/null +++ b/src/main/java/cz/jzitnik/query/QueryResult.java @@ -0,0 +1,21 @@ +package cz.jzitnik.query; + +import java.time.Instant; +import java.util.Optional; + +public class QueryResult { + private final T data; + private final Throwable error; + private final Instant fetchedAt; + + public QueryResult(T data, Throwable error) { + this.data = data; + this.error = error; + this.fetchedAt = Instant.now(); + } + + public Optional getData() { return Optional.ofNullable(data); } + public Optional getError() { return Optional.ofNullable(error); } + public Instant getFetchedAt() { return fetchedAt; } + public boolean isSuccess() { return error == null; } +} diff --git a/src/main/java/cz/jzitnik/router/Route.java b/src/main/java/cz/jzitnik/router/Route.java index 021e150..92106cd 100644 --- a/src/main/java/cz/jzitnik/router/Route.java +++ b/src/main/java/cz/jzitnik/router/Route.java @@ -11,5 +11,6 @@ import java.lang.annotation.Target; @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) public @interface Route { + String path(); String fxml(); } diff --git a/src/main/java/cz/jzitnik/router/Router.java b/src/main/java/cz/jzitnik/router/Router.java index f3812d4..3c6c4aa 100644 --- a/src/main/java/cz/jzitnik/router/Router.java +++ b/src/main/java/cz/jzitnik/router/Router.java @@ -4,13 +4,13 @@ import cz.jzitnik.state.StateManager; import javafx.fxml.FXMLLoader; import javafx.scene.Parent; import javafx.scene.layout.Pane; +import org.reflections.Reflections; + import java.io.IOException; import java.util.HashMap; import java.util.Map; +import java.util.Set; -/** - * Scalable API for managing screens. - */ public class Router { private static Router instance; private Pane rootContainer; @@ -29,11 +29,15 @@ public class Router { this.rootContainer = rootContainer; } - public void registerRoute(String path, Class controllerClass) { - if (!controllerClass.isAnnotationPresent(Route.class)) { - throw new IllegalArgumentException("Controller " + controllerClass.getName() + " must be annotated with @Route"); + public void registerAnnotatedRoutes(String basePackage) { + Reflections r = new Reflections(basePackage); + Set> controllers = r.getTypesAnnotatedWith(Route.class); + for (Class c : controllers) { + Route a = c.getAnnotation(Route.class); + if (a != null) { + routes.put(a.path(), c); + } } - routes.put(path, controllerClass); } public void navigate(String path) { @@ -51,6 +55,7 @@ public class Router { } Route routeAnnotation = controllerClass.getAnnotation(Route.class); + assert routeAnnotation != null; String fxmlPath = routeAnnotation.fxml(); try { @@ -58,7 +63,6 @@ public class Router { Parent view = loader.load(); Object controller = loader.getController(); - // Inject global state into the controller if it uses @InjectState StateManager.getInstance().injectStates(controller); if (controller instanceof Routable routable) { diff --git a/src/main/java/cz/jzitnik/state/AppState.java b/src/main/java/cz/jzitnik/state/AppState.java index 385ec10..cbe0d7b 100644 --- a/src/main/java/cz/jzitnik/state/AppState.java +++ b/src/main/java/cz/jzitnik/state/AppState.java @@ -1,33 +1,68 @@ package cz.jzitnik.state; +import cz.jzitnik.auth.CredentialStore; +import io.github.tomhula.jecnaapi.java.JecnaClientJavaWrapper; +import cz.jzitnik.query.QueryClient; + @State public class AppState { private String username; - private String token; // Example for some auth token + private JecnaClientJavaWrapper client; + private QueryClient queryClient; public String getUsername() { return username; } - public void setUsername(String username) { - this.username = username; + public JecnaClientJavaWrapper getClient() { + return client; } - public String getToken() { - return token; + public QueryClient getQueryClient() { + return queryClient; } - public void setToken(String token) { - this.token = token; + public void setQueryClient(QueryClient qc) { + this.queryClient = qc; } - - public boolean isLoggedIn() { - return username != null && !username.isEmpty(); + + public boolean login(String username, String password) { + if (username == null || username.isEmpty() || password == null) return false; + + if (client == null) { + client = new JecnaClientJavaWrapper(); + } + + try { + boolean ok = client.login(username, password).join(); + if (ok) { + this.username = username; + try { + CredentialStore.saveCredentials(username, password); + } catch (Throwable t) { + System.err.println("Failed to save credentials: " + t.getMessage()); + } + } else { + this.client = null; + } + return ok; + } catch (Exception e) { + System.err.println("Login failed with exception: " + e.getMessage()); + e.printStackTrace(); + this.client = null; + return false; + } } - + public void clear() { this.username = null; - this.token = null; + if (this.client != null) { + try { + this.client.logout(); + } catch (Throwable ignored) { + } + this.client = null; + } } } diff --git a/src/main/java/cz/jzitnik/state/StateManager.java b/src/main/java/cz/jzitnik/state/StateManager.java index 40afcd4..cf2d179 100644 --- a/src/main/java/cz/jzitnik/state/StateManager.java +++ b/src/main/java/cz/jzitnik/state/StateManager.java @@ -50,24 +50,53 @@ public class StateManager { */ public void injectStates(Object target) { if (target == null) return; + Class current = target.getClass(); + while (current != null && current != Object.class) { + for (Field field : current.getDeclaredFields()) { + if (field.isAnnotationPresent(InjectState.class)) { + Class fieldType = field.getType(); + Object stateInstance = states.get(fieldType); - Class targetClass = target.getClass(); - for (Field field : targetClass.getDeclaredFields()) { - if (field.isAnnotationPresent(InjectState.class)) { - Class fieldType = field.getType(); - Object stateInstance = states.get(fieldType); + if (stateInstance == null) { + for (Object candidate : states.values()) { + if (candidate != null && fieldType.isAssignableFrom(candidate.getClass())) { + stateInstance = candidate; + break; + } + } + } - if (stateInstance == null) { - throw new RuntimeException("No @State found for type: " + fieldType.getName() + " in " + targetClass.getName()); + if (stateInstance == null) { + throw new RuntimeException("No @State found for type: " + fieldType.getName() + " in " + current.getName()); + } + + try { + field.setAccessible(true); + field.set(target, stateInstance); + } catch (IllegalAccessException e) { + throw new RuntimeException("Failed to inject state into field: " + field.getName(), e); + } } + } + current = current.getSuperclass(); + } + } - try { - field.setAccessible(true); - field.set(target, stateInstance); - } catch (IllegalAccessException e) { - throw new RuntimeException("Failed to inject state into field: " + field.getName(), e); + /** + * Retrieve a managed state singleton by its class. + */ + @SuppressWarnings("unchecked") + public T getState(Class cls) { + Object obj = states.get(cls); + if (obj == null) { + // try assignable match + for (Object candidate : states.values()) { + if (candidate != null && cls.isAssignableFrom(candidate.getClass())) { + obj = candidate; + break; } } } + return (T) obj; } } diff --git a/src/main/java/cz/jzitnik/ui/AuroraBackground.java b/src/main/java/cz/jzitnik/ui/AuroraBackground.java index ddd158b..0bee80e 100644 --- a/src/main/java/cz/jzitnik/ui/AuroraBackground.java +++ b/src/main/java/cz/jzitnik/ui/AuroraBackground.java @@ -11,23 +11,17 @@ import javafx.scene.paint.Color; import javafx.scene.shape.Circle; import javafx.util.Duration; -/** - * Reusable animated background component. - */ public class AuroraBackground extends StackPane { public AuroraBackground() { - // LOWER OPACITY: Dropped from 0.3 to 0.1 for a subtle background feel Color color1 = Color.web("#00FFFF", 0.10); // Cyan Color color2 = Color.web("#FF00FF", 0.10); // Magenta Color color3 = Color.web("#7D3CFF", 0.10); // Deep Purple - // MASSIVE BLOBS: Increased radius so they fill large screens beautifully Circle blob1 = new Circle(400, color1); Circle blob2 = new Circle(350, color2); Circle blob3 = new Circle(450, color3); - // HEAVY BLUR: Increased from 100 to 200 for maximum softness GaussianBlur blur = new GaussianBlur(200); blob1.setEffect(blur); blob2.setEffect(blur); @@ -37,18 +31,13 @@ public class AuroraBackground extends StackPane { blob2.setBlendMode(BlendMode.ADD); blob3.setBlendMode(BlendMode.ADD); - // Because this is a StackPane, all circles start perfectly centered getChildren().addAll(blob1, blob2, blob3); - // Animate them drifting away from the center using translation setupAnimation(blob1, -150, -100, 150, 100, 20); setupAnimation(blob2, 150, 150, -150, -150, 25); setupAnimation(blob3, 0, -200, 0, 200, 15); } - /** - * Animates a node shifting back and forth from a center point. - */ private void setupAnimation(Circle node, double startX, double startY, double endX, double endY, int durationSeconds) { Timeline timeline = new Timeline( new KeyFrame(Duration.ZERO, diff --git a/src/main/resources/dashboard_generic.fxml b/src/main/resources/dashboard_generic.fxml new file mode 100644 index 0000000..5a2f1b0 --- /dev/null +++ b/src/main/resources/dashboard_generic.fxml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + +