feat: Added absences and moodle integration

This commit is contained in:
2026-05-30 12:24:06 +02:00
parent 0a80f89136
commit 991c023ed9
12 changed files with 569 additions and 17 deletions
-16
View File
@@ -1,16 +0,0 @@
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());
}
}
+13
View File
@@ -24,6 +24,14 @@
<mainClass>cz.jzitnik.Main</mainClass>
</configuration>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.1.1</version>
<configuration>
<mainClass>cz.jzitnik.Main</mainClass>
</configuration>
</plugin>
</plugins>
</build>
@@ -39,6 +47,11 @@
<artifactId>javafx-fxml</artifactId>
<version>21.0.1</version>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-web</artifactId>
<version>21.0.1</version>
</dependency>
<!-- AtlantaFX Theme -->
<dependency>
@@ -0,0 +1,173 @@
package cz.jzitnik.controllers;
import cz.jzitnik.router.Route;
import cz.jzitnik.router.Router;
import cz.jzitnik.query.QueryOptions;
import cz.jzitnik.query.QueryResult;
import io.github.tomhula.jecnaapi.data.absence.AbsenceInfo;
import io.github.tomhula.jecnaapi.data.absence.AbsencesPage;
import kotlinx.datetime.LocalDate;
import javafx.application.Platform;
import javafx.fxml.FXML;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
@Route(path = "/absences", fxml = "/absences.fxml")
public class AbsencesController extends DashboardBaseController {
@FXML
private ProgressIndicator loadingIndicator;
@FXML
private ScrollPane scrollPane;
@FXML
private HBox summaryBox;
@FXML
private FlowPane detailFlow;
private AbsencesPage currentPage;
@Override
public void onNavigate(Map<String, Object> props) {
super.onNavigate(props);
loadAbsences();
}
private void loadAbsences() {
loadingIndicator.setVisible(true);
scrollPane.setVisible(false);
summaryBox.getChildren().clear();
detailFlow.getChildren().clear();
appState.getQueryClient().fetch("absences:page", () -> {
try {
return appState.getClient().getAbsencesPage().join();
} catch (Exception e) {
e.printStackTrace();
return null;
}
}, QueryOptions.defaultOptions()).thenAccept(this::handleAbsencesResult);
}
private void handleAbsencesResult(QueryResult<AbsencesPage> result) {
Platform.runLater(() -> {
loadingIndicator.setVisible(false);
if (result.isSuccess() && result.getData().isPresent()) {
currentPage = result.getData().get();
renderAbsences();
scrollPane.setVisible(true);
} else {
Label errorLabel = new Label("Failed to load absences.");
errorLabel.getStyleClass().addAll("text-danger", "title-3");
summaryBox.getChildren().add(errorLabel);
scrollPane.setVisible(true);
}
});
}
private void renderAbsences() {
summaryBox.getChildren().clear();
detailFlow.getChildren().clear();
if (currentPage.getDays().isEmpty()) {
Label noAbsences = new Label("Žádné absence v tomto roce!");
noAbsences.getStyleClass().addAll("title-3", "text-success");
summaryBox.getChildren().add(noAbsences);
return;
}
int totalAbsent = 0;
int totalUnexcused = 0;
int totalLate = 0;
List<LocalDate> sortedDays = new ArrayList<>(currentPage.getDays());
sortedDays.sort(Comparator.reverseOrder());
for (LocalDate day : sortedDays) {
AbsenceInfo info = currentPage.get(day);
if (info != null) {
totalAbsent += info.getHoursAbsent();
totalUnexcused += info.getUnexcusedHours();
totalLate += info.getLateEntryCount();
detailFlow.getChildren().add(createAbsenceCard(day, info));
}
}
summaryBox.getChildren().addAll(
createSummaryCard("Zameškané hodiny", totalAbsent, "absence-summary-total"),
createSummaryCard("Neomluvené", totalUnexcused, totalUnexcused > 0 ? "absence-summary-danger" : "absence-summary-safe"),
createSummaryCard("Pozdní příchody", totalLate, totalLate > 0 ? "absence-summary-warning" : "absence-summary-safe")
);
}
private VBox createSummaryCard(String title, int count, String styleClass) {
VBox card = new VBox(5);
card.setAlignment(Pos.CENTER);
card.getStyleClass().addAll("card", "absence-summary-card", styleClass);
Label valueLbl = new Label(String.valueOf(count));
valueLbl.getStyleClass().add("absence-summary-value");
Label titleLbl = new Label(title);
titleLbl.getStyleClass().add("text-muted");
card.getChildren().addAll(valueLbl, titleLbl);
return card;
}
private VBox createAbsenceCard(LocalDate day, AbsenceInfo info) {
VBox card = new VBox(8);
card.getStyleClass().addAll("card", "absence-card");
if (info.getUnexcusedHours() > 0) {
card.getStyleClass().add("absence-card-danger");
} else if (info.getLateEntryCount() > 0) {
card.getStyleClass().add("absence-card-warning");
}
Label dateLbl = new Label(formatDate(day));
dateLbl.getStyleClass().addAll("title-4", "absence-card-date");
HBox stats = new HBox(15);
stats.setAlignment(Pos.CENTER_LEFT);
Label hoursLbl = new Label("Hodiny: " + info.getHoursAbsent());
hoursLbl.getStyleClass().add("text-light");
stats.getChildren().add(hoursLbl);
if (info.getUnexcusedHours() > 0) {
Label unexcusedLbl = new Label("Neomluveno: " + info.getUnexcusedHours());
unexcusedLbl.getStyleClass().addAll("text-danger", "text-bold");
stats.getChildren().add(unexcusedLbl);
}
if (info.getLateEntryCount() > 0) {
Label lateLbl = new Label("Pozdě: " + info.getLateEntryCount());
lateLbl.getStyleClass().addAll("text-warning", "text-bold");
stats.getChildren().add(lateLbl);
}
card.getChildren().addAll(dateLbl, stats);
return card;
}
@SuppressWarnings("deprecation")
private String formatDate(LocalDate date) {
return date.getDayOfMonth() + ". " + date.getMonthNumber() + ". " + date.getYear();
}
@FXML
protected void onBackToDashboard() {
Router.getInstance().navigate("/dashboard");
}
}
@@ -41,6 +41,9 @@ public class DashboardBaseController implements Routable {
@FXML
protected void onNavigateToSpecial() { Router.getInstance().navigate("/special"); }
@FXML
protected void onNavigateToMoodle() { Router.getInstance().navigate("/moodle"); }
@FXML
protected void onDoNothing() {
}
@@ -0,0 +1,167 @@
package cz.jzitnik.controllers;
import cz.jzitnik.router.Route;
import cz.jzitnik.router.Router;
import javafx.concurrent.Worker;
import javafx.fxml.FXML;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.web.WebEngine;
import javafx.scene.web.WebHistory;
import javafx.scene.web.WebView;
import java.util.Map;
import java.util.Objects;
@Route(path = "/moodle", fxml = "/moodle.fxml")
public class MoodleController extends DashboardBaseController {
@FXML
private WebView webView;
@FXML
private ProgressIndicator loadingIndicator;
@FXML
private Label titleLabel;
@FXML
private Button btnBack;
@FXML
private Button btnForward;
private WebEngine webEngine;
@Override
public void onNavigate(Map<String, Object> props) {
super.onNavigate(props);
webEngine = webView.getEngine();
webEngine.getLoadWorker().stateProperty().addListener((obs, oldState, newState) -> {
if (newState == Worker.State.SUCCEEDED) {
String currentUrl = webEngine.getLocation();
boolean isLoginPage = currentUrl != null && currentUrl.contains("login/index.php");
Boolean isLoggedOut = false;
try {
Object result = webEngine.executeScript("document.body && document.body.classList.contains('notloggedin')");
if (result instanceof Boolean) {
isLoggedOut = (Boolean) result;
}
} catch (Exception e) {}
if (isLoginPage) {
String username = appState.getUsername();
String password = appState.getPassword();
if (username != null && password != null) {
String script = String.format(
"var u = document.getElementById('username');" +
"var p = document.getElementById('password');" +
"var b = document.getElementById('loginbtn');" +
"if (u && p && b) {" +
" u.value = '%s';" +
" p.value = '%s';" +
" b.click();" +
"}",
username.replace("'", "\\'"),
password.replace("'", "\\'")
);
webEngine.executeScript(script);
} else {
loadingIndicator.setVisible(false);
webView.setVisible(true);
}
} else if (isLoggedOut) {
webEngine.executeScript("window.location.href = 'https://moodle.spsejecna.cz/login/index.php';");
} else {
loadingIndicator.setVisible(false);
webView.setVisible(true);
}
} else if (newState == Worker.State.FAILED) {
titleLabel.setText("Failed to load");
loadingIndicator.setVisible(false);
}
});
webEngine.titleProperty().addListener((obs, oldTitle, newTitle) -> {
if (newTitle != null && !newTitle.isEmpty()) {
titleLabel.setText(newTitle);
}
});
webEngine.getHistory().currentIndexProperty().addListener((obs, oldVal, newVal) -> {
updateNavigationButtons();
});
updateNavigationButtons();
loadingIndicator.setVisible(true);
webView.setVisible(false);
webEngine.setUserStyleSheetLocation(Objects.requireNonNull(getClass().getResource("/styles/moodle-dark.css")).toExternalForm());
webEngine.load("https://moodle.spsejecna.cz/");
}
private void updateNavigationButtons() {
WebHistory history = webEngine.getHistory();
int currentIndex = history.getCurrentIndex();
int validBackIndex = -1;
for (int i = currentIndex - 1; i >= 0; i--) {
String url = history.getEntries().get(i).getUrl();
if (url == null || !url.contains("login/index.php")) {
validBackIndex = i;
break;
}
}
btnBack.setDisable(validBackIndex == -1);
int validForwardIndex = -1;
for (int i = currentIndex + 1; i < history.getEntries().size(); i++) {
String url = history.getEntries().get(i).getUrl();
if (url == null || !url.contains("login/index.php")) {
validForwardIndex = i;
break;
}
}
btnForward.setDisable(validForwardIndex == -1);
}
@FXML
protected void onBrowserBack() {
WebHistory history = webEngine.getHistory();
int currentIndex = history.getCurrentIndex();
for (int i = currentIndex - 1; i >= 0; i--) {
String url = history.getEntries().get(i).getUrl();
if (url == null || !url.contains("login/index.php")) {
history.go(i - currentIndex);
break;
}
}
}
@FXML
protected void onBrowserForward() {
WebHistory history = webEngine.getHistory();
int currentIndex = history.getCurrentIndex();
for (int i = currentIndex + 1; i < history.getEntries().size(); i++) {
String url = history.getEntries().get(i).getUrl();
if (url == null || !url.contains("login/index.php")) {
history.go(i - currentIndex);
break;
}
}
}
@FXML
protected void onBrowserReload() {
webEngine.reload();
}
@FXML
protected void onBackToDashboard() {
Router.getInstance().navigate("/dashboard");
}
}
@@ -8,6 +8,7 @@ import cz.jzitnik.query.QueryClient;
public class AppState {
private String username;
private String password;
private JecnaClientJavaWrapper client;
private QueryClient queryClient;
@@ -15,6 +16,10 @@ public class AppState {
return username;
}
public String getPassword() {
return password;
}
public JecnaClientJavaWrapper getClient() {
return client;
}
@@ -38,6 +43,7 @@ public class AppState {
boolean ok = client.login(username, password).join();
if (ok) {
this.username = username;
this.password = password;
try {
CredentialStore.saveCredentials(username, password);
} catch (Throwable t) {
@@ -57,6 +63,7 @@ public class AppState {
public void clear() {
this.username = null;
this.password = null;
if (this.client != null) {
try {
this.client.logout();
+50
View File
@@ -0,0 +1,50 @@
<?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.FlowPane?>
<?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.AbsencesController"
style="-fx-background-color: linear-gradient(#0f1724, #071023);"
stylesheets="@styles/absences.css">
<!-- 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="Absence" 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 spacing="25" alignment="TOP_CENTER" maxWidth="900" StackPane.alignment="TOP_CENTER">
<!-- Summary Box -->
<HBox fx:id="summaryBox" spacing="20" alignment="CENTER" />
<!-- Detail Grid -->
<FlowPane fx:id="detailFlow" hgap="15" vgap="15" alignment="CENTER" />
</VBox>
</ScrollPane>
</StackPane>
</center>
</BorderPane>
+4 -1
View File
@@ -43,8 +43,11 @@
<!-- 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="Absence" GridPane.rowIndex="1" GridPane.columnIndex="1" onAction="#onNavigateToAbsences" styleClass="card"/>
<Button text="Mimořádný rozvrh" GridPane.rowIndex="1" GridPane.columnIndex="2" onAction="#onDoNothing" styleClass="card"/>
<!-- Row 2 -->
<Button text="Moodle" GridPane.rowIndex="2" GridPane.columnIndex="0" onAction="#onNavigateToMoodle" styleClass="card"/>
</GridPane>
</center>
+37
View File
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.ProgressIndicator?>
<?import javafx.scene.layout.BorderPane?>
<?import javafx.scene.layout.HBox?>
<?import javafx.scene.layout.StackPane?>
<?import javafx.scene.web.WebView?>
<BorderPane xmlns="http://javafx.com/javafx/21" xmlns:fx="http://javafx.com/fxml/1"
fx:controller="cz.jzitnik.controllers.MoodleController"
style="-fx-background-color: linear-gradient(#0f1724, #071023);"
stylesheets="@styles/moodle.css">
<top>
<HBox spacing="10" 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" />
<HBox spacing="5" alignment="CENTER_LEFT">
<Button fx:id="btnBack" text="&lt;" onAction="#onBrowserBack" styleClass="browser-btn" />
<Button fx:id="btnForward" text="&gt;" onAction="#onBrowserForward" styleClass="browser-btn" />
<Button fx:id="btnReload" text="↻" onAction="#onBrowserReload" styleClass="browser-btn" />
</HBox>
<Label fx:id="titleLabel" text="Načítání..." styleClass="title-3, text-light, moodle-title" />
</HBox>
</top>
<center>
<StackPane>
<WebView fx:id="webView" visible="false" />
<ProgressIndicator fx:id="loadingIndicator" maxWidth="50" maxHeight="50" />
</StackPane>
</center>
</BorderPane>
+77
View File
@@ -0,0 +1,77 @@
/* Absences Styles */
.absence-summary-card {
-fx-min-width: 150px;
-fx-min-height: 100px;
-fx-background-color: rgba(255, 255, 255, 0.05);
-fx-border-radius: 12px;
-fx-background-radius: 12px;
}
.absence-summary-value {
-fx-font-size: 32px;
-fx-font-weight: bold;
-fx-text-fill: white;
}
.absence-summary-total .absence-summary-value {
-fx-text-fill: #58a6ff;
}
.absence-summary-safe .absence-summary-value {
-fx-text-fill: #2ea043;
}
.absence-summary-warning {
-fx-border-color: rgba(210, 153, 34, 0.4);
}
.absence-summary-warning .absence-summary-value {
-fx-text-fill: #d29922;
}
.absence-summary-danger {
-fx-border-color: rgba(248, 81, 73, 0.4);
}
.absence-summary-danger .absence-summary-value {
-fx-text-fill: #f85149;
}
/* Detail Cards */
.absence-card {
-fx-min-width: 250px;
-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;
-fx-alignment: center-left;
}
.absence-card-warning {
-fx-border-color: rgba(210, 153, 34, 0.5);
-fx-background-color: rgba(210, 153, 34, 0.05);
}
.absence-card-danger {
-fx-border-color: rgba(248, 81, 73, 0.5);
-fx-background-color: rgba(248, 81, 73, 0.05);
}
.absence-card-date {
-fx-text-fill: white;
-fx-font-size: 16px;
-fx-font-weight: bold;
-fx-padding: 0 0 5 0;
}
.text-success {
-fx-text-fill: #2ea043;
}
.text-warning {
-fx-text-fill: #d29922;
}
.text-bold {
-fx-font-weight: bold;
}
@@ -0,0 +1,8 @@
html {
filter: invert(100%) hue-rotate(180deg) brightness(105%) contrast(85%);
background: white;
}
img, video, iframe, canvas, svg, [style*='background-image'] {
filter: invert(100%) hue-rotate(180deg) !important;
}
+30
View File
@@ -0,0 +1,30 @@
.browser-btn {
-fx-background-color: transparent;
-fx-border-color: transparent;
-fx-text-fill: #c9d1d9;
-fx-font-weight: bold;
-fx-font-size: 14px;
-fx-min-width: 35px;
-fx-min-height: 35px;
-fx-padding: 0;
-fx-cursor: hand;
-fx-background-radius: 4px;
}
.browser-btn:hover {
-fx-background-color: rgba(255, 255, 255, 0.1);
}
.browser-btn:pressed {
-fx-background-color: rgba(255, 255, 255, 0.15);
}
.browser-btn:disabled {
-fx-opacity: 0.3;
-fx-cursor: default;
}
.moodle-title {
-fx-padding: 0 0 0 15px;
-fx-text-fill: white;
}