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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/dashboard_modern.fxml b/src/main/resources/dashboard_modern.fxml
new file mode 100644
index 0000000..e2843c8
--- /dev/null
+++ b/src/main/resources/dashboard_modern.fxml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/grades.fxml b/src/main/resources/grades.fxml
new file mode 100644
index 0000000..934ca09
--- /dev/null
+++ b/src/main/resources/grades.fxml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/main/resources/login.fxml b/src/main/resources/login.fxml
index 8a8f7d5..3b674be 100644
--- a/src/main/resources/login.fxml
+++ b/src/main/resources/login.fxml
@@ -29,10 +29,13 @@
-
-
+
+
-
+
+
+
+
diff --git a/src/main/resources/styles/dashboard.css b/src/main/resources/styles/dashboard.css
new file mode 100644
index 0000000..b79cf19
--- /dev/null
+++ b/src/main/resources/styles/dashboard.css
@@ -0,0 +1,124 @@
+/* Dashboard styles: modern cards and header */
+.title-2.text-light {
+ -fx-text-fill: white;
+ -fx-font-size: 18px;
+ -fx-font-weight: 600;
+}
+
+.text-light {
+ -fx-text-fill: rgba(255,255,255,0.8);
+}
+
+.card {
+ -fx-background-color: rgba(255,255,255,0.06);
+ -fx-text-fill: white;
+ -fx-padding: 20 24 20 24;
+ -fx-background-radius: 12;
+ -fx-font-size: 14px;
+ -fx-pref-width: 220px;
+ -fx-pref-height: 120px;
+ -fx-alignment: CENTER;
+ -fx-effect: dropshadow(two-pass-box, rgba(0,0,0,0.4), 6, 0.0, 0, 2);
+}
+
+.card:hover {
+ -fx-background-color: linear-gradient(to bottom right, rgba(255,255,255,0.08), rgba(255,255,255,0.03));
+ -fx-cursor: hand;
+ -fx-scale-x: 1.02;
+ -fx-scale-y: 1.02;
+}
+
+.accent {
+ -fx-background-color: linear-gradient(#3b82f6, #06b6d4);
+ -fx-text-fill: white;
+}
+
+.danger {
+ -fx-background-color: #ef4444;
+ -fx-text-fill: white;
+}
+
+.danger.small {
+ -fx-font-size: 11px;
+ -fx-padding: 6 10 6 10;
+}
+
+.skeleton-text {
+ -fx-background-color: rgba(255, 255, 255, 0.1);
+ -fx-background-radius: 4;
+ -fx-min-width: 100px;
+ -fx-min-height: 16px;
+ -fx-text-fill: transparent;
+}
+
+/* Grades Styling */
+.subject-card {
+ -fx-background-color: rgba(30, 40, 56, 0.7);
+ -fx-border-color: rgba(255, 255, 255, 0.05);
+ -fx-border-radius: 8px;
+ -fx-background-radius: 8px;
+ -fx-padding: 15px 20px;
+}
+
+.grade-badge {
+ -fx-background-radius: 5px;
+ -fx-min-width: 32px;
+ -fx-min-height: 32px;
+ -fx-alignment: center;
+ -fx-padding: 2px 8px;
+}
+
+.grade-small {
+ -fx-min-height: 20px;
+ -fx-max-height: 20px;
+ -fx-min-width: 32px;
+ -fx-opacity: 0.85;
+ -fx-padding: 0px 8px;
+}
+
+.grade-predicted {
+ -fx-background-color: transparent !important;
+ -fx-border-style: dashed;
+ -fx-border-width: 1px;
+ -fx-border-radius: 4px;
+ -fx-opacity: 1.0;
+}
+.grade-predicted.grade-1 { -fx-border-color: #2ea043; }
+.grade-predicted.grade-1 .grade-text { -fx-text-fill: #2ea043; }
+.grade-predicted.grade-2 { -fx-border-color: #8957e5; }
+.grade-predicted.grade-2 .grade-text { -fx-text-fill: #8957e5; }
+.grade-predicted.grade-3 { -fx-border-color: #d29922; }
+.grade-predicted.grade-3 .grade-text { -fx-text-fill: #d29922; }
+.grade-predicted.grade-4 { -fx-border-color: #f85149; }
+.grade-predicted.grade-4 .grade-text { -fx-text-fill: #f85149; }
+.grade-predicted.grade-5 { -fx-border-color: #da3633; }
+.grade-predicted.grade-5 .grade-text { -fx-text-fill: #da3633; }
+.grade-predicted.grade-other { -fx-border-color: #6e7681; }
+.grade-predicted.grade-other .grade-text { -fx-text-fill: #6e7681; }
+
+.grade-add-btn {
+ -fx-background-color: rgba(255, 255, 255, 0.02);
+ -fx-border-color: rgba(255, 255, 255, 0.2);
+ -fx-border-style: dashed;
+ -fx-border-radius: 5px;
+ -fx-cursor: hand;
+}
+.grade-add-btn:hover {
+ -fx-background-color: rgba(255, 255, 255, 0.08);
+}
+
+.grade-small .grade-text {
+ -fx-font-size: 0.85em;
+}
+
+.grade-text {
+ -fx-font-weight: bold;
+ -fx-text-fill: #ffffff;
+}
+
+.grade-1 { -fx-background-color: #2ea043; } /* Primer Green */
+.grade-2 { -fx-background-color: #8957e5; } /* Primer Purple */
+.grade-3 { -fx-background-color: #d29922; } /* Primer Yellow */
+.grade-4 { -fx-background-color: #f85149; } /* Primer Red */
+.grade-5 { -fx-background-color: #da3633; -fx-border-color: #ff7b72; -fx-border-width: 1px; -fx-border-radius: 4px; } /* Darker Red */
+.grade-other { -fx-background-color: #6e7681; } /* Primer Gray */