feat: Implemented fetching and a lot of other stuff
This commit is contained in:
@@ -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<CredentialStore.Credentials> 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) {
|
||||
|
||||
@@ -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<Credentials> 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<PosixFilePermission> perms = EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE);
|
||||
Files.setPosixFilePermissions(p, perms);
|
||||
} catch (Throwable ignored) {
|
||||
// Windows or other systems: best-effort, ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> props) {
|
||||
super.onNavigate(props);
|
||||
}
|
||||
|
||||
@FXML
|
||||
public void onLogoutClick() {
|
||||
appState.clear();
|
||||
try {
|
||||
CredentialStore.clearCredentials();
|
||||
} catch (Throwable ignored) {}
|
||||
Router.getInstance().navigate("/login");
|
||||
}
|
||||
}
|
||||
@@ -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<String, List<PredictedGrade>> predictions = new HashMap<>();
|
||||
|
||||
public record PredictedGrade(int value, boolean isSmall) {}
|
||||
|
||||
@Override
|
||||
public void onNavigate(Map<String, Object> 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<GradesPage> 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<PredictedGrade> 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<PredictedGrade> 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<PredictedGrade> preds) {
|
||||
Dialog<PredictedGrade> 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<Integer> 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");
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, Object> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, CachedItem<?>> cache = new ConcurrentHashMap<>();
|
||||
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
|
||||
|
||||
public <T> CompletableFuture<QueryResult<T>> fetch(String key, Supplier<T> fetcher, QueryOptions opts) {
|
||||
@SuppressWarnings("unchecked") CachedItem<T> item = (CachedItem<T>) 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<QueryResult<T>> future = CompletableFuture.supplyAsync(() -> {
|
||||
int attempts = 0;
|
||||
Throwable lastErr = null;
|
||||
while (attempts <= opts.retryAttempts) {
|
||||
try {
|
||||
T data = fetcher.get();
|
||||
CachedItem<T> 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 <T> 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<T> {
|
||||
final T data;
|
||||
final long fetchedAt;
|
||||
|
||||
CachedItem(T data, long fetchedAt) {
|
||||
this.data = data;
|
||||
this.fetchedAt = fetchedAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package cz.jzitnik.query;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
public class QueryResult<T> {
|
||||
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<T> getData() { return Optional.ofNullable(data); }
|
||||
public Optional<Throwable> getError() { return Optional.ofNullable(error); }
|
||||
public Instant getFetchedAt() { return fetchedAt; }
|
||||
public boolean isSuccess() { return error == null; }
|
||||
}
|
||||
@@ -11,5 +11,6 @@ import java.lang.annotation.Target;
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface Route {
|
||||
String path();
|
||||
String fxml();
|
||||
}
|
||||
|
||||
@@ -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<Class<?>> 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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> T getState(Class<T> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.geometry.Insets?>
|
||||
<?import javafx.scene.control.Button?>
|
||||
<?import javafx.scene.control.Label?>
|
||||
<?import javafx.scene.layout.VBox?>
|
||||
<?import javafx.scene.layout.StackPane?>
|
||||
|
||||
<StackPane xmlns="http://javafx.com/javafx/21" xmlns:fx="http://javafx.com/fxml/1"
|
||||
fx:controller="cz.jzitnik.controllers.DashboardBaseController">
|
||||
|
||||
<VBox alignment="TOP_CENTER" spacing="12.0" maxWidth="700.0" style="-fx-background-color: transparent;">
|
||||
<padding>
|
||||
<Insets bottom="20.0" left="20.0" right="20.0" top="20.0"/>
|
||||
</padding>
|
||||
|
||||
<Label text="Dashboard" styleClass="title-1"/>
|
||||
<Label fx:id="welcomeLabel" styleClass="text-muted"/>
|
||||
|
||||
<VBox spacing="10.0" maxWidth="400.0">
|
||||
<Button text="Známky" onAction="#onNavigateToGrades" maxWidth="Infinity" styleClass="accent"/>
|
||||
<Button text="Rozvrh" onAction="#onNavigateToTimetable" maxWidth="Infinity"/>
|
||||
<Button text="Učitelé" onAction="#onNavigateToTeachers" maxWidth="Infinity"/>
|
||||
<Button text="Učebny" onAction="#onNavigateToRooms" maxWidth="Infinity"/>
|
||||
<Button text="Absence" onAction="#onNavigateToAbsences" maxWidth="Infinity"/>
|
||||
<Button text="Mimořádný rozvrh" onAction="#onNavigateToSpecial" maxWidth="Infinity"/>
|
||||
</VBox>
|
||||
|
||||
<Button text="Back" onAction="#onBack" maxWidth="200.0" styleClass="muted"/>
|
||||
</VBox>
|
||||
|
||||
</StackPane>
|
||||
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.geometry.Insets?>
|
||||
<?import javafx.scene.control.Button?>
|
||||
<?import javafx.scene.control.Label?>
|
||||
<?import javafx.scene.image.ImageView?>
|
||||
<?import javafx.scene.layout.BorderPane?>
|
||||
<?import javafx.scene.layout.GridPane?>
|
||||
<?import javafx.scene.layout.HBox?>
|
||||
<?import javafx.scene.layout.Region?>
|
||||
<?import javafx.scene.layout.VBox?>
|
||||
|
||||
<BorderPane xmlns="http://javafx.com/javafx/21" xmlns:fx="http://javafx.com/fxml/1"
|
||||
fx:controller="cz.jzitnik.controllers.DashboardController"
|
||||
style="-fx-background-color: linear-gradient(#0f1724, #071023); -fx-padding: 20;">
|
||||
|
||||
<!-- Header -->
|
||||
<top>
|
||||
<HBox spacing="10" alignment="CENTER_LEFT">
|
||||
<Label text="JecnaClient" styleClass="title-2 text-light"/>
|
||||
<Region HBox.hgrow="ALWAYS" />
|
||||
<HBox spacing="15" alignment="CENTER_RIGHT">
|
||||
<VBox alignment="CENTER_RIGHT" spacing="2">
|
||||
<Label fx:id="welcomeLabel" styleClass="text-light skeleton-text"/>
|
||||
<Label fx:id="classLabel" styleClass="text-muted skeleton-text"/>
|
||||
</VBox>
|
||||
<Button text="Logout" onAction="#onLogoutClick" styleClass="danger small"/>
|
||||
</HBox>
|
||||
</HBox>
|
||||
</top>
|
||||
|
||||
<!-- Grid menu -->
|
||||
<center>
|
||||
<GridPane hgap="20" vgap="20" alignment="CENTER">
|
||||
<padding>
|
||||
<Insets top="30" right="30" bottom="30" left="30"/>
|
||||
</padding>
|
||||
|
||||
<!-- Row 0 -->
|
||||
<Button text="Známky" GridPane.rowIndex="0" GridPane.columnIndex="0" onAction="#onNavigateToGrades" styleClass="card"/>
|
||||
<Button text="Rozvrh" GridPane.rowIndex="0" GridPane.columnIndex="1" onAction="#onDoNothing" styleClass="card"/>
|
||||
<Button text="Učitelé" GridPane.rowIndex="0" GridPane.columnIndex="2" onAction="#onDoNothing" styleClass="card"/>
|
||||
|
||||
<!-- Row 1 -->
|
||||
<Button text="Učebny" GridPane.rowIndex="1" GridPane.columnIndex="0" onAction="#onDoNothing" styleClass="card"/>
|
||||
<Button text="Absence" GridPane.rowIndex="1" GridPane.columnIndex="1" onAction="#onDoNothing" styleClass="card"/>
|
||||
<Button text="Mimořádný rozvrh" GridPane.rowIndex="1" GridPane.columnIndex="2" onAction="#onDoNothing" styleClass="card"/>
|
||||
</GridPane>
|
||||
</center>
|
||||
|
||||
</BorderPane>
|
||||
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.geometry.Insets?>
|
||||
<?import javafx.scene.control.Button?>
|
||||
<?import javafx.scene.control.Label?>
|
||||
<?import javafx.scene.control.ProgressIndicator?>
|
||||
<?import javafx.scene.control.ScrollPane?>
|
||||
<?import javafx.scene.layout.BorderPane?>
|
||||
<?import javafx.scene.layout.HBox?>
|
||||
<?import javafx.scene.layout.Region?>
|
||||
<?import javafx.scene.layout.StackPane?>
|
||||
<?import javafx.scene.layout.VBox?>
|
||||
|
||||
<BorderPane xmlns="http://javafx.com/javafx/21" xmlns:fx="http://javafx.com/fxml/1"
|
||||
fx:controller="cz.jzitnik.controllers.GradesController"
|
||||
style="-fx-background-color: linear-gradient(#0f1724, #071023);">
|
||||
|
||||
<!-- Header -->
|
||||
<top>
|
||||
<HBox spacing="15" alignment="CENTER_LEFT" style="-fx-background-color: rgba(255, 255, 255, 0.05); -fx-padding: 15 25;">
|
||||
<Button text="← Zpět" onAction="#onBackToDashboard" styleClass="flat, small" />
|
||||
<Label text="Známky" styleClass="title-2, text-light" />
|
||||
</HBox>
|
||||
</top>
|
||||
|
||||
<!-- Content -->
|
||||
<center>
|
||||
<StackPane>
|
||||
<ProgressIndicator fx:id="loadingIndicator" maxWidth="50" maxHeight="50" />
|
||||
|
||||
<ScrollPane fx:id="scrollPane" fitToWidth="true" style="-fx-background: transparent; -fx-background-color: transparent;" visible="false">
|
||||
<padding>
|
||||
<Insets top="20" right="30" bottom="30" left="30"/>
|
||||
</padding>
|
||||
<VBox fx:id="contentBox" spacing="15" alignment="TOP_CENTER" maxWidth="900" />
|
||||
</ScrollPane>
|
||||
</StackPane>
|
||||
</center>
|
||||
|
||||
</BorderPane>
|
||||
@@ -29,10 +29,13 @@
|
||||
</VBox>
|
||||
|
||||
<Label prefHeight="15.0"/>
|
||||
<TextField fx:id="usernameField" promptText="Username or Email"/>
|
||||
<PasswordField fx:id="passwordField" promptText="Password"/>
|
||||
<TextField fx:id="usernameField" promptText="Username or Email" onAction="#onUsernameAction"/>
|
||||
<PasswordField fx:id="passwordField" promptText="Password" onAction="#onLoginClick"/>
|
||||
|
||||
<Button text="Login" onAction="#onLoginClick" maxWidth="Infinity" styleClass="accent"/>
|
||||
<?import javafx.scene.control.ProgressIndicator?>
|
||||
<ProgressIndicator fx:id="loadingIndicator" visible="false" prefWidth="24.0" prefHeight="24.0" />
|
||||
|
||||
<Button fx:id="loginButton" text="Login" onAction="#onLoginClick" maxWidth="Infinity" styleClass="accent" defaultButton="true"/>
|
||||
|
||||
</VBox>
|
||||
</StackPane>
|
||||
|
||||
@@ -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 */
|
||||
Reference in New Issue
Block a user