Update .gitignore to exclude Android/Gradle files and enhance TimeEntryController and TimefixService for better error handling and performance. Refactor frontend components to use AppBrand for consistent branding across views.

This commit is contained in:
Torsten Schulz (local)
2026-05-14 22:17:29 +02:00
parent 7d5c8cffc7
commit 5b6adab4cd
72 changed files with 5704 additions and 111 deletions

6
.gitignore vendored
View File

@@ -46,6 +46,12 @@ tmp/
temp/
*.tmp
# Android / Gradle
.gradle/
*/.gradle/
local.properties
*/local.properties
# SQL scripts (optional - nur wenn sie sensible Daten enthalten)
# backend/*.sql

427
ANDROID_FRONTEND_PLAN.md Normal file
View File

@@ -0,0 +1,427 @@
# Android-Frontend Plan
Ziel: Ein funktional und visuell identisches Android-Frontend fuer TimeClock v3, das dieselbe Backend-API nutzt wie das bestehende Vue-Frontend und sich auf Smartphone und Tablet unterschiedlich bedienen laesst.
## Ausgangslage
- Bestehendes Web-Frontend: `frontend` mit Vue, Vite, Pinia und Vue Router.
- Backend: `backend` mit REST-API unter `/api`.
- Authentifizierung: JWT per `Authorization: Bearer <token>`.
- Rollen: normaler Benutzer und Admin, sichtbar in der Navigation.
- Design: helle, sachliche Web-Oberflaeche mit fester Titel-/Navigationsleiste, Statusbereich, dezenten Karten, kompakten Formularen und Bootstrap-aehnlichen Button-Zustaenden.
- Aktuelle Hauptnavigation:
- Buchungen: Wochenuebersicht, Zeitkorrekturen, Urlaub, Krankheit, Arbeitstage, Kalender.
- Export.
- Einstellungen: Persoenliches, Passwort aendern, Zeitwuensche, Zugriffe verwalten, Einladen.
- Verwaltung fuer Admins: Feiertage, Rechte.
- Weitere vorhandene Routen: Eintraege, Statistiken.
## Empfohlene technische Basis
Neue App als eigenes Modul neben Web-Frontend und Backend:
```text
mobile-app/
```
Empfehlung: Kotlin Multiplatform mit Compose Multiplatform fuer Android.
Gruende:
- Native Android-Bedienung und gute Tablet-Unterstuetzung.
- Gemeinsame UI-Patterns fuer Smartphone und Tablet per adaptivem Layout.
- Klare Trennung vom bestehenden Vue-Web-Frontend.
- Gute Erweiterbarkeit, falls spaeter Desktop oder weitere Plattformen relevant werden.
Alternative: React Native oder Expo. Das waere schneller, wenn JavaScript/TypeScript wichtiger ist als native Android-Integration. Fuer diese Codebasis ist die native Kotlin/Compose-Variante langfristig sauberer, weil das Android-Frontend eigenstaendig und nicht als WebView-Klon entstehen soll.
## Nicht-Ziele
- Kein WebView, das nur das bestehende Frontend einbettet.
- Keine Backend-Neuentwicklung.
- Keine Veraenderung der fachlichen Regeln ohne separaten Auftrag.
- Keine Vermischung von Android-Code im bestehenden `frontend`-Verzeichnis.
- Keine freie Neugestaltung der App. Das Android-Design soll die Webversion wiedererkennbar und konsistent nachbilden.
## Design-Paritaet zur Webversion
Das Android-Frontend soll von Anfang an mit einem eigenen Compose-Designsystem aufgebaut werden, das die bestehende Webversion uebersetzt statt neu zu interpretieren.
### Web-Design als Quelle
Primaere Quellen:
- `frontend/src/assets/main.css`
- `frontend/src/App.vue`
- `frontend/src/components/SideMenu.vue`
- `frontend/src/components/StatusBox.vue`
- Styles in den einzelnen Views, soweit sie fuer konkrete Screens relevant sind.
Zu uebernehmende Merkmale:
- Heller Hintergrund: weiss als App-Hintergrund.
- Textfarbe: schwarz/dunkelgrau.
- Schrift: System-Schrift analog Web (`-apple-system`, `Segoe UI`, Roboto, Arial). Android nutzt entsprechend Roboto/System Default.
- Navbar: feste, helle gruenliche Leiste mit `#f0ffec`, dezenter Border `#e0ffe0` und leichtem Schatten.
- Seitentitel: kompakte Box mit hellem Hintergrund, grauem Rand und 3 px Radius.
- Karten: `#fafafa`, Rand `#e0e0e0`, Radius 6 px, dezenter Schatten.
- Buttons:
- Standard: `#f5f5f5`, Rand `#ccc`, Text `#333`.
- Primary: `#5bc0de`, Rand `#46b8da`, Text weiss.
- Success: `#5cb85c`, Rand `#4cae4c`, Text weiss.
- Danger: `#d9534f`, Rand `#d43f3a`, Text weiss.
- Secondary: `#6c757d`, Text weiss.
- Inputs: 1 px Rand `#ddd`, 4 px Radius, 8/12 px Innenabstand, Fokusfarbe `#5bc0de`.
- Layout: grosszuegiger horizontaler Rand auf Tablet, kompakter auf Smartphone.
- StatusBox: eigene wiederverwendbare Komponente mit Aktionsbuttons und Statuszeilen.
### Android-Designsystem
In der Grundapp sollen diese Bausteine zuerst entstehen:
- `TimeClockTheme`
- Farben aus der Webversion.
- Typografie auf Basis der Android-Systemschrift.
- Standard-Abstaende und Radien.
- `TcScaffold`
- Gemeinsame App-Shell fuer angemeldete Screens.
- Navbar/Header, Seitentitel, Statusbereich, Content und optional Footer-Ersatz.
- `TcTopBar`
- Brand `Stechuhr`, aktueller Seitentitel, Benutzerbereich, Logout.
- `TcStatusBox`
- Visuell und funktional an `StatusBox.vue` angelehnt.
- Zuerst mit Mock-/Preview-Daten, spaeter mit API-Daten.
- `TcButton`
- Varianten: default, primary, success, danger, secondary.
- Gleiche Farb- und Randlogik wie Web.
- `TcCard`
- Kartencontainer mit Web-Radius, Rand, Hintergrund und Schatten.
- `TcTextField`
- Formularfelder mit Web-Input-Stil.
- `TcSectionMenu`
- Mobile und Tablet-Varianten der Web-Navigation.
- `TcLoading`, `TcError`, `TcEmptyState`
- Einheitliche Zustandsanzeigen im Web-Stil.
### Design-Validierung
Jede neue Android-Screen-Implementierung soll gegen die Webversion geprueft werden:
- Farben stimmen mit den Web-CSS-Werten ueberein.
- Abstaende und Dichte wirken wie die Webversion, nicht wie ein neues Material-Design.
- Buttons, Karten, Inputs und Tabellen verwenden die gemeinsamen `Tc*`-Widgets.
- Smartphone und Tablet duerfen unterschiedlich bedient werden, muessen aber klar dieselbe TimeClock-Oberflaeche bleiben.
- Screens werden nicht direkt mit Compose-Material-Defaults gebaut, wenn dadurch das Web-Design verloren geht.
## Funktionsumfang Paritaet
### Authentifizierung
- Login.
- Registrierung, falls weiterhin mobil erlaubt.
- Passwort vergessen.
- Passwort zuruecksetzen.
- Passwort aendern.
- Session-Wiederherstellung beim App-Start.
- Logout.
- Token sicher speichern, nicht in Klartext-Preferences.
- OAuth/Google pruefen: Fuer Android braucht der bestehende Web-OAuth-Flow wahrscheinlich eine eigene Deep-Link- oder App-Link-Strategie.
### Zeiterfassung und Buchungen
- Aktueller Status aus `/time-entries/current-state`.
- Stempeln ueber `/time-entries/clock`.
- Laufenden Eintrag anzeigen.
- Wochenuebersicht.
- Zeitkorrekturen.
- Urlaub.
- Krankheit.
- Arbeitstage.
- Kalender.
- Eintraege anzeigen, bearbeiten und loeschen, soweit im Web-Frontend vorhanden.
- Statistiken.
### Einstellungen
- Persoenliches Profil.
- Passwort aendern.
- Zeitwuensche.
- Zugriffe verwalten.
- Einladen.
### Export
- Exportfunktion analog Web.
- Auf Android muss geklaert werden, ob Dateien direkt heruntergeladen, geteilt oder im System-Dateidialog gespeichert werden sollen.
### Admin
- Feiertage.
- Rechte/Rollen.
- Nur sichtbar und erreichbar fuer Admin-Benutzer.
## API-Schicht
Die Android-App bekommt eine eigene API-Schicht, die die bestehenden Endpunkte typisiert kapselt.
Vorgeschlagene Struktur:
```text
mobile-app/
composeApp/
src/
commonMain/
kotlin/
api/
auth/
model/
repository/
ui/
util/
androidMain/
kotlin/
```
API-Aufgaben:
- Basis-URL konfigurierbar machen.
- JSON-Serialisierung zentral definieren.
- Auth-Header automatisch setzen.
- 401 zentral behandeln und Session beenden.
- Fehlertexte aus Backend-Antworten normalisieren.
- Datums- und Zeitzonenlogik konsistent zur Web-App halten.
## Navigation
### Smartphone
Smartphone-Layout priorisiert schnelle Bedienung mit einer Hand, bleibt aber visuell an der Web-Navigation orientiert.
- Bottom Navigation fuer die wichtigsten Bereiche:
- Buchungen.
- Kalender.
- Export oder Statistik, je nach tatsaechlicher Nutzung.
- Einstellungen.
- Buchungen erhalten eine eigene Unterseite mit Liste oder Tabs:
- Wochenuebersicht.
- Zeitkorrekturen.
- Urlaub.
- Krankheit.
- Arbeitstage.
- Der aktuelle Stempelstatus bleibt prominent oben sichtbar.
- Primaere Aktion `Kommen/Gehen/Pause` als klarer Hauptbutton.
- Admin-Funktionen nicht in der Bottom Navigation, sondern unter einem Verwaltungsbereich in Einstellungen oder einem Overflow-Menue.
### Tablet
Tablet-Layout nutzt die Breite fuer parallele Navigation und Inhalt und kommt der Webversion am naechsten.
- Permanente Navigation links als Navigation Rail oder Side Drawer.
- Statusbereich dauerhaft im oberen Inhaltskopf.
- Master-Detail-Ansichten, wo sinnvoll:
- Kalender links, Tagesdetails rechts.
- Wochenuebersicht links, Eintragsdetails/Bearbeitung rechts.
- Rollen/Feiertage mit Liste und Detailpanel.
- Keine reine Smartphone-Skalierung auf grosse Breite.
- Dialoge auf Tablet als zentrierte Panels oder seitliche Detailbereiche, nicht als vollflaechige Seiten, sofern der Workflow dadurch klarer bleibt.
### Breakpoints
Vorschlag:
- Compact: Smartphone-Portrait und kleine Breiten.
- Medium: grosse Smartphones, Foldables, kleine Tablets.
- Expanded: Tablets im Landscape-Modus.
Die konkrete Entscheidung sollte nicht nur an Pixeln haengen, sondern an Android Window Size Classes.
## Screen-Liste
| Web-Route | Android-Screen | Smartphone | Tablet |
| --- | --- | --- | --- |
| `/login` | LoginScreen | Vollbildformular | schmales Formular in ruhigem Layout |
| `/register` | RegisterScreen | Vollbildformular | Formularpanel |
| `/password-forgot` | PasswordForgotScreen | Vollbildformular | Formularpanel |
| `/password-reset` | PasswordResetScreen | Deep-Link-faehig | Deep-Link-faehig |
| `/bookings/week` | WeekOverviewScreen | Liste/Tagesgruppen | Wochenraster plus Detailbereich |
| `/bookings/timefix` | TimefixScreen | Formular/Liste getrennt | Liste plus Bearbeitung |
| `/bookings/vacation` | VacationScreen | Antrag und Verlauf | Kalender/Liste plus Details |
| `/bookings/sick` | SickScreen | Antrag und Verlauf | Liste plus Details |
| `/bookings/workdays` | WorkdaysScreen | kompakte Wochenliste | tabellarische Monats-/Wochenansicht |
| `/calendar` | CalendarScreen | Monatsansicht mit Tagesdetails | Kalender plus Seitenpanel |
| `/export` | ExportScreen | Filter plus Aktion | Filter links, Ergebnis/Aktion rechts |
| `/settings/profile` | ProfileScreen | Formular | Formularpanel |
| `/settings/password` | PasswordChangeScreen | Formular | Formularpanel |
| `/settings/timewish` | TimewishScreen | Liste/Formular | Liste plus Detail |
| `/settings/invite` | InviteScreen | Formular/Liste | Verwaltungspanel |
| `/settings/permissions` | PermissionsScreen | Liste | Liste plus Detail |
| `/admin/holidays` | HolidaysAdminScreen | Admin-Liste | Tabelle plus Detail |
| `/admin/roles` | RolesAdminScreen | Admin-Liste | Tabelle plus Detail |
| `/entries` | EntriesScreen | Liste mit Filtern | Tabelle plus Detail |
| `/stats` | StatsScreen | Karten/Diagramme untereinander | Dashboard-Raster |
## Datenhaltung und Offline-Verhalten
Die erste fachlich nutzbare Android-Version sollte online-first bleiben:
- Kein vollstaendiger Offline-Modus.
- Token und Benutzerprofil lokal speichern.
- Letzten bekannten aktuellen Status optional cachen.
- Bei fehlender Verbindung klare Fehlermeldung und keine riskanten lokalen Buchungen.
Optional spaeter:
- Offline-Warteschlange fuer Stempelaktionen.
- Konfliktbehandlung bei Zeitkorrekturen.
- Lokaler Read-Cache fuer Kalender und Wochenuebersicht.
## Sicherheit
- Token im Android Keystore oder ueber eine sichere Storage-Abstraktion speichern.
- Screens mit sensiblen Daten optional gegen Screenshots schuetzen, falls gewuenscht.
- Logout loescht lokale Session vollstaendig.
- API-Basis-URL nicht hart im Code fuer alle Umgebungen fest verdrahten.
- OAuth-Redirects nur ueber erlaubte App-/Deep-Link-Hosts.
## Projektphasen
### Phase 1: Grundapp und Designsystem
- `mobile-app` anlegen.
- Android-Build lauffaehig machen.
- `TimeClockTheme` mit Farben, Typografie, Abstaenden, Radien und Schatten aus der Webversion.
- Grundlegende `Tc*`-Widgets: Scaffold, TopBar, StatusBox, Button, Card, TextField, SectionMenu, Loading/Error.
- Preview-/Demo-Screens fuer Smartphone und Tablet mit Mock-Daten.
- Adaptive Grundnavigation, aber noch ohne vollstaendige fachliche Implementierung.
- Designabgleich mit Web-Frontend anhand der bestehenden CSS-/Vue-Komponenten.
Akzeptanz:
- App startet auf Emulator/Device.
- Grundshell sieht wie die Webversion aus.
- Smartphone und Tablet haben unterschiedliche Layouts, aber identische visuelle Sprache.
- Neue Screens koennen auf Basis der gemeinsamen Widgets gebaut werden.
### Phase 2: API- und Auth-Fundament
- App-Konfiguration fuer API-Basis-URL.
- HTTP-Client, JSON, Fehlerbehandlung.
- Secure Token Storage.
- Auth-Flow: Login, Logout, Session-Wiederherstellung, `/auth/me`.
Akzeptanz:
- Login gegen bestehendes Backend funktioniert.
- Geschuetzter Bereich wird nach App-Neustart wiederhergestellt.
- Auth-Screens verwenden bereits die gemeinsamen Design-Widgets.
### Phase 3: Shell und adaptive Navigation
- Gemeinsames App-Layout.
- Smartphone: Bottom Navigation plus Unterbereiche.
- Tablet: permanente Navigation plus Inhaltsbereich.
- Rollenbasierte Sichtbarkeit.
- Statusleiste/Statusbereich analog Web-App.
Akzeptanz:
- Smartphone und Tablet zeigen bewusst unterschiedliche Navigation.
- Admin-Menues erscheinen nur fuer Admins.
- Session-Ablauf fuehrt sauber zurueck zum Login.
### Phase 4: Kern-Zeiterfassung
- Aktueller Status.
- Kommen/Gehen/Pause ueber `/time-entries/clock`.
- Laufender Eintrag.
- Wochenuebersicht.
- Eintraege anzeigen.
Akzeptanz:
- Kernworkflow kann mobil vollstaendig genutzt werden.
- Web und Android zeigen danach konsistente Daten.
### Phase 5: Buchungs-Workflows
- Zeitkorrekturen.
- Urlaub.
- Krankheit.
- Arbeitstage.
- Kalender.
Akzeptanz:
- Jeder Buchungsbereich deckt die Web-Funktionen ab.
- Tablet nutzt Detailpanels, Smartphone nutzt fokussierte Einzelseiten.
### Phase 6: Einstellungen
- Profil.
- Passwort aendern.
- Zeitwuensche.
- Zugriffe verwalten.
- Einladen.
- Kein Export in der Android-App, da laut Entscheidung nicht gewuenscht.
Akzeptanz:
- Alle Nicht-Admin-Web-Funktionen sind mobil verfuegbar.
### Phase 7: Admin und Navigation
- Feiertage.
- Rollen/Rechte.
- Menüpunkte für alle bereiche implementieren. mit submenüs arbeiten, wie in der weboberfläche
Akzeptanz:
- Admin-Funktionen sind mobil und auf Tablet sinnvoll bedienbar.
- Nicht-Admins koennen sie weder sehen noch aufrufen.
- Alle Bereiche sind ansteuerbar
### Phase 8: Qualitaet
- Unit-Tests fuer API/Repository/Auth.
- UI-Tests fuer Login, Navigation und Kern-Zeiterfassung.
- Manuelle Tests auf Smartphone- und Tablet-Emulator.
- Dark-Mode-Entscheidung treffen: entweder sauber unterstuetzen oder explizit deaktivieren.
- Accessibility pruefen: Touch-Zielgroessen, Kontrast, TalkBack-Beschriftungen.
- Visuelle Regressionen zumindest ueber dokumentierte Screenshots fuer Smartphone und Tablet pruefen.
## Implementierungsstand
- Phase 1 umgesetzt: Grundapp, responsives App-Shell-Layout und Web-Farbdesign.
- Phase 2 umgesetzt: Login, Session-Restore, Logout, API-Konfiguration und Token-Store.
- Phase 3 umgesetzt: aktueller Status, Stempeln, laufender Eintrag und Wochenuebersicht.
- Phase 4 umgesetzt: Zeitkorrekturen, Urlaub, Krankheit, Arbeitstage und Kalender.
- Phase 5 umgesetzt: Profil, Passwort, Zeitwuensche, Zugriffe verwalten und Einladen.
- Phase 6 umgesetzt: Admin-Feiertage und Rollen/Rechte.
- Phase 7 umgesetzt: Smartphone-Submenues wie in der Web-Navigation, dynamische Hauptnavigation, Eintraege und Statistiken.
- Phase 8 umgesetzt als erste Qualitaetsschicht: lokale Unit-Tests, Offline-Stempel-Queue, technischer OAuth-/Offline-Plan und erfolgreicher Test-/Buildlauf.
- Export ist in Android aus Navigation und Menues entfernt.
## Offene Entscheidungen
- Soll die Android-App nur intern installiert werden oder spaeter in den Play Store? - später im playstore
- Welche Android-Mindestversion ist gewuenscht? 15
- Soll Registrierung in der App erlaubt sein oder nur Login? registrierung
- Soll Google OAuth in Android direkt unterstuetzt werden oder vorerst nur E-Mail/Passwort? oauth
- Soll Stempeln offline moeglich sein? ja
- Welche Export-Zielform ist mobil gewuenscht: Download, Teilen, E-Mail oder System-Dateiauswahl? kein export
- Gibt es bestehende Corporate-Design-Vorgaben ausser dem aktuellen Web-Look? nein
- Soll der Web-Look exakt nachgebaut werden, auch wenn einzelne Material-Android-Konventionen dadurch weniger stark genutzt werden? ja.
## Risiken
- OAuth braucht fuer Android eine eigene Redirect-Strategie.
- Datums-/Zeitzonenlogik kann zwischen Web, Backend und Android abweichen, wenn sie nicht frueh getestet wird.
- Tablet-Paritaet braucht eigene Layout-Entscheidungen, sonst entsteht nur eine gestreckte Smartphone-App.
- Backend-Endpunkte muessen fuer alle Web-Funktionen ausreichend dokumentiert oder aus den Controllern abgeleitet werden.
- Wenn die Grundwidgets nicht zuerst stehen, entsteht spaeter schnell ein uneinheitlicher Mix aus Web-Look und Android-Material-Defaults.
## Naechster sinnvoller Schritt
1. OAuth-Deep-Link/Custom-Tab-Flow mit Backend-Konfiguration umsetzen.
2. Offline-Stempeln mit UI-Anzeige, Konfliktbehandlung und Sync-Status erweitern.
3. Compose-Screenshot-/Instrumented-Tests fuer Smartphone und Tablet ergaenzen.

View File

@@ -0,0 +1,58 @@
# Android Technical Next Steps
Stand: 2026-05-14
## OAuth
Ziel: Android soll Google OAuth direkt starten koennen, ohne WebView-Klon.
Empfohlener Ablauf:
1. Backend bekommt einen Android-kompatiblen OAuth-Callback mit App-Link oder Custom-Scheme.
2. Android startet den Google-Flow ueber Browser Custom Tabs.
3. Callback oeffnet die App per Deep Link, z.B. `timeclock://oauth-callback?token=...`.
4. App speichert JWT im bestehenden `TokenStore` und laedt `/auth/me`.
5. Fehlerfaelle: abgebrochener Login, abgelaufener Token, fehlender Account, Offline.
Technische Punkte:
- Redirect-URI muss in Google Console und Backend konfiguriert werden.
- Token darf nicht in Logs erscheinen.
- Der bestehende E-Mail/Passwort-Login bleibt Fallback.
## Offline-Stempeln
Umgesetzt als Grundfunktion:
- Netzwerkfehler bei `clock(action)` speichern die Aktion lokal in `OfflineClockQueue`.
- Beim naechsten erfolgreichen Dashboard-Refresh werden gespeicherte Aktionen in Reihenfolge synchronisiert.
Noch offen fuer produktionsreife Offline-Funktion:
- UI-Hinweis mit Anzahl ausstehender Aktionen.
- Konfliktbehandlung, falls der Serverzustand nicht mehr zur Aktionsfolge passt.
- Persistente Audit-Anzeige fuer synchronisierte/fehlgeschlagene Offline-Aktionen.
- Tests mit simulierten Netzwerkfehlern und Wiederverbindung.
## Smartphone/Tablet UI-Feinschliff
Pruefpunkte mit echten Backend-Daten:
- Smartphone: Bottom-Navigation + Submenue fuer Buchungen, Einstellungen, Auswertung und Verwaltung.
- Tablet: linkes Section-Menue, Detailbereiche und breite Listen.
- Lange Namen/E-Mail-Adressen duerfen keine Buttons oder Zeilen sprengen.
- Admin-Listen mit vielen Benutzern/Feiertagen muessen scrollbar bleiben.
## Phase 8 Teststrategie
Erste lokale Tests sind angelegt fuer:
- API-Serialisierung wichtiger Backend-Responses.
- Gehashte IDs als Strings auf geschuetzten Endpunkten.
- Offline-Queue-Serialisierung.
Weitere sinnvolle Tests:
- Repository-Tests mit fake API-Schicht.
- ViewModel-Tests fuer Login, Stempeln, Offline-Queue und Rollenwechsel.
- Compose-Screenshot-Checks fuer Smartphone und Tablet.

View File

@@ -11,7 +11,8 @@ class TimeEntryController {
*/
async getAllEntries(req, res) {
try {
const entries = timeEntryService.getAllEntries();
const userId = req.user?.userId;
const entries = await timeEntryService.getAllEntries(userId);
res.json(entries);
} catch (error) {
console.error('Fehler beim Abrufen der Einträge:', error);
@@ -119,7 +120,7 @@ class TimeEntryController {
async deleteEntry(req, res) {
try {
const { id } = req.params;
timeEntryService.deleteEntry(id);
await timeEntryService.deleteEntry(id);
res.status(204).send();
} catch (error) {
@@ -262,4 +263,3 @@ class TimeEntryController {
// Singleton-Instanz exportieren
module.exports = new TimeEntryController();

View File

@@ -34,6 +34,10 @@ class TimefixService {
// Hole auch die Timefixes für diese Einträge
const entryIds = entries.map(e => e.id);
if (entryIds.length === 0) {
return [];
}
const timefixesForEntries = await sequelize.query(
`SELECT worklog_id, fix_type, fix_date_time FROM timefix WHERE worklog_id IN (?)`,
{
@@ -349,4 +353,3 @@ class TimefixService {
}
module.exports = new TimefixService();

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -2,7 +2,9 @@
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" type="image/png" href="/stechuhr.png">
<link rel="apple-touch-icon" href="/stechuhr.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TimeClock v3 - Zeiterfassung</title>
</head>

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -3,9 +3,7 @@
<div class="navbar" v-if="authStore.isAuthenticated">
<div class="navbar-inner">
<div class="container">
<h1 class="brand">
<RouterLink to="/">Stechuhr</RouterLink>
</h1>
<AppBrand />
<div class="nav-title-menu">
<h2 class="page-title" v-if="pageTitle">{{ pageTitle }}</h2>
<div class="nav-collapse">
@@ -47,12 +45,13 @@
</template>
<script setup>
import { RouterLink, RouterView, useRoute } from 'vue-router'
import { RouterView, useRoute } from 'vue-router'
import { useAuthStore } from './stores/authStore'
import { useRouter } from 'vue-router'
import { computed } from 'vue'
import StatusBox from './components/StatusBox.vue'
import SideMenu from './components/SideMenu.vue'
import AppBrand from './components/AppBrand.vue'
const authStore = useAuthStore()
const router = useRouter()
@@ -133,24 +132,6 @@ const pageTitle = computed(() => {
flex-direction: row;
}
.brand {
margin: 0;
padding: 0;
}
.brand a {
font-weight: bold;
color: #000;
padding: 12px 20px 12px 0;
font-size: 24px;
text-decoration: none;
transition: color 0.2s;
}
.brand a:hover {
color: #333;
}
.nav-collapse {
display: flex;
width: 100%;

View File

@@ -0,0 +1,54 @@
<template>
<h1 class="brand">
<RouterLink :to="to" class="brand-link">
<img src="/stechuhr.png" alt="Stechuhr" class="brand-logo" width="32" height="32" />
<span class="brand-text">Stechuhr</span>
</RouterLink>
</h1>
</template>
<script setup>
import { RouterLink } from 'vue-router'
defineProps({
to: {
type: String,
default: '/',
},
})
</script>
<style scoped>
.brand {
margin: 0;
padding: 0;
}
.brand-link {
display: inline-flex;
align-items: center;
gap: 10px;
font-weight: bold;
color: #000;
padding: 8px 20px 8px 0;
font-size: 24px;
text-decoration: none;
transition: color 0.2s;
}
.brand-link:hover {
color: #333;
}
.brand-logo {
display: block;
flex-shrink: 0;
width: 32px;
height: 32px;
object-fit: contain;
}
.brand-text {
line-height: 1;
}
</style>

View File

@@ -3,9 +3,7 @@
<div class="navbar">
<div class="navbar-inner">
<div class="container">
<h1 class="brand">
<router-link to="/">Stechuhr</router-link>
</h1>
<AppBrand />
</div>
</div>
</div>
@@ -115,6 +113,7 @@ import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/authStore'
import { API_BASE_URL } from '@/config/api'
import AppBrand from '../components/AppBrand.vue'
const API_URL = API_BASE_URL
const router = useRouter()
@@ -189,24 +188,6 @@ body {
border-bottom: 1px solid #e0ffe0;
}
.brand {
margin: 0;
padding: 0;
}
.brand a {
font-weight: bold;
color: #000;
padding: 12px 20px 12px 0;
font-size: 24px;
text-decoration: none;
transition: color 0.2s;
}
.brand a:hover {
color: #333;
}
.contents {
flex: 1;
display: flex;

View File

@@ -3,9 +3,7 @@
<div class="navbar">
<div class="navbar-inner">
<div class="container">
<h1 class="brand">
<router-link to="/">Stechuhr</router-link>
</h1>
<AppBrand />
</div>
</div>
</div>
@@ -69,6 +67,7 @@
<script setup>
import { ref } from 'vue'
import { useAuthStore } from '../stores/authStore'
import AppBrand from '../components/AppBrand.vue'
const authStore = useAuthStore()
const email = ref('')
@@ -137,24 +136,6 @@ const copyResetLink = async () => {
border-bottom: 1px solid #e0ffe0;
}
.brand {
margin: 0;
padding: 0;
}
.brand a {
font-weight: bold;
color: #000;
padding: 12px 20px 12px 0;
font-size: 24px;
text-decoration: none;
transition: color 0.2s;
}
.brand a:hover {
color: #333;
}
.contents {
flex: 1;
display: flex;

View File

@@ -3,9 +3,7 @@
<div class="navbar">
<div class="navbar-inner">
<div class="container">
<h1 class="brand">
<router-link to="/">Stechuhr</router-link>
</h1>
<AppBrand />
</div>
</div>
</div>
@@ -81,6 +79,7 @@
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useAuthStore } from '../stores/authStore'
import AppBrand from '../components/AppBrand.vue'
const route = useRoute()
const authStore = useAuthStore()
@@ -143,24 +142,6 @@ const handleReset = async () => {
border-bottom: 1px solid #e0ffe0;
}
.brand {
margin: 0;
padding: 0;
}
.brand a {
font-weight: bold;
color: #000;
padding: 12px 20px 12px 0;
font-size: 24px;
text-decoration: none;
transition: color 0.2s;
}
.brand a:hover {
color: #333;
}
.contents {
flex: 1;
display: flex;

View File

@@ -3,9 +3,7 @@
<div class="navbar">
<div class="navbar-inner">
<div class="container">
<h1 class="brand">
<router-link to="/">Stechuhr</router-link>
</h1>
<AppBrand />
</div>
</div>
</div>
@@ -96,6 +94,7 @@
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/authStore'
import AppBrand from '../components/AppBrand.vue'
const router = useRouter()
const authStore = useAuthStore()
@@ -163,24 +162,6 @@ const handleRegister = async () => {
border-bottom: 1px solid #e0ffe0;
}
.brand {
margin: 0;
padding: 0;
}
.brand a {
font-weight: bold;
color: #000;
padding: 12px 20px 12px 0;
font-size: 24px;
text-decoration: none;
transition: color 0.2s;
}
.brand a:hover {
color: #333;
}
.contents {
flex: 1;
display: flex;

67
mobile-app/README.md Normal file
View File

@@ -0,0 +1,67 @@
# TimeClock Android Frontend
Phase 1 enthält die native Android-Grundapp und die gemeinsamen Compose-Design-Widgets.
**Phase 2 (API- und Auth-Fundament)** ist umgesetzt:
- Konfigurierbare API-Basis-URL (`BuildConfig.API_BASE_URL`), Standard: `https://stechuhr3.tsschulz.de/api`.
- HTTP mit OkHttp, JSON mit kotlinx.serialization, normalisierte Fehler (`ApiException`).
- JWT in **EncryptedSharedPreferences** (AndroidX Security Crypto), nicht im Klartext.
- **Login** (`POST /auth/login`), **Logout** (`POST /auth/logout`), **Session-Wiederherstellung** (`GET /auth/me` beim App-Start).
- Login-Oberfläche mit den bestehenden `Tc*`-Widgets; nach Anmeldung die Phase1-Demo-Shell mit echtem Benutzernamen und rollenbasierter Navigation (Admin-Zusatzmenü nur bei `role == 1`).
- Debug-Build: Cleartext-HTTP erlaubt (`composeApp/src/debug/AndroidManifest.xml`) für lokales Testen.
**Phase 3 (Kern-Zeiterfassung)** ist umgesetzt:
- Echter aktueller Status über `GET /time-entries/current-state`.
- Stempelaktionen über `POST /time-entries/clock`.
- Laufender Eintrag über `GET /time-entries/running`.
- StatusBox mit echten Zeiten und Aktionsbuttons.
- Tages-/Wochenwerte über `GET /time-entries/stats/summary`.
- Wochenübersicht über `GET /week-overview?weekOffset=...`.
- Automatische Status-Aktualisierung alle 30 Sekunden nach Anmeldung.
**Phase 4 (Buchungs-Workflows)** ist umgesetzt:
- Zeitkorrekturen: Worklog-Einträge nach Datum, heutige Korrekturen, Erstellen und Löschen.
- Urlaub: Liste, Erstellen und Löschen.
- Krankheit: Krankheitstypen, Liste, Erstellen und Löschen.
- Arbeitstage: Jahresstatistik.
- Kalender: Monatsansicht mit Feiertag, Krankheit, Urlaub und Arbeitsstunden.
## API-URL setzen
In `mobile-app/local.properties` (wird typischerweise nicht eingecheckt):
```properties
# Beispiel: lokales Backend im Emulator (Host-Rechner)
timeclock.api.baseUrl=http://10.0.2.2:3010/api
```
oder beim Gradle-Aufruf:
```bash
# Beispiel: lokales Backend statt Standard-Server
./gradlew :composeApp:assembleDebug -Ptimeclock.api.baseUrl=http://10.0.2.2:3010/api
```
## Scope (Phase 1)
- Native Android-App unter `composeApp`.
- Web-inspiriertes Designsystem an `frontend/src/assets/main.css`, `App.vue`, `SideMenu.vue` und `StatusBox.vue`.
- Adaptive Shell: schmale Breiten mit Top-Bar und Bottom-Navigation, Tablet mit persistenter Seitennavigation.
- Nicht umgesetzte Einstellungs-/Admin-Screens bleiben vorerst Demo/Mock; StatusBox, Wochenübersicht und Buchungs-Workflows verwenden echte Backend-Daten.
## Build
Aus diesem Verzeichnis:
```bash
./gradlew :composeApp:assembleDebug
```
Beispiel mit eigenem Gradle-Cache:
```bash
GRADLE_USER_HOME=/tmp/gradle-home ./gradlew :composeApp:assembleDebug --no-daemon
```

View File

@@ -0,0 +1,5 @@
plugins {
id("com.android.application") version "9.1.1" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.2.20" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.2.20" apply false
}

View File

@@ -0,0 +1,65 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
}
val localProps = rootProject.file("local.properties").takeIf { it.exists() }?.reader()?.use {
Properties().apply { load(it) }
}
val apiBaseUrl: String =
(project.findProperty("timeclock.api.baseUrl") as String?)
?: localProps?.getProperty("timeclock.api.baseUrl")
?: "https://stechuhr3.tsschulz.de/api"
android {
namespace = "de.tsschulz.timeclock"
compileSdk = 36
defaultConfig {
applicationId = "de.tsschulz.timeclock"
minSdk = 26
targetSdk = 36
versionCode = 3
versionName = "0.8.0-alpha2"
buildConfigField("String", "API_BASE_URL", "\"${apiBaseUrl.replace("\\", "\\\\").replace("\"", "\\\"")}\"")
}
buildFeatures {
compose = true
buildConfig = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
implementation(platform("androidx.compose:compose-bom:2026.03.01"))
implementation("androidx.activity:activity-compose:1.13.0")
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-core")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-text")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.core:core-ktx:1.18.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.10.0")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0")
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0")
debugImplementation("androidx.compose.ui:ui-tooling")
}

Binary file not shown.

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- HTTP-Cleartext für lokale Dev-URLs (z. B. http://10.0.2.2:3010/api über local.properties) -->
<application android:usesCleartextTraffic="true" />
</manifest>

View File

@@ -0,0 +1,20 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="Stechuhr"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.TimeClock">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,45 @@
package de.tsschulz.timeclock
import android.graphics.Color
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.core.view.WindowCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import de.tsschulz.timeclock.ui.TimeClockApp
import de.tsschulz.timeclock.ui.admin.AdminViewModel
import de.tsschulz.timeclock.ui.auth.AuthViewModel
import de.tsschulz.timeclock.ui.booking.BookingViewModel
import de.tsschulz.timeclock.ui.settings.SettingsViewModel
import de.tsschulz.timeclock.ui.theme.TimeClockTheme
import de.tsschulz.timeclock.ui.time.TimeViewModel
class MainActivity : ComponentActivity() {
@Suppress("DEPRECATION")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.statusBarColor = Color.rgb(240, 255, 236)
window.navigationBarColor = Color.WHITE
setContent {
val authViewModel: AuthViewModel = viewModel(factory = AuthViewModel.Factory(application))
val timeViewModel: TimeViewModel = viewModel(factory = TimeViewModel.Factory(application))
val bookingViewModel: BookingViewModel = viewModel(factory = BookingViewModel.Factory(application))
val settingsViewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.Factory(application))
val adminViewModel: AdminViewModel = viewModel(factory = AdminViewModel.Factory(application))
TimeClockTheme {
TimeClockApp(
authViewModel = authViewModel,
timeViewModel = timeViewModel,
bookingViewModel = bookingViewModel,
settingsViewModel = settingsViewModel,
adminViewModel = adminViewModel,
)
}
}
window.decorView.post {
val controller = WindowCompat.getInsetsController(window, window.decorView)
controller.isAppearanceLightStatusBars = true
controller.isAppearanceLightNavigationBars = true
}
}
}

View File

@@ -0,0 +1,8 @@
package de.tsschulz.timeclock.config
import de.tsschulz.timeclock.BuildConfig
object AppConfig {
/** Basis-URL inkl. `/api`-Suffix (Default aus `BuildConfig`, siehe `composeApp/build.gradle.kts`). */
val apiBaseUrl: String = BuildConfig.API_BASE_URL
}

View File

@@ -0,0 +1,20 @@
package de.tsschulz.timeclock.data.admin
import de.tsschulz.timeclock.data.api.HolidayCreateRequest
import de.tsschulz.timeclock.data.api.HolidayStateDto
import de.tsschulz.timeclock.data.api.HolidaysResponse
import de.tsschulz.timeclock.data.api.RoleUserDto
import de.tsschulz.timeclock.data.api.TimeClockApiClient
class AdminRepository(
private val api: TimeClockApiClient,
) {
suspend fun getHolidayStates(): List<HolidayStateDto> = api.getHolidayStates()
suspend fun getHolidays(): HolidaysResponse = api.getHolidays()
suspend fun createHoliday(date: String, hours: Double, description: String, stateIds: List<String>) =
api.createHoliday(HolidayCreateRequest(date, hours, description, stateIds))
suspend fun deleteHoliday(id: String) = api.deleteHoliday(id)
suspend fun getRoleUsers(): List<RoleUserDto> = api.getRoleUsers()
suspend fun updateUserRole(id: String, role: Int) = api.updateUserRole(id, role)
}

View File

@@ -0,0 +1,47 @@
package de.tsschulz.timeclock.data.api
import kotlinx.serialization.Serializable
@Serializable
data class HolidayStateDto(
val id: String,
val name: String,
)
@Serializable
data class HolidayDto(
val id: String,
val date: String,
val hours: Double = 8.0,
val description: String,
val states: List<String> = emptyList(),
val isFederal: Boolean = true,
)
@Serializable
data class HolidaysResponse(
val future: List<HolidayDto> = emptyList(),
val past: List<HolidayDto> = emptyList(),
)
@Serializable
data class HolidayCreateRequest(
val date: String,
val hours: Double,
val description: String,
val stateIds: List<String> = emptyList(),
)
@Serializable
data class RoleUserDto(
val id: String,
val fullName: String,
val role: Int = 0,
val roleString: String = "user",
val stateName: String? = null,
)
@Serializable
data class RoleUpdateRequest(
val role: Int,
)

View File

@@ -0,0 +1,6 @@
package de.tsschulz.timeclock.data.api
class ApiException(
message: String,
val code: Int,
) : Exception(message)

View File

@@ -0,0 +1,52 @@
package de.tsschulz.timeclock.data.api
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class LoginRequest(
val email: String,
val password: String,
/** Entspricht dem Web-Login: `0` = keine Stempelaktion. */
val action: String = "0",
)
@Serializable
data class LoginResponse(
val success: Boolean = false,
val token: String? = null,
val user: UserDto? = null,
val message: String? = null,
val error: String? = null,
val actionWarning: String? = null,
)
@Serializable
data class MeResponse(
val success: Boolean = false,
val user: UserDto? = null,
val error: String? = null,
)
@Serializable
data class UserDto(
val id: Int,
@SerialName("full_name") val fullName: String,
val email: String? = null,
val role: Int = 0,
@SerialName("daily_hours") val dailyHours: Double? = null,
@SerialName("week_hours") val weekHours: Double? = null,
@SerialName("week_workdays") val weekWorkdays: Int? = null,
)
@Serializable
data class ErrorBody(
val error: String? = null,
val message: String? = null,
)
@Serializable
data class LogoutResponse(
val success: Boolean = true,
val message: String? = null,
)

View File

@@ -0,0 +1,106 @@
package de.tsschulz.timeclock.data.api
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class VacationDto(
val id: String,
val type: String? = null,
val typeValue: Int = 0,
val startDate: String? = null,
val endDate: String? = null,
)
@Serializable
data class VacationCreateRequest(
val vacationType: Int,
val startDate: String,
val endDate: String? = null,
)
@Serializable
data class SickEntryDto(
val id: String,
val startDate: String? = null,
val endDate: String? = null,
val sickTypeId: Int? = null,
val sickTypeName: String? = null,
)
@Serializable
data class SickTypeDto(
val id: String,
val name: String,
)
@Serializable
data class SickCreateRequest(
val sickTypeId: String,
val startDate: String,
val endDate: String? = null,
)
@Serializable
data class TimefixDto(
val id: String,
val worklogId: String? = null,
val newDate: String? = null,
val newTime: String? = null,
val newAction: String? = null,
val originalDate: String? = null,
val originalTime: String? = null,
val originalAction: String? = null,
)
@Serializable
data class WorklogEntryDto(
val id: String,
val time: String? = null,
val action: String? = null,
val tstamp: String? = null,
)
@Serializable
data class TimefixCreateRequest(
val worklogId: String,
val newDate: String,
val newTime: String,
val newAction: String,
)
@Serializable
data class WorkdaysDto(
val year: Int,
val workdays: Int = 0,
val holidays: Int = 0,
val sickDays: Double = 0.0,
val sickPercentage: Int = 0,
val vacationDays: Double = 0.0,
val workedDays: Int = 0,
)
@Serializable
data class CalendarDto(
val year: Int,
val month: Int,
val weeks: List<List<CalendarDayDto>> = emptyList(),
)
@Serializable
data class CalendarDayDto(
val date: String,
val day: Int,
val isCurrentMonth: Boolean = false,
val isToday: Boolean = false,
val holiday: String? = null,
val sick: Boolean = false,
val vacation: String? = null,
val workedHours: Double? = null,
)
@Serializable
data class MessageResponse(
val message: String? = null,
val error: String? = null,
)

View File

@@ -0,0 +1,86 @@
package de.tsschulz.timeclock.data.api
import kotlinx.serialization.Serializable
@Serializable
data class ProfileDto(
val id: String? = null,
val fullName: String = "",
val email: String? = null,
val stateId: String? = null,
val stateName: String? = null,
val weekWorkdays: Int? = null,
val dailyHours: Double? = null,
val preferredTitleType: Int? = null,
)
@Serializable
data class StateDto(
val id: String,
val name: String,
)
@Serializable
data class ProfileUpdateRequest(
val fullName: String,
val stateId: String? = null,
val weekWorkdays: Int,
val dailyHours: Double,
val preferredTitleType: Int,
)
@Serializable
data class PasswordChangeRequest(
val oldPassword: String,
val newPassword: String,
val confirmPassword: String,
)
@Serializable
data class TimewishDto(
val id: String,
val day: Int,
val dayName: String? = null,
val wishtype: Int,
val wishtypeName: String? = null,
val hours: Double? = null,
val startDate: String? = null,
val endDate: String? = null,
)
@Serializable
data class TimewishCreateRequest(
val day: Int,
val wishtype: Int,
val hours: Double? = null,
val startDate: String,
val endDate: String? = null,
)
@Serializable
data class InvitationDto(
val id: String,
val email: String,
val createdAt: String? = null,
val expiresAt: String? = null,
val status: String? = null,
val isExpired: Boolean = false,
val token: String? = null,
)
@Serializable
data class InviteRequest(
val email: String,
)
@Serializable
data class WatcherDto(
val id: String,
val email: String,
val createdAt: String? = null,
)
@Serializable
data class WatcherRequest(
val email: String,
)

View File

@@ -0,0 +1,289 @@
package de.tsschulz.timeclock.data.api
import de.tsschulz.timeclock.config.AppConfig
import de.tsschulz.timeclock.data.auth.TokenStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.concurrent.TimeUnit
private val JsonMedia = "application/json; charset=utf-8".toMediaType()
class TimeClockApiClient(
private val tokenStore: TokenStore,
private val json: Json = Json {
ignoreUnknownKeys = true
isLenient = true
coerceInputValues = true
},
private val client: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build(),
private val baseUrl: String = AppConfig.apiBaseUrl,
) {
private fun endpoint(path: String): String {
val base = baseUrl.trimEnd('/')
val p = path.trimStart('/')
return "$base/$p"
}
suspend fun postLogin(body: LoginRequest): LoginResponse {
val raw = execute(
Request.Builder()
.url(endpoint("auth/login"))
.post(json.encodeToString(LoginRequest.serializer(), body).toRequestBody(JsonMedia))
.build(),
)
return decode(LoginResponse.serializer(), raw)
}
suspend fun getMe(): MeResponse {
val t = tokenStore.getToken() ?: throw ApiException("Nicht angemeldet", 401)
val raw = execute(
Request.Builder()
.url(endpoint("auth/me"))
.header("Authorization", "Bearer $t")
.get()
.build(),
)
return decode(MeResponse.serializer(), raw)
}
suspend fun postLogout(): LogoutResponse {
val t = tokenStore.getToken() ?: throw ApiException("Nicht angemeldet", 401)
val raw = execute(
Request.Builder()
.url(endpoint("auth/logout"))
.header("Authorization", "Bearer $t")
.post("{}".toRequestBody(JsonMedia))
.build(),
)
return decode(LogoutResponse.serializer(), raw.ifBlank { "{}" })
}
suspend fun getCurrentState(): CurrentStateResponse {
val raw = execute(authorized("time-entries/current-state").get().build())
return decode(CurrentStateResponse.serializer(), raw)
}
suspend fun getRunningEntry(): RunningEntryDto {
val raw = execute(authorized("time-entries/running").get().build())
return decode(RunningEntryDto.serializer(), raw.ifBlank { "{}" })
}
suspend fun getTimeStats(): TimeStatsDto {
val raw = execute(authorized("time-entries/stats/summary").get().build())
return decode(TimeStatsDto.serializer(), raw.ifBlank { "{}" })
}
suspend fun postClock(action: String): ClockResponse {
val raw = execute(
authorized("time-entries/clock")
.post(json.encodeToString(ClockRequest.serializer(), ClockRequest(action)).toRequestBody(JsonMedia))
.build(),
)
return decode(ClockResponse.serializer(), raw)
}
suspend fun getWeekOverview(weekOffset: Int): WeekOverviewResponse {
val raw = execute(authorized("week-overview?weekOffset=$weekOffset").get().build())
return decode(WeekOverviewResponse.serializer(), raw)
}
suspend fun getTimeEntries(): List<TimeEntryDto> =
decode(ListSerializer(TimeEntryDto.serializer()), execute(authorized("time-entries").get().build()))
suspend fun deleteTimeEntry(id: String) {
execute(authorized("time-entries/$id").delete().build())
}
suspend fun getVacations(): List<VacationDto> =
decode(ListSerializer(VacationDto.serializer()), execute(authorized("vacation").get().build()))
suspend fun createVacation(request: VacationCreateRequest) {
execute(
authorized("vacation")
.post(json.encodeToString(VacationCreateRequest.serializer(), request).toRequestBody(JsonMedia))
.build(),
)
}
suspend fun deleteVacation(id: String) {
execute(authorized("vacation/$id").delete().build())
}
suspend fun getSickEntries(): List<SickEntryDto> =
decode(ListSerializer(SickEntryDto.serializer()), execute(authorized("sick").get().build()))
suspend fun getSickTypes(): List<SickTypeDto> =
decode(ListSerializer(SickTypeDto.serializer()), execute(authorized("sick/types").get().build()))
suspend fun createSick(request: SickCreateRequest) {
execute(
authorized("sick")
.post(json.encodeToString(SickCreateRequest.serializer(), request).toRequestBody(JsonMedia))
.build(),
)
}
suspend fun deleteSick(id: String) {
execute(authorized("sick/$id").delete().build())
}
suspend fun getTimefixes(): List<TimefixDto> =
decode(ListSerializer(TimefixDto.serializer()), execute(authorized("timefix").get().build()))
suspend fun getWorklogEntries(date: String): List<WorklogEntryDto> =
decode(ListSerializer(WorklogEntryDto.serializer()), execute(authorized("timefix/worklog-entries?date=$date").get().build()))
suspend fun createTimefix(request: TimefixCreateRequest) {
execute(
authorized("timefix")
.post(json.encodeToString(TimefixCreateRequest.serializer(), request).toRequestBody(JsonMedia))
.build(),
)
}
suspend fun deleteTimefix(id: String) {
execute(authorized("timefix/$id").delete().build())
}
suspend fun getWorkdays(year: Int): WorkdaysDto =
decode(WorkdaysDto.serializer(), execute(authorized("workdays?year=$year").get().build()))
suspend fun getCalendar(year: Int, month: Int): CalendarDto =
decode(CalendarDto.serializer(), execute(authorized("calendar?year=$year&month=$month").get().build()))
suspend fun getProfile(): ProfileDto =
decode(ProfileDto.serializer(), execute(authorized("profile").get().build()))
suspend fun getStates(): List<StateDto> =
decode(ListSerializer(StateDto.serializer()), execute(authorized("profile/states").get().build()))
suspend fun updateProfile(request: ProfileUpdateRequest): MessageResponse {
val raw = execute(
authorized("profile")
.put(json.encodeToString(ProfileUpdateRequest.serializer(), request).toRequestBody(JsonMedia))
.build(),
)
return decode(MessageResponse.serializer(), raw.ifBlank { "{}" })
}
suspend fun changePassword(request: PasswordChangeRequest): MessageResponse {
val raw = execute(
authorized("password")
.put(json.encodeToString(PasswordChangeRequest.serializer(), request).toRequestBody(JsonMedia))
.build(),
)
return decode(MessageResponse.serializer(), raw.ifBlank { "{}" })
}
suspend fun getTimewishes(): List<TimewishDto> =
decode(ListSerializer(TimewishDto.serializer()), execute(authorized("timewish").get().build()))
suspend fun createTimewish(request: TimewishCreateRequest): MessageResponse {
val raw = execute(
authorized("timewish")
.post(json.encodeToString(TimewishCreateRequest.serializer(), request).toRequestBody(JsonMedia))
.build(),
)
return decode(MessageResponse.serializer(), raw.ifBlank { "{}" })
}
suspend fun deleteTimewish(id: String) {
execute(authorized("timewish/$id").delete().build())
}
suspend fun getInvites(): List<InvitationDto> =
decode(ListSerializer(InvitationDto.serializer()), execute(authorized("invite").get().build()))
suspend fun sendInvite(request: InviteRequest): InvitationDto {
val raw = execute(
authorized("invite")
.post(json.encodeToString(InviteRequest.serializer(), request).toRequestBody(JsonMedia))
.build(),
)
return decode(InvitationDto.serializer(), raw)
}
suspend fun getWatchers(): List<WatcherDto> =
decode(ListSerializer(WatcherDto.serializer()), execute(authorized("watcher").get().build()))
suspend fun addWatcher(request: WatcherRequest): WatcherDto {
val raw = execute(
authorized("watcher")
.post(json.encodeToString(WatcherRequest.serializer(), request).toRequestBody(JsonMedia))
.build(),
)
return decode(WatcherDto.serializer(), raw)
}
suspend fun deleteWatcher(id: String) {
execute(authorized("watcher/$id").delete().build())
}
suspend fun getHolidayStates(): List<HolidayStateDto> =
decode(ListSerializer(HolidayStateDto.serializer()), execute(authorized("holidays/states").get().build()))
suspend fun getHolidays(): HolidaysResponse =
decode(HolidaysResponse.serializer(), execute(authorized("holidays").get().build()))
suspend fun createHoliday(request: HolidayCreateRequest): HolidayDto {
val raw = execute(
authorized("holidays")
.post(json.encodeToString(HolidayCreateRequest.serializer(), request).toRequestBody(JsonMedia))
.build(),
)
return decode(HolidayDto.serializer(), raw)
}
suspend fun deleteHoliday(id: String) {
execute(authorized("holidays/$id").delete().build())
}
suspend fun getRoleUsers(): List<RoleUserDto> =
decode(ListSerializer(RoleUserDto.serializer()), execute(authorized("roles/users").get().build()))
suspend fun updateUserRole(id: String, role: Int): RoleUserDto {
val raw = execute(
authorized("roles/users/$id")
.put(json.encodeToString(RoleUpdateRequest.serializer(), RoleUpdateRequest(role)).toRequestBody(JsonMedia))
.build(),
)
return decode(RoleUserDto.serializer(), raw)
}
private fun authorized(path: String): Request.Builder {
val t = tokenStore.getToken() ?: throw ApiException("Nicht angemeldet", 401)
return Request.Builder()
.url(endpoint(path))
.header("Authorization", "Bearer $t")
}
private suspend fun execute(request: Request): String = withContext(Dispatchers.IO) {
client.newCall(request).execute().use { response ->
val raw = response.body?.string().orEmpty()
if (response.code == 401) {
tokenStore.clearToken()
}
if (!response.isSuccessful) {
val err = runCatching { json.decodeFromString(ErrorBody.serializer(), raw) }.getOrNull()
val msg = err?.error ?: err?.message ?: "HTTP ${response.code}"
throw ApiException(msg, response.code)
}
raw
}
}
private fun <T> decode(deserializer: KSerializer<T>, raw: String): T =
if (raw.isBlank()) json.decodeFromString(deserializer, "{}")
else json.decodeFromString(deserializer, raw)
}

View File

@@ -0,0 +1,101 @@
package de.tsschulz.timeclock.data.api
import kotlinx.serialization.Serializable
@Serializable
data class CurrentStateResponse(
val success: Boolean = false,
val state: String? = null,
val error: String? = null,
)
@Serializable
data class ClockRequest(
val action: String,
)
@Serializable
data class ClockResponse(
val success: Boolean = false,
val message: String? = null,
val entry: RunningEntryDto? = null,
val error: String? = null,
)
@Serializable
data class RunningEntryDto(
val id: String? = null,
val startTime: String? = null,
val currentPauseStart: String? = null,
)
@Serializable
data class TimeStatsDto(
val totalEntries: Int? = null,
val completedEntries: Int? = null,
val runningEntries: Int? = null,
val totalHours: String? = null,
val timestamp: String? = null,
val currentlyWorked: String? = null,
val open: String? = null,
val requiredBreakMinutes: Int? = null,
val alreadyTakenBreakMinutes: Int? = null,
val missingBreakMinutes: Int? = null,
val regularEnd: String? = null,
val overtime: String? = null,
val totalOvertime: String? = null,
val weekWorktime: String? = null,
val nonWorkingHours: String? = null,
val openForWeek: String? = null,
val adjustedEndToday: String? = null,
val adjustedEndTodayGeneral: String? = null,
val adjustedEndTodayWeek: String? = null,
)
@Serializable
data class TimeEntryDto(
val id: String,
val project: String? = null,
val description: String? = null,
val startTime: String? = null,
val endTime: String? = null,
val duration: Long? = null,
val isRunning: Boolean = false,
val userId: String? = null,
)
@Serializable
data class WeekOverviewResponse(
val success: Boolean = false,
val data: WeekOverviewDto? = null,
val error: String? = null,
)
@Serializable
data class WeekOverviewDto(
val weekStart: String? = null,
val weekEnd: String? = null,
val weekTotal: String? = null,
val totalAll: String? = null,
val days: List<WeekDayDto> = emptyList(),
)
@Serializable
data class WeekDayDto(
val name: String? = null,
val date: String? = null,
val isToday: Boolean = false,
val workTime: String? = null,
val totalWorkTime: String? = null,
val netWorkTime: String? = null,
val status: String? = null,
val statusText: String? = null,
val workBlocks: List<WorkBlockDto> = emptyList(),
)
@Serializable
data class WorkBlockDto(
val workTime: String? = null,
val totalWorkTime: String? = null,
val netWorkTime: String? = null,
)

View File

@@ -0,0 +1,83 @@
package de.tsschulz.timeclock.data.auth
import de.tsschulz.timeclock.data.api.ApiException
import de.tsschulz.timeclock.data.api.LoginRequest
import de.tsschulz.timeclock.data.api.TimeClockApiClient
import de.tsschulz.timeclock.data.api.UserDto
import java.io.IOException
data class UserProfile(
val id: Int,
val fullName: String,
val email: String?,
val role: Int,
) {
val isAdmin: Boolean get() = role == 1
}
class AuthRepository(
private val api: TimeClockApiClient,
private val tokenStore: TokenStore,
) {
fun hasStoredToken(): Boolean = tokenStore.getToken() != null
suspend fun restoreSession(): UserProfile? {
if (tokenStore.getToken() == null) return null
return try {
val me = api.getMe()
if (me.success && me.user != null) {
me.user!!.toProfile()
} else {
tokenStore.clearToken()
null
}
} catch (_: ApiException) {
null
} catch (_: IOException) {
null
} catch (_: Exception) {
null
}
}
suspend fun login(email: String, password: String, action: String = "0"): Result<UserProfile> {
return try {
val res = api.postLogin(
LoginRequest(
email = email.trim(),
password = password,
action = action,
),
)
if (res.success && !res.token.isNullOrBlank() && res.user != null) {
tokenStore.saveToken(res.token)
Result.success(res.user!!.toProfile())
} else {
Result.failure(Exception(res.error ?: "Login fehlgeschlagen"))
}
} catch (e: ApiException) {
Result.failure(Exception(e.message ?: "Login fehlgeschlagen"))
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun logout() {
try {
api.postLogout()
} catch (_: Exception) {
// Session serverseitig ungültig oder offline — lokal trotzdem leeren
} finally {
tokenStore.clearToken()
}
}
private fun UserDto.toProfile(): UserProfile =
UserProfile(
id = id,
fullName = fullName,
email = email,
role = role,
)
}

View File

@@ -0,0 +1,38 @@
package de.tsschulz.timeclock.data.auth
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
class TokenStore(context: Context) {
private val appContext = context.applicationContext
private val prefs by lazy {
val masterKey = MasterKey.Builder(appContext)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
appContext,
PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
fun getToken(): String? = prefs.getString(KEY_TOKEN, null)
fun saveToken(token: String) {
prefs.edit().putString(KEY_TOKEN, token).apply()
}
fun clearToken() {
prefs.edit().remove(KEY_TOKEN).apply()
}
private companion object {
const val PREFS_NAME = "timeclock_auth_prefs"
const val KEY_TOKEN = "jwt"
}
}

View File

@@ -0,0 +1,37 @@
package de.tsschulz.timeclock.data.booking
import de.tsschulz.timeclock.data.api.CalendarDto
import de.tsschulz.timeclock.data.api.SickCreateRequest
import de.tsschulz.timeclock.data.api.SickEntryDto
import de.tsschulz.timeclock.data.api.SickTypeDto
import de.tsschulz.timeclock.data.api.TimeClockApiClient
import de.tsschulz.timeclock.data.api.TimefixCreateRequest
import de.tsschulz.timeclock.data.api.TimefixDto
import de.tsschulz.timeclock.data.api.VacationCreateRequest
import de.tsschulz.timeclock.data.api.VacationDto
import de.tsschulz.timeclock.data.api.WorkdaysDto
import de.tsschulz.timeclock.data.api.WorklogEntryDto
class BookingRepository(
private val api: TimeClockApiClient,
) {
suspend fun getVacations(): List<VacationDto> = api.getVacations()
suspend fun createVacation(type: Int, start: String, end: String?) =
api.createVacation(VacationCreateRequest(type, start, end))
suspend fun deleteVacation(id: String) = api.deleteVacation(id)
suspend fun getSickEntries(): List<SickEntryDto> = api.getSickEntries()
suspend fun getSickTypes(): List<SickTypeDto> = api.getSickTypes()
suspend fun createSick(typeId: String, start: String, end: String?) =
api.createSick(SickCreateRequest(typeId, start, end ?: start))
suspend fun deleteSick(id: String) = api.deleteSick(id)
suspend fun getTimefixes(): List<TimefixDto> = api.getTimefixes()
suspend fun getWorklogEntries(date: String): List<WorklogEntryDto> = api.getWorklogEntries(date)
suspend fun createTimefix(worklogId: String, date: String, time: String, action: String) =
api.createTimefix(TimefixCreateRequest(worklogId, date, time, action))
suspend fun deleteTimefix(id: String) = api.deleteTimefix(id)
suspend fun getWorkdays(year: Int): WorkdaysDto = api.getWorkdays(year)
suspend fun getCalendar(year: Int, month: Int): CalendarDto = api.getCalendar(year, month)
}

View File

@@ -0,0 +1,51 @@
package de.tsschulz.timeclock.data.offline
import android.content.Context
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import java.util.UUID
@Serializable
data class PendingClockAction(
val id: String,
val action: String,
val createdAtEpochMillis: Long,
)
class OfflineClockQueue(
context: Context,
private val json: Json = Json { ignoreUnknownKeys = true },
) {
private val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun enqueue(action: String) {
val next = pending() + PendingClockAction(
id = UUID.randomUUID().toString(),
action = action,
createdAtEpochMillis = System.currentTimeMillis(),
)
save(next)
}
fun pending(): List<PendingClockAction> {
val raw = prefs.getString(KEY_ACTIONS, null) ?: return emptyList()
return runCatching {
json.decodeFromString(ListSerializer(PendingClockAction.serializer()), raw)
}.getOrDefault(emptyList())
}
fun remove(id: String) {
save(pending().filterNot { it.id == id })
}
private fun save(actions: List<PendingClockAction>) {
val raw = json.encodeToString(ListSerializer(PendingClockAction.serializer()), actions)
prefs.edit().putString(KEY_ACTIONS, raw).apply()
}
private companion object {
const val PREFS_NAME = "timeclock_offline_clock_queue"
const val KEY_ACTIONS = "pending_clock_actions"
}
}

View File

@@ -0,0 +1,37 @@
package de.tsschulz.timeclock.data.settings
import de.tsschulz.timeclock.data.api.InvitationDto
import de.tsschulz.timeclock.data.api.InviteRequest
import de.tsschulz.timeclock.data.api.PasswordChangeRequest
import de.tsschulz.timeclock.data.api.ProfileDto
import de.tsschulz.timeclock.data.api.ProfileUpdateRequest
import de.tsschulz.timeclock.data.api.StateDto
import de.tsschulz.timeclock.data.api.TimeClockApiClient
import de.tsschulz.timeclock.data.api.TimewishCreateRequest
import de.tsschulz.timeclock.data.api.TimewishDto
import de.tsschulz.timeclock.data.api.WatcherDto
import de.tsschulz.timeclock.data.api.WatcherRequest
class SettingsRepository(
private val api: TimeClockApiClient,
) {
suspend fun getProfile(): ProfileDto = api.getProfile()
suspend fun getStates(): List<StateDto> = api.getStates()
suspend fun updateProfile(fullName: String, stateId: String?, weekWorkdays: Int, dailyHours: Double, preferredTitleType: Int) =
api.updateProfile(ProfileUpdateRequest(fullName, stateId, weekWorkdays, dailyHours, preferredTitleType))
suspend fun changePassword(oldPassword: String, newPassword: String, confirmPassword: String) =
api.changePassword(PasswordChangeRequest(oldPassword, newPassword, confirmPassword))
suspend fun getTimewishes(): List<TimewishDto> = api.getTimewishes()
suspend fun createTimewish(day: Int, wishtype: Int, hours: Double?, startDate: String, endDate: String?) =
api.createTimewish(TimewishCreateRequest(day, wishtype, hours, startDate, endDate))
suspend fun deleteTimewish(id: String) = api.deleteTimewish(id)
suspend fun getInvites(): List<InvitationDto> = api.getInvites()
suspend fun sendInvite(email: String) = api.sendInvite(InviteRequest(email))
suspend fun getWatchers(): List<WatcherDto> = api.getWatchers()
suspend fun addWatcher(email: String) = api.addWatcher(WatcherRequest(email))
suspend fun deleteWatcher(id: String) = api.deleteWatcher(id)
}

View File

@@ -0,0 +1,70 @@
package de.tsschulz.timeclock.data.time
import de.tsschulz.timeclock.data.api.TimeClockApiClient
import de.tsschulz.timeclock.data.api.TimeEntryDto
import de.tsschulz.timeclock.data.api.TimeStatsDto
import de.tsschulz.timeclock.data.api.WeekOverviewDto
import de.tsschulz.timeclock.data.offline.OfflineClockQueue
import java.io.IOException
class TimeRepository(
private val api: TimeClockApiClient,
private val offlineClockQueue: OfflineClockQueue? = null,
) {
suspend fun loadDashboard(): TimeDashboard {
syncOfflineClockActions()
val state = api.getCurrentState().state
val running = api.getRunningEntry()
val stats = api.getTimeStats()
return TimeDashboard(
state = state,
runningStartTime = running.startTime,
currentPauseStart = running.currentPauseStart,
stats = stats,
)
}
suspend fun clock(action: String): TimeDashboard {
val response = try {
api.postClock(action)
} catch (e: IOException) {
offlineClockQueue?.enqueue(action)
throw IllegalStateException("Keine Verbindung. Die Stempelaktion wurde offline gespeichert und wird später synchronisiert.", e)
}
if (!response.success) {
throw IllegalStateException(response.error ?: "Stempeln fehlgeschlagen")
}
return loadDashboard()
}
suspend fun loadWeek(weekOffset: Int): WeekOverviewDto {
val response = api.getWeekOverview(weekOffset)
if (!response.success || response.data == null) {
throw IllegalStateException(response.error ?: "Wochenübersicht konnte nicht geladen werden")
}
return response.data
}
suspend fun loadEntries(): List<TimeEntryDto> = api.getTimeEntries()
suspend fun deleteEntry(id: String) = api.deleteTimeEntry(id)
suspend fun loadStats(): TimeStatsDto = api.getTimeStats()
private suspend fun syncOfflineClockActions() {
val queue = offlineClockQueue ?: return
queue.pending().forEach { pending ->
try {
val response = api.postClock(pending.action)
if (response.success) queue.remove(pending.id)
} catch (_: IOException) {
return
}
}
}
}
data class TimeDashboard(
val state: String?,
val runningStartTime: String?,
val currentPauseStart: String?,
val stats: TimeStatsDto,
)

View File

@@ -0,0 +1,414 @@
package de.tsschulz.timeclock.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.tsschulz.timeclock.data.api.WeekDayDto
import de.tsschulz.timeclock.data.api.WeekOverviewDto
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.tsschulz.timeclock.ui.admin.AdminViewModel
import de.tsschulz.timeclock.ui.admin.HolidaysAdminScreen
import de.tsschulz.timeclock.ui.admin.RolesAdminScreen
import de.tsschulz.timeclock.ui.auth.AuthViewModel
import de.tsschulz.timeclock.ui.auth.LoginScreen
import de.tsschulz.timeclock.ui.booking.BookingViewModel
import de.tsschulz.timeclock.ui.booking.CalendarScreen
import de.tsschulz.timeclock.ui.booking.SickScreen
import de.tsschulz.timeclock.ui.booking.TimefixScreen
import de.tsschulz.timeclock.ui.booking.VacationScreen
import de.tsschulz.timeclock.ui.booking.WorkdaysScreen
import de.tsschulz.timeclock.ui.components.TcButton
import de.tsschulz.timeclock.ui.components.TcCard
import de.tsschulz.timeclock.ui.components.TcError
import de.tsschulz.timeclock.ui.components.TcLoading
import de.tsschulz.timeclock.ui.components.TcScaffold
import de.tsschulz.timeclock.ui.components.TcTextField
import de.tsschulz.timeclock.ui.model.AppRoute
import de.tsschulz.timeclock.ui.model.ButtonVariant
import de.tsschulz.timeclock.ui.model.adminSections
import de.tsschulz.timeclock.ui.model.userSections
import de.tsschulz.timeclock.ui.settings.InviteScreen
import de.tsschulz.timeclock.ui.settings.PasswordScreen
import de.tsschulz.timeclock.ui.settings.PermissionsScreen
import de.tsschulz.timeclock.ui.settings.ProfileScreen
import de.tsschulz.timeclock.ui.settings.SettingsViewModel
import de.tsschulz.timeclock.ui.settings.TimewishScreen
import de.tsschulz.timeclock.ui.theme.TcColors
import de.tsschulz.timeclock.ui.theme.TcSpacing
import de.tsschulz.timeclock.ui.time.EntriesScreen
import de.tsschulz.timeclock.ui.time.StatsScreen
import de.tsschulz.timeclock.ui.time.TimeViewModel
import java.time.LocalDate
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
@Composable
fun TimeClockApp(
authViewModel: AuthViewModel,
timeViewModel: TimeViewModel,
bookingViewModel: BookingViewModel,
settingsViewModel: SettingsViewModel,
adminViewModel: AdminViewModel,
) {
val authState by authViewModel.uiState.collectAsStateWithLifecycle()
val timeState by timeViewModel.uiState.collectAsStateWithLifecycle()
val bookingState by bookingViewModel.uiState.collectAsStateWithLifecycle()
val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle()
val adminState by adminViewModel.uiState.collectAsStateWithLifecycle()
if (!authState.isAuthenticated) {
LaunchedEffect(Unit) { timeViewModel.stop() }
LoginScreen(
state = authState,
onLogin = { e, p, action -> authViewModel.login(e, p, action) },
onRetryBootstrap = { authViewModel.retryBootstrap() },
)
return
}
val user = authState.user
if (user == null) {
LoginScreen(
state = authState,
onLogin = { e, p, action -> authViewModel.login(e, p, action) },
onRetryBootstrap = { authViewModel.retryBootstrap() },
)
return
}
val sections = if (user.isAdmin) adminSections else userSections
LaunchedEffect(user.id) { timeViewModel.start() }
LaunchedEffect(user.id) { bookingViewModel.loadPhase4() }
LaunchedEffect(user.id) { settingsViewModel.loadPhase5() }
LaunchedEffect(user.id, user.isAdmin) { adminViewModel.loadPhase6(user.isAdmin) }
var selectedRoute by remember { mutableStateOf(AppRoute.Week) }
BoxWithConstraints(modifier = Modifier.background(TcColors.Background)) {
val isTablet = maxWidth >= 840.dp
TcScaffold(
title = selectedRoute.title,
userName = user.fullName,
sections = sections,
selectedRoute = selectedRoute,
isTablet = isTablet,
statusRows = timeState.statusRows,
primaryStatusAction = timeState.primaryAction,
secondaryStatusAction = timeState.secondaryAction,
onRouteSelected = { selectedRoute = it },
onLogout = {
timeViewModel.stop()
authViewModel.logout()
},
onStatusAction = { action ->
action.clockAction?.let { timeViewModel.clock(it) }
},
) {
timeState.error?.let { TcError(it) }
DemoScreen(
route = selectedRoute,
isTablet = isTablet,
week = timeState.week,
weekLoading = timeState.weekLoading,
weekError = timeState.weekError,
weekOffset = timeState.weekOffset,
onWeekOffset = { timeViewModel.loadWeek(it) },
timeState = timeState,
timeViewModel = timeViewModel,
bookingState = bookingState,
bookingViewModel = bookingViewModel,
settingsState = settingsState,
settingsViewModel = settingsViewModel,
adminState = adminState,
adminViewModel = adminViewModel,
)
}
}
}
@Composable
private fun DemoScreen(
route: AppRoute,
isTablet: Boolean,
week: WeekOverviewDto?,
weekLoading: Boolean,
weekError: String?,
weekOffset: Int,
onWeekOffset: (Int) -> Unit,
timeState: de.tsschulz.timeclock.ui.time.TimeUiState,
timeViewModel: TimeViewModel,
bookingState: de.tsschulz.timeclock.ui.booking.BookingUiState,
bookingViewModel: BookingViewModel,
settingsState: de.tsschulz.timeclock.ui.settings.SettingsUiState,
settingsViewModel: SettingsViewModel,
adminState: de.tsschulz.timeclock.ui.admin.AdminUiState,
adminViewModel: AdminViewModel,
) {
when (route) {
AppRoute.Week -> WeekOverviewScreen(
week = week,
loading = weekLoading,
error = weekError,
weekOffset = weekOffset,
onWeekOffset = onWeekOffset,
isTablet = isTablet,
)
AppRoute.Timefix -> TimefixScreen(
state = bookingState,
isTablet = isTablet,
onDate = { bookingViewModel.setTimefixDate(it) },
onCreate = { id, date, time, action -> bookingViewModel.createTimefix(id, date, time, action) },
onDelete = { bookingViewModel.deleteTimefix(it) },
)
AppRoute.Vacation -> VacationScreen(
state = bookingState,
isTablet = isTablet,
onCreate = { type, start, end -> bookingViewModel.createVacation(type, start, end) },
onDelete = { bookingViewModel.deleteVacation(it) },
)
AppRoute.Sick -> SickScreen(
state = bookingState,
isTablet = isTablet,
onCreate = { type, start, end -> bookingViewModel.createSick(type, start, end) },
onDelete = { bookingViewModel.deleteSick(it) },
)
AppRoute.Workdays -> WorkdaysScreen(state = bookingState, onYear = { bookingViewModel.loadWorkdays(it) })
AppRoute.Calendar -> CalendarScreen(state = bookingState, onMonth = { bookingViewModel.changeCalendarMonth(it) })
AppRoute.Entries -> {
LaunchedEffect(Unit) { timeViewModel.loadEntries() }
EntriesScreen(
entries = timeState.entries,
loading = timeState.entriesLoading,
error = timeState.entriesError,
onRefresh = { timeViewModel.loadEntries() },
onDelete = { timeViewModel.deleteEntry(it) },
)
}
AppRoute.Stats -> {
LaunchedEffect(Unit) { timeViewModel.loadStats() }
StatsScreen(
stats = timeState.stats,
loading = timeState.statsLoading,
error = timeState.statsError,
onRefresh = { timeViewModel.loadStats() },
)
}
AppRoute.Export -> TcCard { Text("Export ist in der Android-App deaktiviert.", color = TcColors.TextMuted) }
AppRoute.Profile -> ProfileScreen(
state = settingsState,
isTablet = isTablet,
onSave = { name, stateId, weekWorkdays, dailyHours, titleType ->
settingsViewModel.updateProfile(name, stateId, weekWorkdays, dailyHours, titleType)
},
)
AppRoute.Password -> PasswordScreen(
state = settingsState,
onChange = { old, new, confirm -> settingsViewModel.changePassword(old, new, confirm) },
)
AppRoute.Timewish -> TimewishScreen(
state = settingsState,
isTablet = isTablet,
onCreate = { day, type, hours, start, end -> settingsViewModel.createTimewish(day, type, hours, start, end) },
onDelete = { settingsViewModel.deleteTimewish(it) },
)
AppRoute.Permissions -> PermissionsScreen(
state = settingsState,
isTablet = isTablet,
onAdd = { settingsViewModel.addWatcher(it) },
onDelete = { settingsViewModel.deleteWatcher(it) },
)
AppRoute.Invite -> InviteScreen(
state = settingsState,
isTablet = isTablet,
onSend = { settingsViewModel.sendInvite(it) },
)
AppRoute.Holidays -> HolidaysAdminScreen(
state = adminState,
isTablet = isTablet,
onCreate = { date, hours, description, stateIds -> adminViewModel.createHoliday(date, hours, description, stateIds) },
onDelete = { adminViewModel.deleteHoliday(it) },
)
AppRoute.Roles -> RolesAdminScreen(
state = adminState,
onUpdateRole = { id, role -> adminViewModel.updateUserRole(id, role) },
)
}
}
@Composable
private fun WeekOverviewScreen(
week: WeekOverviewDto?,
loading: Boolean,
error: String?,
weekOffset: Int,
onWeekOffset: (Int) -> Unit,
isTablet: Boolean,
) {
TcCard {
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
TcButton("← Vorherige Woche", variant = ButtonVariant.Secondary, onClick = { onWeekOffset(weekOffset - 1) })
Text(
text = week?.rangeText() ?: "Wochenübersicht",
color = TcColors.Text,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
)
TcButton("Nächste Woche →", variant = ButtonVariant.Secondary, onClick = { onWeekOffset(weekOffset + 1) })
}
}
when {
loading -> TcLoading("Lade Wochenübersicht...")
error != null -> TcError(error)
week == null -> TcCard { Text("Keine Wochenübersicht vorhanden", color = TcColors.TextMuted) }
isTablet -> WeekTablet(week)
else -> WeekPhone(week)
}
}
@Composable
private fun WeekTablet(week: WeekOverviewDto) {
Row(horizontalArrangement = Arrangement.spacedBy(TcSpacing.Xl), modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(TcSpacing.Lg)) {
week.days.forEach { day -> WeekDayCard(day) }
}
TcCard(modifier = Modifier.weight(1f)) {
SectionTitle("Wochensumme")
DetailRow("Arbeitszeit", week.weekTotal ?: "0:00")
DetailRow("Gesamt", week.totalAll ?: week.weekTotal ?: "0:00")
val today = week.days.firstOrNull { it.isToday } ?: week.days.firstOrNull()
today?.let {
SectionTitle("Aktueller Tag")
DetailRow("Tag", it.name ?: "")
DetailRow("Datum", it.date.toDisplayDate())
DetailRow("Status", it.statusText ?: "")
DetailRow("Arbeitszeit", it.netWorkTime ?: it.totalWorkTime ?: "")
}
}
}
}
@Composable
private fun WeekPhone(week: WeekOverviewDto) {
week.days.forEach { day -> WeekDayCard(day) }
TcCard {
SectionTitle("Wochensumme")
DetailRow("Arbeitszeit", week.weekTotal ?: "0:00")
DetailRow("Gesamt", week.totalAll ?: week.weekTotal ?: "0:00")
}
}
@Composable
private fun WeekDayCard(day: WeekDayDto) {
TcCard {
SectionTitle("${day.name ?: "Tag"} ${day.date.toDisplayDate()}")
val blocks = day.workBlocks
if (blocks.isNotEmpty()) {
blocks.forEachIndexed { index, block ->
val label = if (blocks.size > 1) "Arbeitszeit ${index + 1}" else "Arbeitszeit"
DetailRow(label, block.workTime ?: day.workTime ?: "")
DetailRow("Netto", block.netWorkTime ?: block.totalWorkTime ?: "")
}
} else {
DetailRow("Arbeitszeit", day.workTime ?: "")
DetailRow("Netto", day.netWorkTime ?: day.totalWorkTime ?: "")
}
DetailRow("Status", day.statusText ?: "")
}
}
@Composable
private fun CalendarDemo(isTablet: Boolean) {
if (isTablet) {
Row(horizontalArrangement = Arrangement.spacedBy(TcSpacing.Xl), modifier = Modifier.fillMaxWidth()) {
ListDemo(title = "Kalender", modifier = Modifier.weight(1f))
TcCard(modifier = Modifier.weight(1f)) {
SectionTitle("Tagesdetails")
DetailRow("Datum", "14.05.2026")
DetailRow("Typ", "Arbeitstag")
DetailRow("Status", "Arbeit läuft")
}
}
} else {
ListDemo(title = "Kalender")
}
}
@Composable
private fun FormDemo(title: String, primaryAction: String) {
var text by remember(title) { mutableStateOf("") }
TcCard {
SectionTitle(title)
TcTextField(label = "Bezeichnung", value = text, onValueChange = { text = it }, placeholder = "Eingabe")
Row(horizontalArrangement = Arrangement.spacedBy(TcSpacing.Sm), modifier = Modifier.padding(top = TcSpacing.Lg)) {
TcButton(primaryAction, variant = ButtonVariant.Primary)
TcButton("Abbrechen")
}
}
}
@Composable
private fun ListDemo(title: String, modifier: Modifier = Modifier) {
TcCard(modifier = modifier) {
SectionTitle(title)
DetailRow("Eintrag 1", "bereit")
DetailRow("Eintrag 2", "offen")
DetailRow("Eintrag 3", "geprüft")
}
}
@Composable
private fun WeekCard(day: String, time: String, status: String) {
TcCard {
SectionTitle(day)
DetailRow("Zeit", time)
DetailRow("Status", status)
}
}
private fun WeekOverviewDto.rangeText(): String =
"${weekStart.toDisplayDate()} - ${weekEnd.toDisplayDate()}"
private fun String?.toDisplayDate(): String {
if (this.isNullOrBlank()) return ""
return runCatching {
LocalDate.parse(this.substring(0, 10)).format(DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale.GERMANY))
}.recoverCatching {
OffsetDateTime.parse(this).toLocalDate().format(DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale.GERMANY))
}.getOrDefault(this)
}
@Composable
private fun SectionTitle(text: String) {
Text(
text = text,
color = TcColors.Text,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = TcSpacing.Md),
)
}
@Composable
private fun DetailRow(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text(text = "$label:", color = TcColors.TextMuted, fontSize = 14.sp)
Text(text = value, color = TcColors.Text, fontSize = 14.sp)
}
}

View File

@@ -0,0 +1,278 @@
package de.tsschulz.timeclock.ui.admin
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.tsschulz.timeclock.data.api.HolidayDto
import de.tsschulz.timeclock.data.api.RoleUserDto
import de.tsschulz.timeclock.ui.components.TcButton
import de.tsschulz.timeclock.ui.components.TcCard
import de.tsschulz.timeclock.ui.components.TcError
import de.tsschulz.timeclock.ui.components.TcLoading
import de.tsschulz.timeclock.ui.components.TcTextField
import de.tsschulz.timeclock.ui.model.ButtonVariant
import de.tsschulz.timeclock.ui.theme.TcColors
import de.tsschulz.timeclock.ui.theme.TcRadius
import de.tsschulz.timeclock.ui.theme.TcSpacing
import java.time.LocalDate
@Composable
fun HolidaysAdminScreen(
state: AdminUiState,
isTablet: Boolean,
onCreate: (String, Double, String, List<String>) -> Unit,
onDelete: (String) -> Unit,
) {
var date by rememberSaveable { mutableStateOf(LocalDate.now().toString()) }
var hours by rememberSaveable { mutableStateOf("8") }
var description by rememberSaveable { mutableStateOf("") }
var stateIds by rememberSaveable { mutableStateOf("") }
AdminFrame(state) {
if (isTablet) {
Row(horizontalArrangement = Arrangement.spacedBy(TcSpacing.Xl), modifier = Modifier.fillMaxWidth()) {
HolidayForm(
state = state,
date = date,
onDate = { date = it },
hours = hours,
onHours = { hours = it },
description = description,
onDescription = { description = it },
stateIds = stateIds,
onStateIds = { stateIds = it },
onCreate = {
onCreate(date, hours.toDoubleOrNull() ?: 8.0, description, parseStateIds(stateIds))
description = ""
},
modifier = Modifier.weight(0.9f),
)
Column(modifier = Modifier.weight(1.1f), verticalArrangement = Arrangement.spacedBy(TcSpacing.Lg)) {
HolidayList("Zukünftige Feiertage", state.futureHolidays, onDelete)
HolidayList("Vergangene Feiertage", state.pastHolidays, onDelete)
}
}
} else {
HolidayForm(
state = state,
date = date,
onDate = { date = it },
hours = hours,
onHours = { hours = it },
description = description,
onDescription = { description = it },
stateIds = stateIds,
onStateIds = { stateIds = it },
onCreate = {
onCreate(date, hours.toDoubleOrNull() ?: 8.0, description, parseStateIds(stateIds))
description = ""
},
)
HolidayList("Zukünftige Feiertage", state.futureHolidays, onDelete)
HolidayList("Vergangene Feiertage", state.pastHolidays, onDelete)
}
}
}
@Composable
fun RolesAdminScreen(
state: AdminUiState,
onUpdateRole: (String, Int) -> Unit,
) {
AdminFrame(state) {
TcCard {
SectionTitle("Rechte")
Text(
text = "Als Administrator können Sie hier die Berechtigungen anderer Benutzer verwalten.",
color = TcColors.TextMuted,
fontSize = 14.sp,
)
}
ListCard("Benutzer", state.users) { user ->
UserRoleRow(user, onUpdateRole)
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun HolidayForm(
state: AdminUiState,
date: String,
onDate: (String) -> Unit,
hours: String,
onHours: (String) -> Unit,
description: String,
onDescription: (String) -> Unit,
stateIds: String,
onStateIds: (String) -> Unit,
onCreate: () -> Unit,
modifier: Modifier = Modifier,
) {
FormCard("Feiertag hinzufügen", modifier) {
TcTextField("Datum", date, onDate, placeholder = "YYYY-MM-DD")
TcTextField("Freie Stunden", hours, onHours, placeholder = "8")
TcTextField("Beschreibung", description, onDescription, placeholder = "z.B. Tag der Deutschen Einheit")
TcTextField(
label = "Bundesland-IDs",
value = stateIds,
onValueChange = onStateIds,
placeholder = "Leer lassen für Bundesfeiertag",
)
if (state.holidayStates.isNotEmpty()) {
Text("Bundesländer", color = TcColors.TextMuted, fontSize = 13.sp)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(TcSpacing.Sm),
verticalArrangement = Arrangement.spacedBy(TcSpacing.Sm),
) {
val selected = parseStateIds(stateIds).toSet()
state.holidayStates.forEach { item ->
TcButton(
text = item.name,
variant = if (item.id in selected) ButtonVariant.Primary else ButtonVariant.Default,
onClick = { onStateIds(toggleStateId(stateIds, item.id)) },
)
}
}
}
TcButton("Feiertag hinzufügen", variant = ButtonVariant.Primary, onClick = {
if (date.isNotBlank() && description.isNotBlank()) onCreate()
})
}
}
@Composable
private fun HolidayList(title: String, holidays: List<HolidayDto>, onDelete: (String) -> Unit) {
ListCard(title, holidays) { holiday -> HolidayRow(holiday, onDelete) }
}
@Composable
private fun HolidayRow(holiday: HolidayDto, onDelete: (String) -> Unit) = DataRow(
title = holiday.description,
details = "${holiday.date.toDisplayDate()} - ${holiday.hours} h - ${
if (holiday.isFederal || holiday.states.isEmpty()) "Bundesfeiertag" else holiday.states.joinToString()
}",
action = "Löschen",
variant = ButtonVariant.Danger,
onAction = { onDelete(holiday.id) },
)
@Composable
private fun UserRoleRow(user: RoleUserDto, onUpdateRole: (String, Int) -> Unit) {
val isAdmin = user.role == 1 || user.roleString == "admin"
DataRow(
title = user.fullName,
details = "${user.stateName ?: "-"} - ${if (isAdmin) "Administrator" else "Benutzer"}",
action = if (isAdmin) "Zu Benutzer" else "Zu Admin",
variant = if (isAdmin) ButtonVariant.Secondary else ButtonVariant.Primary,
onAction = { onUpdateRole(user.id, if (isAdmin) 0 else 1) },
)
}
@Composable
private fun AdminFrame(state: AdminUiState, content: @Composable ColumnScope.() -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(TcSpacing.Lg)) {
if (state.loading) TcLoading("Lade Daten...")
state.error?.let { TcError(it) }
state.success?.let {
TcCard { Text(it, color = TcColors.Success, fontSize = 14.sp, fontWeight = FontWeight.Medium) }
}
content()
}
}
@Composable
private fun FormCard(title: String, modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
TcCard(modifier = modifier) {
SectionTitle(title)
Column(verticalArrangement = Arrangement.spacedBy(TcSpacing.Md), content = content)
}
}
@Composable
private fun <T> ListCard(title: String, items: List<T>, row: @Composable (T) -> Unit) {
TcCard {
SectionTitle(title)
if (items.isEmpty()) {
Text("Keine Einträge vorhanden.", color = TcColors.TextMuted, fontSize = 14.sp)
} else {
Column(verticalArrangement = Arrangement.spacedBy(TcSpacing.Sm)) {
items.forEach { item -> row(item) }
}
}
}
}
@Composable
private fun DataRow(
title: String,
details: String,
action: String,
variant: ButtonVariant,
onAction: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(TcColors.Background, RoundedCornerShape(TcRadius.Medium))
.border(1.dp, TcColors.Border, RoundedCornerShape(TcRadius.Medium))
.padding(TcSpacing.Md),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TcSpacing.Md),
) {
Column(modifier = Modifier.weight(1f)) {
Text(title, color = TcColors.Text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
Text(details, color = TcColors.TextMuted, fontSize = 13.sp)
}
TcButton(action, variant = variant, onClick = onAction)
}
}
@Composable
private fun SectionTitle(title: String) {
Text(
text = title,
color = TcColors.Text,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = TcSpacing.Sm),
)
}
private fun parseStateIds(raw: String): List<String> =
raw.split(',', ';', ' ')
.map { it.trim() }
.filter { it.isNotBlank() }
private fun toggleStateId(raw: String, id: String): String {
val ids = parseStateIds(raw).toMutableList()
if (id in ids) ids.remove(id) else ids.add(id)
return ids.joinToString(",")
}
private fun String?.toDisplayDate(): String {
if (this.isNullOrBlank()) return "-"
return runCatching {
val p = LocalDate.parse(this)
"%02d.%02d.%04d".format(p.dayOfMonth, p.monthValue, p.year)
}.getOrDefault(this)
}

View File

@@ -0,0 +1,95 @@
package de.tsschulz.timeclock.ui.admin
import android.app.Application
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import de.tsschulz.timeclock.data.admin.AdminRepository
import de.tsschulz.timeclock.data.api.HolidayDto
import de.tsschulz.timeclock.data.api.HolidayStateDto
import de.tsschulz.timeclock.data.api.RoleUserDto
import de.tsschulz.timeclock.data.api.TimeClockApiClient
import de.tsschulz.timeclock.data.auth.TokenStore
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class AdminUiState(
val loading: Boolean = false,
val error: String? = null,
val success: String? = null,
val holidayStates: List<HolidayStateDto> = emptyList(),
val futureHolidays: List<HolidayDto> = emptyList(),
val pastHolidays: List<HolidayDto> = emptyList(),
val users: List<RoleUserDto> = emptyList(),
)
class AdminViewModel(
private val repository: AdminRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(AdminUiState())
val uiState: StateFlow<AdminUiState> = _uiState.asStateFlow()
fun loadPhase6(isAdmin: Boolean) {
if (!isAdmin) return
loadHolidays()
loadRoles()
}
fun loadHolidays() = launchLoad {
val holidays = repository.getHolidays()
copy(
holidayStates = repository.getHolidayStates(),
futureHolidays = holidays.future,
pastHolidays = holidays.past,
)
}
fun createHoliday(date: String, hours: Double, description: String, stateIds: List<String>) = launchMutation("Feiertag gespeichert") {
repository.createHoliday(date, hours, description, stateIds)
loadHolidays()
}
fun deleteHoliday(id: String) = launchMutation("Feiertag gelöscht") {
repository.deleteHoliday(id)
loadHolidays()
}
fun loadRoles() = launchLoad {
copy(users = repository.getRoleUsers())
}
fun updateUserRole(id: String, role: Int) = launchMutation("Rolle geändert") {
repository.updateUserRole(id, role)
loadRoles()
}
private fun launchLoad(reducer: suspend AdminUiState.() -> AdminUiState) {
viewModelScope.launch {
_uiState.update { it.copy(loading = true, error = null, success = null) }
runCatching { _uiState.value.reducer() }
.onSuccess { next -> _uiState.value = next.copy(loading = false, error = null) }
.onFailure { e -> _uiState.update { it.copy(loading = false, error = e.message ?: "Daten konnten nicht geladen werden") } }
}
}
private fun launchMutation(successMessage: String, block: suspend () -> Unit) {
viewModelScope.launch {
_uiState.update { it.copy(loading = true, error = null, success = null) }
runCatching { block() }
.onSuccess { _uiState.update { it.copy(success = successMessage) } }
.onFailure { e -> _uiState.update { it.copy(error = e.message ?: "Aktion fehlgeschlagen") } }
_uiState.update { it.copy(loading = false) }
}
}
class Factory(private val application: Application) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val tokenStore = TokenStore(application)
return AdminViewModel(AdminRepository(TimeClockApiClient(tokenStore))) as T
}
}
}

View File

@@ -0,0 +1,109 @@
package de.tsschulz.timeclock.ui.auth
import android.app.Application
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import de.tsschulz.timeclock.data.auth.AuthRepository
import de.tsschulz.timeclock.data.auth.TokenStore
import de.tsschulz.timeclock.data.auth.UserProfile
import de.tsschulz.timeclock.data.api.TimeClockApiClient
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class AuthUiState(
val bootstrapping: Boolean = true,
val isAuthenticated: Boolean = false,
val user: UserProfile? = null,
val loginInProgress: Boolean = false,
val error: String? = null,
/** Gespeicherter Token, aber `/auth/me` ist fehlgeschlagen (z. B. offline). */
val bootstrapWarn: String? = null,
)
class AuthViewModel(
private val repository: AuthRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(AuthUiState())
val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch { runBootstrap() }
}
private suspend fun runBootstrap() {
val user = repository.restoreSession()
val warn = if (user == null && repository.hasStoredToken()) {
"Profil konnte nicht geladen werden. Bitte Netzwerk prüfen oder erneut versuchen."
} else {
null
}
_uiState.value = AuthUiState(
bootstrapping = false,
isAuthenticated = user != null,
user = user,
bootstrapWarn = warn,
)
}
fun login(email: String, password: String, action: String) {
if (email.isBlank() || password.isBlank()) {
_uiState.update { it.copy(error = "E-Mail und Passwort sind erforderlich") }
return
}
viewModelScope.launch {
_uiState.update { it.copy(loginInProgress = true, error = null) }
repository.login(email, password, action).fold(
onSuccess = { user ->
_uiState.update {
it.copy(
loginInProgress = false,
isAuthenticated = true,
user = user,
error = null,
bootstrapWarn = null,
)
}
},
onFailure = { e ->
_uiState.update {
it.copy(
loginInProgress = false,
error = e.message ?: "Login fehlgeschlagen",
)
}
},
)
}
}
fun logout() {
viewModelScope.launch {
repository.logout()
_uiState.value = AuthUiState(bootstrapping = false)
}
}
fun retryBootstrap() {
viewModelScope.launch {
_uiState.update { it.copy(bootstrapping = true, bootstrapWarn = null) }
runBootstrap()
}
}
class Factory(
private val application: Application,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val tokenStore = TokenStore(application)
val api = TimeClockApiClient(tokenStore)
val repo = AuthRepository(api, tokenStore)
return AuthViewModel(repo) as T
}
}
}

View File

@@ -0,0 +1,257 @@
package de.tsschulz.timeclock.ui.auth
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.tsschulz.timeclock.ui.components.TcBrandTitle
import de.tsschulz.timeclock.ui.components.TcButton
import de.tsschulz.timeclock.ui.components.TcTextField
import de.tsschulz.timeclock.ui.model.ButtonVariant
import de.tsschulz.timeclock.ui.theme.TcColors
import de.tsschulz.timeclock.ui.theme.TcRadius
import de.tsschulz.timeclock.ui.theme.TcSpacing
/**
* Layout und Farben an die Web-Loginseite angelehnt (`Login.vue`: `.auth-page`, Navbar, `.auth-form-container`).
*/
@Composable
fun LoginScreen(
state: AuthUiState,
onLogin: (email: String, password: String, action: String) -> Unit,
onRetryBootstrap: () -> Unit,
modifier: Modifier = Modifier,
) {
var email by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
BoxWithConstraints(
modifier = modifier
.fillMaxSize()
.background(TcColors.Background),
) {
val useWideFormRows = maxWidth >= 560.dp
val formHorizontalPadding = if (maxWidth < 420.dp) 24.dp else 48.dp
val formVerticalPadding = if (maxWidth < 420.dp) 24.dp else 40.dp
Column(Modifier.fillMaxSize()) {
LoginTopBar()
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = TcSpacing.Xxl, vertical = TcSpacing.WebContainer),
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier
.widthIn(max = 900.dp)
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.shadow(4.dp, RoundedCornerShape(TcRadius.AuthPanel), clip = false)
.background(TcColors.FormSurface, RoundedCornerShape(TcRadius.AuthPanel))
.border(BorderStroke(1.dp, TcColors.Border), RoundedCornerShape(TcRadius.AuthPanel)),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(
TcColors.FormHeaderBg,
RoundedCornerShape(topStart = TcRadius.AuthPanel, topEnd = TcRadius.AuthPanel),
)
.padding(vertical = 12.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = "Einloggen",
color = TcColors.Text,
fontSize = 25.6.sp,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
)
}
HorizontalDivider(color = TcColors.Border, thickness = 1.dp)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = formHorizontalPadding, vertical = formVerticalPadding),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
if (state.bootstrapping) {
Text(
text = "Sitzung wird geprüft…",
color = TcColors.TextMuted,
fontSize = 14.sp,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
)
} else {
state.bootstrapWarn?.let { msg ->
AuthErrorBanner(message = msg)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
TcButton(
text = "Erneut versuchen",
variant = ButtonVariant.Primary,
onClick = onRetryBootstrap,
)
}
}
state.error?.let { AuthErrorBanner(message = it) }
AuthFormRow(
label = "E-Mail-Adresse",
horizontal = useWideFormRows,
) {
TcTextField(
label = "",
value = email,
onValueChange = { email = it },
placeholder = "Ihre E-Mail-Adresse eingeben",
showLabel = false,
)
}
AuthFormRow(
label = "Passwort",
horizontal = useWideFormRows,
) {
TcTextField(
label = "",
value = password,
onValueChange = { password = it },
placeholder = "Ihr Passwort eingeben",
isPassword = true,
showLabel = false,
)
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
TcButton(
text = if (state.loginInProgress) "Wird eingeloggt…" else "Einloggen",
variant = ButtonVariant.Primary,
onClick = { onLogin(email, password, "0") },
modifier = Modifier.defaultMinSize(minWidth = 140.dp),
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 10.dp),
)
}
}
}
}
}
}
}
}
}
@Composable
private fun LoginTopBar() {
Row(
modifier = Modifier
.fillMaxWidth()
.shadow(2.dp)
.background(TcColors.Navbar)
.border(BorderStroke(1.dp, TcColors.BorderSoft))
.padding(horizontal = TcSpacing.WebContainer, vertical = TcSpacing.Md),
verticalAlignment = Alignment.CenterVertically,
) {
TcBrandTitle()
}
}
@Composable
private fun AuthErrorBanner(message: String) {
Text(
text = message,
color = TcColors.AuthErrorText,
fontSize = 14.sp,
modifier = Modifier
.fillMaxWidth()
.background(TcColors.AuthErrorBg, RoundedCornerShape(TcRadius.Medium))
.border(BorderStroke(1.dp, TcColors.AuthErrorBorder), RoundedCornerShape(TcRadius.Medium))
.padding(12.dp),
)
}
@Composable
private fun AuthFormRow(
label: String,
horizontal: Boolean,
content: @Composable () -> Unit,
) {
if (horizontal) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = label,
color = Color333,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
modifier = Modifier.width(192.dp),
)
Box(modifier = Modifier.weight(1f)) {
content()
}
}
} else {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = label,
color = Color333,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
modifier = Modifier.padding(bottom = TcSpacing.Sm),
)
content()
}
}
}
private val Color333 = androidx.compose.ui.graphics.Color(0xFF333333)

View File

@@ -0,0 +1,300 @@
package de.tsschulz.timeclock.ui.booking
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.tsschulz.timeclock.data.api.CalendarDayDto
import de.tsschulz.timeclock.data.api.CalendarDto
import de.tsschulz.timeclock.data.api.SickEntryDto
import de.tsschulz.timeclock.data.api.TimefixDto
import de.tsschulz.timeclock.data.api.VacationDto
import de.tsschulz.timeclock.data.api.WorkdaysDto
import de.tsschulz.timeclock.data.api.WorklogEntryDto
import de.tsschulz.timeclock.ui.components.TcButton
import de.tsschulz.timeclock.ui.components.TcCard
import de.tsschulz.timeclock.ui.components.TcError
import de.tsschulz.timeclock.ui.components.TcLoading
import de.tsschulz.timeclock.ui.components.TcTextField
import de.tsschulz.timeclock.ui.model.ButtonVariant
import de.tsschulz.timeclock.ui.theme.TcColors
import de.tsschulz.timeclock.ui.theme.TcRadius
import de.tsschulz.timeclock.ui.theme.TcSpacing
import java.time.LocalDate
import java.time.YearMonth
import java.time.format.DateTimeFormatter
import java.util.Locale
@Composable
fun VacationScreen(state: BookingUiState, isTablet: Boolean, onCreate: (Int, String, String?) -> Unit, onDelete: (String) -> Unit) {
var type by rememberSaveable { mutableStateOf("0") }
var start by rememberSaveable { mutableStateOf(LocalDate.now().toString()) }
var end by rememberSaveable { mutableStateOf(LocalDate.now().toString()) }
Phase4Frame(state) {
FormCard("Urlaub eintragen", isTablet) {
TcTextField("Umfang (0 Zeitraum, 1 Halber Tag)", type, { type = it }, placeholder = "0")
TcTextField("Urlaubsbeginn", start, { start = it }, placeholder = "YYYY-MM-DD")
TcTextField("Urlaubsende", end, { end = it }, placeholder = "YYYY-MM-DD")
TcButton("Urlaub eintragen", variant = ButtonVariant.Primary, onClick = {
val typeValue = type.toIntOrNull() ?: 0
onCreate(typeValue, start, if (typeValue == 1) start else end)
})
}
ListCard("Urlaubseinträge", state.vacations) { item ->
VacationRow(item, onDelete)
}
}
}
@Composable
fun SickScreen(state: BookingUiState, isTablet: Boolean, onCreate: (String, String, String?) -> Unit, onDelete: (String) -> Unit) {
var start by rememberSaveable { mutableStateOf(LocalDate.now().toString()) }
var end by rememberSaveable { mutableStateOf(LocalDate.now().toString()) }
var typeId by rememberSaveable { mutableStateOf(state.sickTypes.firstOrNull()?.id?.toString().orEmpty()) }
Phase4Frame(state) {
FormCard("Erkrankung eintragen", isTablet) {
TcTextField("Erster Krankheitstag", start, { start = it }, placeholder = "YYYY-MM-DD")
TcTextField("Letzter Krankheitstag", end, { end = it }, placeholder = "YYYY-MM-DD")
TcTextField("Krankheitstyp-ID", typeId, { typeId = it }, placeholder = state.sickTypes.joinToString { "${it.id}=${it.name}" })
TcButton("Erkrankung eintragen", variant = ButtonVariant.Primary, onClick = {
if (typeId.isNotBlank()) onCreate(typeId, start, end.ifBlank { start })
})
}
ListCard("Krankheitseinträge", state.sickEntries) { item ->
SickRow(item, onDelete)
}
}
}
@Composable
fun TimefixScreen(
state: BookingUiState,
isTablet: Boolean,
onDate: (String) -> Unit,
onCreate: (String, String, String, String) -> Unit,
onDelete: (String) -> Unit,
) {
var worklogId by rememberSaveable { mutableStateOf("") }
var time by rememberSaveable { mutableStateOf("08:00") }
var action by rememberSaveable { mutableStateOf("start work") }
Phase4Frame(state) {
FormCard("Zeitkorrektur", isTablet) {
TcTextField("Datum", state.timefixDate, onDate, placeholder = "YYYY-MM-DD")
TcTextField("Worklog-ID", worklogId, { worklogId = it }, placeholder = state.worklogEntries.firstOrNull()?.id?.toString() ?: "")
TcTextField("Neue Uhrzeit", time, { time = it }, placeholder = "HH:MM")
TcTextField("Aktion", action, { action = it }, placeholder = "start work")
TcButton("Korrektur erstellen", variant = ButtonVariant.Primary, onClick = {
if (worklogId.isNotBlank()) onCreate(worklogId, state.timefixDate, time, action)
})
if (state.worklogEntries.isEmpty()) {
Text("Für dieses Datum sind keine Worklog-Einträge vorhanden.", color = TcColors.TextMuted, fontSize = 13.sp)
}
}
ListCard("Worklog-Einträge am Datum", state.worklogEntries) { item -> WorklogRow(item) }
ListCard("Zeitkorrekturen heute", state.timefixes) { item -> TimefixRow(item, onDelete) }
}
}
@Composable
fun WorkdaysScreen(state: BookingUiState, onYear: (Int) -> Unit) {
var year by rememberSaveable { mutableStateOf(state.workdaysYear.toString()) }
Phase4Frame(state) {
FormCard("Arbeitstage", isTablet = false) {
TcTextField("Jahr", year, { year = it }, placeholder = "2026")
TcButton("Jahr laden", variant = ButtonVariant.Primary, onClick = { year.toIntOrNull()?.let(onYear) })
}
WorkdaysCard(state.workdays)
}
}
@Composable
fun CalendarScreen(state: BookingUiState, onMonth: (Int) -> Unit) {
Phase4Frame(state) {
TcCard {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
TcButton("", variant = ButtonVariant.Secondary, onClick = { onMonth(-1) })
Text(
text = YearMonth.of(state.calendarYear, state.calendarMonth)
.format(DateTimeFormatter.ofPattern("MMMM yyyy", Locale.GERMANY)),
color = TcColors.Text,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
)
TcButton("", variant = ButtonVariant.Secondary, onClick = { onMonth(1) })
}
}
CalendarGrid(state.calendar)
}
}
@Composable
private fun Phase4Frame(state: BookingUiState, content: @Composable () -> Unit) {
if (state.loading) TcLoading("Lade Daten...")
state.error?.let { TcError(it) }
content()
}
@Composable
private fun FormCard(title: String, isTablet: Boolean, content: @Composable ColumnScope.() -> Unit) {
TcCard {
SectionTitle(title)
Column(verticalArrangement = Arrangement.spacedBy(TcSpacing.Md), content = content)
}
}
@Composable
private fun <T> ListCard(title: String, items: List<T>, row: @Composable (T) -> Unit) {
TcCard {
SectionTitle(title)
if (items.isEmpty()) {
Text("Keine Einträge vorhanden.", color = TcColors.TextMuted, fontSize = 14.sp)
} else {
Column(verticalArrangement = Arrangement.spacedBy(TcSpacing.Sm)) {
items.forEach { item -> row(item) }
}
}
}
}
@Composable
private fun VacationRow(item: VacationDto, onDelete: (String) -> Unit) = DataRow(
title = item.type ?: "Urlaub",
details = "${item.startDate.toDisplayDate()} - ${item.endDate.toDisplayDate()}",
onDelete = { onDelete(item.id) },
)
@Composable
private fun SickRow(item: SickEntryDto, onDelete: (String) -> Unit) = DataRow(
title = item.sickTypeName ?: "Krankheit",
details = "${item.startDate.toDisplayDate()} - ${item.endDate.toDisplayDate()}",
onDelete = { onDelete(item.id) },
)
@Composable
private fun TimefixRow(item: TimefixDto, onDelete: (String) -> Unit) = DataRow(
title = item.newAction ?: "Zeitkorrektur",
details = "${item.originalDate.toDisplayDate()} ${item.originalTime ?: "—"}${item.newDate.toDisplayDate()} ${item.newTime ?: "—"}",
onDelete = { onDelete(item.id) },
)
@Composable
private fun WorklogRow(item: WorklogEntryDto) = DataRow(
title = "#${item.id} ${item.action ?: "—"}",
details = "${item.time ?: "—"} (${item.tstamp ?: "—"})",
)
@Composable
private fun DataRow(title: String, details: String, onDelete: (() -> Unit)? = null) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(TcColors.Background, RoundedCornerShape(TcRadius.Medium))
.border(1.dp, TcColors.Border, RoundedCornerShape(TcRadius.Medium))
.padding(TcSpacing.Md),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TcSpacing.Md),
) {
Column(modifier = Modifier.weight(1f)) {
Text(title, color = TcColors.Text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
Text(details, color = TcColors.TextMuted, fontSize = 13.sp)
}
onDelete?.let { TcButton("Löschen", variant = ButtonVariant.Danger, onClick = it) }
}
}
@Composable
private fun WorkdaysCard(data: WorkdaysDto?) {
TcCard {
SectionTitle("Jahresstatistik")
if (data == null) {
Text("Keine Statistik geladen.", color = TcColors.TextMuted)
} else {
Detail("Jahr", data.year.toString())
Detail("Werktage", data.workdays.toString())
Detail("Feiertage", data.holidays.toString())
Detail("Urlaubstage", data.vacationDays.toString())
Detail("Krankheitstage", "${data.sickDays} (${data.sickPercentage}%)")
Detail("Gearbeitete Tage", data.workedDays.toString())
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun CalendarGrid(data: CalendarDto?) {
TcCard {
SectionTitle("Kalender")
if (data == null) {
Text("Kein Kalender geladen.", color = TcColors.TextMuted)
} else {
Column(verticalArrangement = Arrangement.spacedBy(TcSpacing.Sm)) {
data.weeks.forEach { week ->
FlowRow(horizontalArrangement = Arrangement.spacedBy(TcSpacing.Sm), verticalArrangement = Arrangement.spacedBy(TcSpacing.Sm)) {
week.forEach { CalendarCell(it) }
}
}
}
}
}
}
@Composable
private fun CalendarCell(day: CalendarDayDto) {
val bg = when {
day.isToday -> TcColors.ActiveMenu
!day.isCurrentMonth -> TcColors.Card
else -> TcColors.Background
}
Column(
modifier = Modifier
.width(92.dp)
.background(bg, RoundedCornerShape(TcRadius.Medium))
.border(1.dp, TcColors.Border, RoundedCornerShape(TcRadius.Medium))
.padding(TcSpacing.Sm),
) {
Text(day.day.toString(), color = TcColors.Text, fontWeight = FontWeight.SemiBold)
day.holiday?.let { Text(it, color = TcColors.Danger, fontSize = 11.sp) }
if (day.sick) Text("Krank", color = TcColors.Secondary, fontSize = 11.sp)
day.vacation?.let { Text(if (it == "half") "Urlaub 1/2" else "Urlaub", color = TcColors.Primary, fontSize = 11.sp) }
day.workedHours?.let { Text("${it}h", color = TcColors.TextMuted, fontSize = 11.sp) }
}
}
@Composable
private fun SectionTitle(text: String) {
Text(text, color = TcColors.Text, fontSize = 18.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(bottom = TcSpacing.Md))
}
@Composable
private fun Detail(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text("$label:", color = TcColors.TextMuted, fontSize = 14.sp)
Text(value, color = TcColors.Text, fontSize = 14.sp)
}
}
private fun String?.toDisplayDate(): String {
if (this.isNullOrBlank()) return ""
return runCatching {
LocalDate.parse(this.substring(0, 10)).format(DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale.GERMANY))
}.getOrDefault(this)
}

View File

@@ -0,0 +1,132 @@
package de.tsschulz.timeclock.ui.booking
import android.app.Application
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import de.tsschulz.timeclock.data.api.CalendarDto
import de.tsschulz.timeclock.data.api.SickEntryDto
import de.tsschulz.timeclock.data.api.SickTypeDto
import de.tsschulz.timeclock.data.api.TimeClockApiClient
import de.tsschulz.timeclock.data.api.TimefixDto
import de.tsschulz.timeclock.data.api.VacationDto
import de.tsschulz.timeclock.data.api.WorkdaysDto
import de.tsschulz.timeclock.data.api.WorklogEntryDto
import de.tsschulz.timeclock.data.auth.TokenStore
import de.tsschulz.timeclock.data.booking.BookingRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.time.LocalDate
import java.time.YearMonth
data class BookingUiState(
val loading: Boolean = false,
val error: String? = null,
val vacations: List<VacationDto> = emptyList(),
val sickEntries: List<SickEntryDto> = emptyList(),
val sickTypes: List<SickTypeDto> = emptyList(),
val timefixes: List<TimefixDto> = emptyList(),
val worklogEntries: List<WorklogEntryDto> = emptyList(),
val timefixDate: String = LocalDate.now().toString(),
val workdaysYear: Int = LocalDate.now().year,
val workdays: WorkdaysDto? = null,
val calendarYear: Int = LocalDate.now().year,
val calendarMonth: Int = LocalDate.now().monthValue,
val calendar: CalendarDto? = null,
)
class BookingViewModel(
private val repository: BookingRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(BookingUiState())
val uiState: StateFlow<BookingUiState> = _uiState.asStateFlow()
fun loadPhase4() {
loadVacations()
loadSick()
loadTimefix()
loadWorkdays(_uiState.value.workdaysYear)
loadCalendar(_uiState.value.calendarYear, _uiState.value.calendarMonth)
}
fun loadVacations() = launchLoad { copy(vacations = repository.getVacations()) }
fun createVacation(type: Int, start: String, end: String?) = launchMutation {
repository.createVacation(type, start, end)
loadVacations()
}
fun deleteVacation(id: String) = launchMutation {
repository.deleteVacation(id)
loadVacations()
}
fun loadSick() = launchLoad {
copy(sickEntries = repository.getSickEntries(), sickTypes = repository.getSickTypes())
}
fun createSick(typeId: String, start: String, end: String?) = launchMutation {
repository.createSick(typeId, start, end)
loadSick()
}
fun deleteSick(id: String) = launchMutation {
repository.deleteSick(id)
loadSick()
}
fun setTimefixDate(date: String) {
_uiState.update { it.copy(timefixDate = date) }
loadTimefix(date)
}
fun loadTimefix(date: String = _uiState.value.timefixDate) = launchLoad {
copy(timefixes = repository.getTimefixes(), worklogEntries = repository.getWorklogEntries(date))
}
fun createTimefix(worklogId: String, date: String, time: String, action: String) = launchMutation {
repository.createTimefix(worklogId, date, time, action)
loadTimefix(date)
}
fun deleteTimefix(id: String) = launchMutation {
repository.deleteTimefix(id)
loadTimefix()
}
fun loadWorkdays(year: Int) = launchLoad {
copy(workdaysYear = year, workdays = repository.getWorkdays(year))
}
fun changeCalendarMonth(delta: Int) {
val current = YearMonth.of(_uiState.value.calendarYear, _uiState.value.calendarMonth).plusMonths(delta.toLong())
loadCalendar(current.year, current.monthValue)
}
fun loadCalendar(year: Int, month: Int) = launchLoad {
copy(calendarYear = year, calendarMonth = month, calendar = repository.getCalendar(year, month))
}
private fun launchLoad(reducer: suspend BookingUiState.() -> BookingUiState) {
viewModelScope.launch {
_uiState.update { it.copy(loading = true, error = null) }
runCatching { _uiState.value.reducer() }
.onSuccess { next -> _uiState.value = next.copy(loading = false, error = null) }
.onFailure { e -> _uiState.update { it.copy(loading = false, error = e.message ?: "Daten konnten nicht geladen werden") } }
}
}
private fun launchMutation(block: suspend () -> Unit) {
viewModelScope.launch {
_uiState.update { it.copy(loading = true, error = null) }
runCatching { block() }
.onFailure { e -> _uiState.update { it.copy(error = e.message ?: "Aktion fehlgeschlagen") } }
_uiState.update { it.copy(loading = false) }
}
}
class Factory(private val application: Application) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val tokenStore = TokenStore(application)
return BookingViewModel(BookingRepository(TimeClockApiClient(tokenStore))) as T
}
}
}

View File

@@ -0,0 +1,40 @@
package de.tsschulz.timeclock.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.tsschulz.timeclock.R
import de.tsschulz.timeclock.ui.theme.TcColors
/** App-Logo + „Stechuhr“ wie in der Web-Navbar. */
@Composable
fun TcBrandTitle(modifier: Modifier = Modifier) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Image(
painter = painterResource(R.drawable.ic_stechuhr_logo),
contentDescription = null,
modifier = Modifier.size(32.dp),
contentScale = ContentScale.Fit,
)
Text(
text = "Stechuhr",
color = TcColors.Text,
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
)
}
}

View File

@@ -0,0 +1,64 @@
package de.tsschulz.timeclock.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.tsschulz.timeclock.ui.model.ButtonVariant
import de.tsschulz.timeclock.ui.theme.TcColors
import de.tsschulz.timeclock.ui.theme.TcRadius
@Composable
fun TcButton(
text: String,
modifier: Modifier = Modifier,
variant: ButtonVariant = ButtonVariant.Default,
contentPadding: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
onClick: () -> Unit = {},
) {
val style = buttonStyle(variant)
Box(
modifier = modifier
.defaultMinSize(minHeight = 36.dp)
.background(style.background, RoundedCornerShape(TcRadius.Medium))
.border(BorderStroke(1.dp, style.border), RoundedCornerShape(TcRadius.Medium))
.clickable(onClick = onClick)
.padding(contentPadding),
contentAlignment = Alignment.Center,
) {
Text(
text = text,
color = style.text,
fontSize = 14.sp,
fontWeight = FontWeight.Normal,
)
}
}
private data class TcButtonStyle(
val background: Color,
val border: Color,
val text: Color,
)
private fun buttonStyle(variant: ButtonVariant): TcButtonStyle =
when (variant) {
ButtonVariant.Default -> TcButtonStyle(TcColors.Button, TcColors.ButtonBorder, Color(0xFF333333))
ButtonVariant.Primary -> TcButtonStyle(TcColors.Primary, TcColors.PrimaryBorder, Color.White)
ButtonVariant.Success -> TcButtonStyle(TcColors.Success, TcColors.SuccessBorder, Color.White)
ButtonVariant.Danger -> TcButtonStyle(TcColors.Danger, TcColors.DangerBorder, Color.White)
ButtonVariant.Secondary -> TcButtonStyle(TcColors.Secondary, TcColors.Secondary, Color.White)
}

View File

@@ -0,0 +1,35 @@
package de.tsschulz.timeclock.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.unit.dp
import de.tsschulz.timeclock.ui.theme.TcColors
import de.tsschulz.timeclock.ui.theme.TcRadius
import de.tsschulz.timeclock.ui.theme.TcSpacing
@Composable
fun TcCard(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(TcSpacing.Xl),
content: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier = modifier
.fillMaxWidth()
.shadow(2.dp, RoundedCornerShape(TcRadius.Card), clip = false)
.background(TcColors.Card, RoundedCornerShape(TcRadius.Card))
.border(BorderStroke(1.dp, TcColors.Border), RoundedCornerShape(TcRadius.Card))
.padding(contentPadding),
content = content,
)
}

View File

@@ -0,0 +1,285 @@
package de.tsschulz.timeclock.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.EventNote
import androidx.compose.material.icons.filled.AdminPanelSettings
import androidx.compose.material.icons.filled.Assessment
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.tsschulz.timeclock.ui.model.AppRoute
import de.tsschulz.timeclock.ui.model.MenuSection
import de.tsschulz.timeclock.ui.theme.TcColors
import de.tsschulz.timeclock.ui.theme.TcRadius
import de.tsschulz.timeclock.ui.theme.TcSpacing
@Composable
fun TcSectionMenu(
sections: List<MenuSection>,
selectedRoute: AppRoute,
onRouteSelected: (AppRoute) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxHeight()
.width(230.dp)
.background(TcColors.Background)
.border(BorderStroke(1.dp, TcColors.Border))
.verticalScroll(rememberScrollState())
.padding(vertical = TcSpacing.Md),
) {
sections.forEach { section ->
Text(
text = section.title,
color = TcColors.TextMuted,
fontSize = 14.sp,
fontWeight = FontWeight.Normal,
modifier = Modifier.padding(horizontal = TcSpacing.Lg, vertical = TcSpacing.Sm),
)
section.items.forEach { item ->
val selected = item.route == selectedRoute
Text(
text = item.label,
color = if (selected) TcColors.Text else Color333,
fontSize = 14.sp,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
modifier = Modifier
.fillMaxWidth()
.background(if (selected) TcColors.ActiveMenu else TcColors.Background)
.clickable { onRouteSelected(item.route) }
.padding(horizontal = TcSpacing.Xl, vertical = TcSpacing.Sm),
)
}
}
}
}
@Composable
fun TcBottomNavigation(
sections: List<MenuSection>,
selectedRoute: AppRoute,
onRouteSelected: (AppRoute) -> Unit,
modifier: Modifier = Modifier,
) {
val items = sections.mapNotNull { section ->
val firstRoute = section.items.firstOrNull()?.route ?: return@mapNotNull null
BottomNavItem(section.title, firstRoute, section.icon())
}
var openSectionTitle by rememberSaveable { mutableStateOf<String?>(null) }
Column(
modifier = modifier
.fillMaxWidth()
.background(TcColors.Navbar)
.border(BorderStroke(1.dp, TcColors.BorderSoft))
.padding(horizontal = TcSpacing.Sm, vertical = TcSpacing.Xs),
) {
val openSection = sections.firstOrNull { it.title == openSectionTitle }
openSection?.let { section ->
Column(
modifier = Modifier
.fillMaxWidth()
.background(TcColors.Card, RoundedCornerShape(TcRadius.Medium))
.border(BorderStroke(1.dp, TcColors.Border), RoundedCornerShape(TcRadius.Medium))
.padding(vertical = TcSpacing.Sm),
) {
section.items.forEach { menuItem ->
val selected = menuItem.route == selectedRoute
Text(
text = menuItem.label,
color = if (selected) TcColors.Text else Color333,
fontSize = 15.sp,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
modifier = Modifier
.fillMaxWidth()
.background(if (selected) TcColors.ActiveMenu else TcColors.Card)
.clickable {
onRouteSelected(menuItem.route)
openSectionTitle = null
}
.padding(horizontal = TcSpacing.Xl, vertical = TcSpacing.Md),
)
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceAround,
verticalAlignment = Alignment.CenterVertically,
) {
items.forEach { item ->
val selected = item.route == selectedRoute || isRouteInSection(item.route, selectedRoute)
val expanded = openSectionTitle == item.label
Column(
modifier = Modifier
.heightIn(min = 56.dp)
.weight(1f)
.background(if (selected || expanded) TcColors.ActiveMenu else TcColors.Navbar, RoundedCornerShape(TcRadius.Small))
.clickable {
openSectionTitle = if (expanded) null else item.label
}
.padding(vertical = TcSpacing.Sm),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(imageVector = item.icon, contentDescription = item.label, tint = TcColors.TextMuted)
Text(text = item.label, color = TcColors.TextMuted, fontSize = 11.sp)
}
}
}
}
}
@Composable
fun TcMobileSubMenu(
sections: List<MenuSection>,
selectedRoute: AppRoute,
onRouteSelected: (AppRoute) -> Unit,
modifier: Modifier = Modifier,
) {
val section = sections.firstOrNull { s -> s.items.any { it.route == selectedRoute } } ?: return
Column(
modifier = modifier
.fillMaxWidth()
.background(TcColors.Card)
.border(BorderStroke(1.dp, TcColors.Border), RoundedCornerShape(TcRadius.Medium))
.padding(TcSpacing.Md),
verticalArrangement = Arrangement.spacedBy(TcSpacing.Sm),
) {
Text(section.title, color = TcColors.TextMuted, fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
section.items.forEach { item ->
val selected = item.route == selectedRoute
Text(
text = item.label,
color = if (selected) TcColors.Text else Color333,
fontSize = 14.sp,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
modifier = Modifier
.fillMaxWidth()
.background(if (selected) TcColors.ActiveMenu else TcColors.Card, RoundedCornerShape(TcRadius.Small))
.clickable { onRouteSelected(item.route) }
.padding(horizontal = TcSpacing.Md, vertical = TcSpacing.Sm),
)
}
}
}
@Composable
fun TcMobileMainMenu(
sections: List<MenuSection>,
selectedRoute: AppRoute,
onRouteSelected: (AppRoute) -> Unit,
modifier: Modifier = Modifier,
) {
val selectedSectionTitle = sections.firstOrNull { section ->
section.items.any { it.route == selectedRoute }
}?.title
var openSectionTitle by rememberSaveable { mutableStateOf(selectedSectionTitle) }
LaunchedEffect(selectedSectionTitle) {
openSectionTitle = selectedSectionTitle
}
Column(
modifier = modifier
.fillMaxWidth()
.background(TcColors.Card)
.border(BorderStroke(1.dp, TcColors.Border), RoundedCornerShape(TcRadius.Medium))
.padding(vertical = TcSpacing.Sm),
) {
sections.forEach { section ->
val open = openSectionTitle == section.title
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
openSectionTitle = if (open) null else section.title
}
.padding(horizontal = TcSpacing.Lg, vertical = TcSpacing.Md),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TcSpacing.Sm),
) {
Text(
text = section.title,
color = TcColors.Text,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
Text(if (open) "" else "", color = TcColors.TextMuted, fontSize = 14.sp)
}
if (open) {
section.items.forEach { item ->
val selected = item.route == selectedRoute
Text(
text = item.label,
color = if (selected) TcColors.Text else Color333,
fontSize = 14.sp,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
modifier = Modifier
.fillMaxWidth()
.background(if (selected) TcColors.ActiveMenu else TcColors.Card)
.clickable { onRouteSelected(item.route) }
.padding(horizontal = TcSpacing.Xl, vertical = TcSpacing.Sm),
)
}
}
}
}
}
private data class BottomNavItem(
val label: String,
val route: AppRoute,
val icon: ImageVector,
)
private fun isRouteInSection(sectionRoute: AppRoute, selectedRoute: AppRoute): Boolean =
when (sectionRoute) {
AppRoute.Week -> selectedRoute in setOf(AppRoute.Timefix, AppRoute.Vacation, AppRoute.Sick, AppRoute.Workdays, AppRoute.Calendar)
AppRoute.Profile -> selectedRoute in setOf(AppRoute.Password, AppRoute.Timewish, AppRoute.Permissions, AppRoute.Invite)
AppRoute.Entries -> selectedRoute == AppRoute.Stats
AppRoute.Holidays -> selectedRoute == AppRoute.Roles
else -> false
}
private fun MenuSection.icon(): ImageVector =
when (title) {
"Buchungen" -> Icons.AutoMirrored.Filled.EventNote
"Einstellungen" -> Icons.Filled.Settings
"Auswertung" -> Icons.Filled.Assessment
"Verwaltung" -> Icons.Filled.AdminPanelSettings
else -> Icons.Filled.CalendarMonth
}
private val Color333 = androidx.compose.ui.graphics.Color(0xFF333333)

View File

@@ -0,0 +1,185 @@
package de.tsschulz.timeclock.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.tsschulz.timeclock.ui.model.AppRoute
import de.tsschulz.timeclock.ui.model.MenuSection
import de.tsschulz.timeclock.ui.model.StatusAction
import de.tsschulz.timeclock.ui.model.StatusRow
import de.tsschulz.timeclock.ui.theme.TcColors
import de.tsschulz.timeclock.ui.theme.TcRadius
import de.tsschulz.timeclock.ui.theme.TcSpacing
@Composable
fun TcScaffold(
title: String,
userName: String,
sections: List<MenuSection>,
selectedRoute: AppRoute,
isTablet: Boolean,
statusRows: List<StatusRow>,
primaryStatusAction: StatusAction?,
secondaryStatusAction: StatusAction?,
onRouteSelected: (AppRoute) -> Unit,
onLogout: () -> Unit,
onStatusAction: (StatusAction) -> Unit = {},
content: @Composable ColumnScope.() -> Unit,
) {
if (isTablet) {
Row(modifier = Modifier.fillMaxSize().background(TcColors.Background)) {
TcSectionMenu(
sections = sections,
selectedRoute = selectedRoute,
onRouteSelected = onRouteSelected,
)
Column(modifier = Modifier.fillMaxSize()) {
TcTopBar(title = title, userName = userName, compact = false, onLogout = onLogout)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TcSpacing.WebContainer, vertical = TcSpacing.Lg),
horizontalArrangement = Arrangement.End,
) {
TcStatusBox(
rows = statusRows,
primaryAction = primaryStatusAction,
secondaryAction = secondaryStatusAction,
modifier = Modifier.fillMaxWidth(0.48f),
onAction = onStatusAction,
)
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = TcSpacing.WebContainer)
.padding(bottom = TcSpacing.Xxl),
verticalArrangement = Arrangement.spacedBy(TcSpacing.Xl),
content = content,
)
}
}
} else {
Column(modifier = Modifier.fillMaxSize().background(TcColors.Background)) {
TcTopBar(title = title, userName = userName, compact = true, onLogout = onLogout)
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(horizontal = TcSpacing.Lg, vertical = TcSpacing.Lg),
verticalArrangement = Arrangement.spacedBy(TcSpacing.Lg),
) {
TcStatusBox(
rows = statusRows,
primaryAction = primaryStatusAction,
secondaryAction = secondaryStatusAction,
modifier = Modifier.fillMaxWidth(),
onAction = onStatusAction,
)
content()
}
TcBottomNavigation(
sections = sections,
selectedRoute = selectedRoute,
onRouteSelected = onRouteSelected,
)
}
}
}
@Composable
fun TcTopBar(
title: String,
userName: String,
modifier: Modifier = Modifier,
compact: Boolean = false,
onLogout: () -> Unit = {},
) {
val barModifier = modifier
.fillMaxWidth()
.shadow(2.dp)
.background(TcColors.Navbar)
.border(BorderStroke(1.dp, TcColors.BorderSoft))
.statusBarsPadding()
.padding(horizontal = TcSpacing.Xl, vertical = TcSpacing.Md)
if (compact) {
Column(
modifier = barModifier,
verticalArrangement = Arrangement.spacedBy(TcSpacing.Sm),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(TcSpacing.Lg)) {
TcBrandTitle()
Box(modifier = Modifier.weight(1f))
TcButton(text = "Abmelden", onClick = onLogout)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(TcSpacing.Md)) {
PageTitle(title = title)
Text(
text = userName,
color = TcColors.TextMuted,
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
}
return
}
Row(
modifier = barModifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TcSpacing.Lg),
) {
TcBrandTitle()
PageTitle(title = title)
Box(modifier = Modifier.weight(1f))
Text(text = userName, color = TcColors.TextMuted, fontSize = 14.sp)
TcButton(text = "Abmelden", onClick = onLogout)
}
}
@Composable
private fun PageTitle(title: String) {
Box(
modifier = Modifier
.background(TcColors.Background, RoundedCornerShape(TcRadius.Small))
.border(BorderStroke(1.dp, TcColors.Border), RoundedCornerShape(TcRadius.Small))
.padding(horizontal = 15.dp, vertical = 4.dp),
) {
Text(
text = title,
color = Color2c3e50,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
private val Color2c3e50 = androidx.compose.ui.graphics.Color(0xFF2C3E50)

View File

@@ -0,0 +1,41 @@
package de.tsschulz.timeclock.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import de.tsschulz.timeclock.ui.theme.TcColors
import de.tsschulz.timeclock.ui.theme.TcSpacing
@Composable
fun TcLoading(text: String = "Lädt...") {
TcCard {
Text(text = text, color = TcColors.TextMuted, fontSize = 14.sp)
}
}
@Composable
fun TcError(message: String) {
TcCard {
Text(text = "Fehler", color = TcColors.Danger, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
Text(text = message, color = TcColors.TextMuted, fontSize = 14.sp, modifier = Modifier.padding(top = TcSpacing.Sm))
}
}
@Composable
fun TcEmptyState(title: String, message: String) {
Column(
modifier = Modifier.fillMaxWidth().padding(TcSpacing.Xl),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(TcSpacing.Sm),
) {
Text(text = title, color = TcColors.Text, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
Text(text = message, color = TcColors.TextMuted, fontSize = 14.sp)
}
}

View File

@@ -0,0 +1,73 @@
package de.tsschulz.timeclock.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.tsschulz.timeclock.ui.model.StatusAction
import de.tsschulz.timeclock.ui.model.StatusRow
import de.tsschulz.timeclock.ui.theme.TcColors
import de.tsschulz.timeclock.ui.theme.TcRadius
import de.tsschulz.timeclock.ui.theme.TcSpacing
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun TcStatusBox(
rows: List<StatusRow>,
primaryAction: StatusAction?,
secondaryAction: StatusAction?,
modifier: Modifier = Modifier,
onAction: (StatusAction) -> Unit = {},
) {
Column(
modifier = modifier
.background(TcColors.Card, RoundedCornerShape(TcRadius.Card))
.border(BorderStroke(1.dp, TcColors.Border), RoundedCornerShape(TcRadius.Card))
.padding(TcSpacing.Md),
verticalArrangement = Arrangement.spacedBy(TcSpacing.Md),
) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(TcSpacing.Sm),
verticalArrangement = Arrangement.spacedBy(TcSpacing.Sm),
) {
primaryAction?.let { action ->
TcButton(text = action.label, variant = action.variant, onClick = { onAction(action) })
}
secondaryAction?.let { action ->
TcButton(text = action.label, variant = action.variant, onClick = { onAction(action) })
}
}
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
rows.forEach { row ->
if (row.isHeading) {
Text(
text = row.label,
color = TcColors.Text,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = TcSpacing.Xs),
)
} else {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(text = "${row.label}:", color = TcColors.TextMuted, fontSize = 13.sp)
Text(text = row.value ?: "-", color = TcColors.Text, fontSize = 13.sp)
}
}
}
}
}
}

View File

@@ -0,0 +1,77 @@
package de.tsschulz.timeclock.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.tsschulz.timeclock.ui.theme.TcColors
import de.tsschulz.timeclock.ui.theme.TcRadius
import de.tsschulz.timeclock.ui.theme.TcSpacing
@Composable
fun TcTextField(
label: String,
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
placeholder: String = "",
isPassword: Boolean = false,
showLabel: Boolean = true,
) {
Column(modifier = modifier.fillMaxWidth()) {
if (showLabel) {
Text(
text = label,
color = Color333,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
modifier = Modifier.padding(bottom = TcSpacing.Sm),
)
}
BasicTextField(
value = value,
onValueChange = onValueChange,
textStyle = TextStyle(color = TcColors.Text, fontSize = 14.sp),
cursorBrush = SolidColor(TcColors.Primary),
singleLine = true,
visualTransformation = if (isPassword) PasswordVisualTransformation() else VisualTransformation.None,
keyboardOptions = KeyboardOptions(
keyboardType = if (isPassword) KeyboardType.Password else KeyboardType.Email,
),
modifier = Modifier.fillMaxWidth(),
decorationBox = { innerTextField ->
Box(
modifier = Modifier
.fillMaxWidth()
.background(TcColors.Background, RoundedCornerShape(TcRadius.Medium))
.border(BorderStroke(1.dp, TcColors.InputBorder), RoundedCornerShape(TcRadius.Medium))
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
if (value.isEmpty() && placeholder.isNotEmpty()) {
Text(text = placeholder, color = TcColors.TextMuted, fontSize = 14.sp)
}
innerTextField()
}
},
)
}
}
private val Color333 = androidx.compose.ui.graphics.Color(0xFF333333)

View File

@@ -0,0 +1,52 @@
package de.tsschulz.timeclock.ui.model
val userSections = listOf(
MenuSection(
title = "Buchungen",
items = listOf(
MenuItem("Wochenübersicht", AppRoute.Week),
MenuItem("Zeitkorrekturen", AppRoute.Timefix),
MenuItem("Urlaub", AppRoute.Vacation),
MenuItem("Krankheit", AppRoute.Sick),
MenuItem("Arbeitstage", AppRoute.Workdays),
MenuItem("Kalender", AppRoute.Calendar),
),
),
MenuSection(
title = "Einstellungen",
items = listOf(
MenuItem("Persönliches", AppRoute.Profile),
MenuItem("Passwort ändern", AppRoute.Password),
MenuItem("Zeitwünsche", AppRoute.Timewish),
MenuItem("Zugriffe verwalten", AppRoute.Permissions),
MenuItem("Einladen", AppRoute.Invite),
),
),
MenuSection(
title = "Auswertung",
items = listOf(
MenuItem("Einträge", AppRoute.Entries),
MenuItem("Statistiken", AppRoute.Stats),
),
),
)
val adminSections = userSections + MenuSection(
title = "Verwaltung",
items = listOf(
MenuItem("Feiertage", AppRoute.Holidays),
MenuItem("Rechte", AppRoute.Roles),
),
)
val mockStatusRows = listOf(
StatusRow(label = "Heute", isHeading = true),
StatusRow(label = "Status", value = "Arbeit läuft"),
StatusRow(label = "Beginn", value = "08:12"),
StatusRow(label = "Arbeitszeit", value = "04:37:18"),
StatusRow(label = "Offen", value = "03:23"),
StatusRow(label = "Reguläres Ende", value = "16:42"),
)
val mockPrimaryAction = StatusAction("Pause starten", ButtonVariant.Success)
val mockSecondaryAction = StatusAction("Gehen", ButtonVariant.Secondary)

View File

@@ -0,0 +1,50 @@
package de.tsschulz.timeclock.ui.model
data class MenuItem(
val label: String,
val route: AppRoute,
)
data class MenuSection(
val title: String,
val items: List<MenuItem>,
)
enum class AppRoute(val title: String) {
Week("Wochenübersicht"),
Timefix("Zeitkorrekturen"),
Vacation("Urlaub"),
Sick("Krankheit"),
Workdays("Arbeitstage"),
Calendar("Kalender"),
Entries("Einträge"),
Stats("Statistiken"),
Export("Export"),
Profile("Persönliches"),
Password("Passwort ändern"),
Timewish("Zeitwünsche"),
Permissions("Zugriffe verwalten"),
Invite("Einladen"),
Holidays("Feiertage"),
Roles("Rechte"),
}
data class StatusRow(
val label: String,
val value: String? = null,
val isHeading: Boolean = false,
)
data class StatusAction(
val label: String,
val variant: ButtonVariant,
val clockAction: String? = null,
)
enum class ButtonVariant {
Default,
Primary,
Success,
Danger,
Secondary,
}

View File

@@ -0,0 +1,304 @@
package de.tsschulz.timeclock.ui.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.tsschulz.timeclock.data.api.InvitationDto
import de.tsschulz.timeclock.data.api.ProfileDto
import de.tsschulz.timeclock.data.api.TimewishDto
import de.tsschulz.timeclock.data.api.WatcherDto
import de.tsschulz.timeclock.ui.components.TcButton
import de.tsschulz.timeclock.ui.components.TcCard
import de.tsschulz.timeclock.ui.components.TcError
import de.tsschulz.timeclock.ui.components.TcLoading
import de.tsschulz.timeclock.ui.components.TcTextField
import de.tsschulz.timeclock.ui.model.ButtonVariant
import de.tsschulz.timeclock.ui.theme.TcColors
import de.tsschulz.timeclock.ui.theme.TcRadius
import de.tsschulz.timeclock.ui.theme.TcSpacing
import java.time.LocalDate
@Composable
fun ProfileScreen(
state: SettingsUiState,
isTablet: Boolean,
onSave: (String, String?, Int, Double, Int) -> Unit,
) {
val profile = state.profile
var fullName by rememberSaveable { mutableStateOf("") }
var stateId by rememberSaveable { mutableStateOf("") }
var weekWorkdays by rememberSaveable { mutableStateOf("5") }
var dailyHours by rememberSaveable { mutableStateOf("8.0") }
var preferredTitleType by rememberSaveable { mutableStateOf("0") }
LaunchedEffect(profile) {
profile?.let {
fullName = it.fullName
stateId = it.stateId.orEmpty()
weekWorkdays = (it.weekWorkdays ?: 5).toString()
dailyHours = (it.dailyHours ?: 8.0).toString()
preferredTitleType = (it.preferredTitleType ?: 0).toString()
}
}
SettingsFrame(state) {
ResponsiveSettings(isTablet) {
FormCard("Persönliche Daten") {
TcTextField("Name", fullName, { fullName = it })
TcTextField("Bundesland-ID", stateId, { stateId = it }, placeholder = state.states.joinToString { "${it.id}=${it.name}" })
TcTextField("Arbeitstage pro Woche", weekWorkdays, { weekWorkdays = it }, placeholder = "5")
TcTextField("Stunden pro Tag", dailyHours, { dailyHours = it }, placeholder = "8.0")
TcTextField("Titeltyp", preferredTitleType, { preferredTitleType = it }, placeholder = "0")
TcButton("Speichern", variant = ButtonVariant.Primary, onClick = {
onSave(
fullName,
stateId.ifBlank { null },
weekWorkdays.toIntOrNull() ?: 5,
dailyHours.toDoubleOrNull() ?: 8.0,
preferredTitleType.toIntOrNull() ?: 0,
)
})
}
ProfileDetails(profile)
}
}
}
@Composable
fun PasswordScreen(
state: SettingsUiState,
onChange: (String, String, String) -> Unit,
) {
var oldPassword by rememberSaveable { mutableStateOf("") }
var newPassword by rememberSaveable { mutableStateOf("") }
var confirmPassword by rememberSaveable { mutableStateOf("") }
SettingsFrame(state) {
FormCard("Passwort ändern") {
TcTextField("Aktuelles Passwort", oldPassword, { oldPassword = it }, isPassword = true)
TcTextField("Neues Passwort", newPassword, { newPassword = it }, isPassword = true)
TcTextField("Neues Passwort wiederholen", confirmPassword, { confirmPassword = it }, isPassword = true)
TcButton("Passwort ändern", variant = ButtonVariant.Primary, onClick = {
onChange(oldPassword, newPassword, confirmPassword)
oldPassword = ""
newPassword = ""
confirmPassword = ""
})
}
}
}
@Composable
fun TimewishScreen(
state: SettingsUiState,
isTablet: Boolean,
onCreate: (Int, Int, Double?, String, String?) -> Unit,
onDelete: (String) -> Unit,
) {
var day by rememberSaveable { mutableStateOf("1") }
var wishtype by rememberSaveable { mutableStateOf("1") }
var hours by rememberSaveable { mutableStateOf("8.0") }
var startDate by rememberSaveable { mutableStateOf(LocalDate.now().toString()) }
var endDate by rememberSaveable { mutableStateOf("") }
SettingsFrame(state) {
ResponsiveSettings(isTablet) {
FormCard("Zeitwunsch eintragen") {
TcTextField("Wochentag", day, { day = it }, placeholder = "1")
TcTextField("Wunschtyp", wishtype, { wishtype = it }, placeholder = "1")
TcTextField("Stunden", hours, { hours = it }, placeholder = "8.0")
TcTextField("Gueltig ab", startDate, { startDate = it }, placeholder = "YYYY-MM-DD")
TcTextField("Gueltig bis", endDate, { endDate = it }, placeholder = "YYYY-MM-DD")
TcButton("Zeitwunsch speichern", variant = ButtonVariant.Primary, onClick = {
onCreate(
day.toIntOrNull() ?: 1,
wishtype.toIntOrNull() ?: 1,
hours.toDoubleOrNull(),
startDate,
endDate.ifBlank { null },
)
})
}
ListCard("Zeitwünsche", state.timewishes) { item -> TimewishRow(item, onDelete) }
}
}
}
@Composable
fun InviteScreen(
state: SettingsUiState,
isTablet: Boolean,
onSend: (String) -> Unit,
) {
var email by rememberSaveable { mutableStateOf("") }
SettingsFrame(state) {
ResponsiveSettings(isTablet) {
FormCard("Einladen") {
TcTextField("E-Mail", email, { email = it }, placeholder = "name@example.com")
TcButton("Einladung senden", variant = ButtonVariant.Primary, onClick = {
if (email.isNotBlank()) {
onSend(email)
email = ""
}
})
}
ListCard("Einladungen", state.invites) { item -> InviteRow(item) }
}
}
}
@Composable
fun PermissionsScreen(
state: SettingsUiState,
isTablet: Boolean,
onAdd: (String) -> Unit,
onDelete: (String) -> Unit,
) {
var email by rememberSaveable { mutableStateOf("") }
SettingsFrame(state) {
ResponsiveSettings(isTablet) {
FormCard("Zugriff hinzufügen") {
TcTextField("E-Mail", email, { email = it }, placeholder = "name@example.com")
TcButton("Zugriff speichern", variant = ButtonVariant.Primary, onClick = {
if (email.isNotBlank()) {
onAdd(email)
email = ""
}
})
}
ListCard("Aktuelle Zugriffe", state.watchers) { item -> WatcherRow(item, onDelete) }
}
}
}
@Composable
private fun SettingsFrame(state: SettingsUiState, content: @Composable () -> Unit) {
if (state.loading) TcLoading("Lade Daten...")
state.error?.let { TcError(it) }
state.success?.let {
TcCard { Text(it, color = TcColors.Success, fontSize = 14.sp, fontWeight = FontWeight.Medium) }
}
content()
}
@Composable
private fun ResponsiveSettings(isTablet: Boolean, content: @Composable ColumnScope.() -> Unit) {
if (isTablet) {
Row(horizontalArrangement = Arrangement.spacedBy(TcSpacing.Xl), modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(TcSpacing.Lg), content = content)
}
} else {
Column(verticalArrangement = Arrangement.spacedBy(TcSpacing.Lg), content = content)
}
}
@Composable
private fun FormCard(title: String, content: @Composable ColumnScope.() -> Unit) {
TcCard {
SectionTitle(title)
Column(verticalArrangement = Arrangement.spacedBy(TcSpacing.Md), content = content)
}
}
@Composable
private fun <T> ListCard(title: String, items: List<T>, row: @Composable (T) -> Unit) {
TcCard {
SectionTitle(title)
if (items.isEmpty()) {
Text("Keine Einträge vorhanden.", color = TcColors.TextMuted, fontSize = 14.sp)
} else {
Column(verticalArrangement = Arrangement.spacedBy(TcSpacing.Sm)) {
items.forEach { item -> row(item) }
}
}
}
}
@Composable
private fun ProfileDetails(profile: ProfileDto?) {
TcCard {
SectionTitle("Konto")
Detail("E-Mail", profile?.email ?: "-")
Detail("Bundesland", profile?.stateName ?: "-")
Detail("Wochenarbeitstage", profile?.weekWorkdays?.toString() ?: "-")
Detail("Tagesstunden", profile?.dailyHours?.toString() ?: "-")
}
}
@Composable
private fun TimewishRow(item: TimewishDto, onDelete: (String) -> Unit) = DataRow(
title = item.dayName ?: "Tag ${item.day}",
details = "${item.wishtypeName ?: "Typ ${item.wishtype}"} - ${item.hours ?: 0.0} h - ${item.startDate ?: "-"} bis ${item.endDate ?: "-"}",
onDelete = { onDelete(item.id) },
)
@Composable
private fun InviteRow(item: InvitationDto) = DataRow(
title = item.email,
details = "${item.status ?: "offen"} - gültig bis ${item.expiresAt ?: "-"}",
)
@Composable
private fun WatcherRow(item: WatcherDto, onDelete: (String) -> Unit) = DataRow(
title = item.email,
details = "Seit ${item.createdAt ?: "-"}",
onDelete = { onDelete(item.id) },
)
@Composable
private fun DataRow(title: String, details: String, onDelete: (() -> Unit)? = null) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(TcColors.Background, RoundedCornerShape(TcRadius.Medium))
.border(1.dp, TcColors.Border, RoundedCornerShape(TcRadius.Medium))
.padding(TcSpacing.Md),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TcSpacing.Md),
) {
Column(modifier = Modifier.weight(1f)) {
Text(title, color = TcColors.Text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
Text(details, color = TcColors.TextMuted, fontSize = 13.sp)
}
onDelete?.let { TcButton("Löschen", variant = ButtonVariant.Danger, onClick = it) }
}
}
@Composable
private fun SectionTitle(title: String) {
Text(
text = title,
color = TcColors.Text,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = TcSpacing.Sm),
)
}
@Composable
private fun Detail(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(label, color = TcColors.TextMuted, fontSize = 14.sp)
Text(value, color = TcColors.Text, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
}

View File

@@ -0,0 +1,121 @@
package de.tsschulz.timeclock.ui.settings
import android.app.Application
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import de.tsschulz.timeclock.data.api.InvitationDto
import de.tsschulz.timeclock.data.api.ProfileDto
import de.tsschulz.timeclock.data.api.StateDto
import de.tsschulz.timeclock.data.api.TimeClockApiClient
import de.tsschulz.timeclock.data.api.TimewishDto
import de.tsschulz.timeclock.data.api.WatcherDto
import de.tsschulz.timeclock.data.auth.TokenStore
import de.tsschulz.timeclock.data.settings.SettingsRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class SettingsUiState(
val loading: Boolean = false,
val error: String? = null,
val success: String? = null,
val profile: ProfileDto? = null,
val states: List<StateDto> = emptyList(),
val timewishes: List<TimewishDto> = emptyList(),
val invites: List<InvitationDto> = emptyList(),
val watchers: List<WatcherDto> = emptyList(),
)
class SettingsViewModel(
private val repository: SettingsRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
fun loadPhase5() {
loadProfile()
loadTimewishes()
loadInvites()
loadWatchers()
}
fun loadProfile() = launchLoad {
copy(profile = repository.getProfile(), states = repository.getStates())
}
fun updateProfile(fullName: String, stateId: String?, weekWorkdays: Int, dailyHours: Double, preferredTitleType: Int) = launchMutation("Profil gespeichert") {
repository.updateProfile(fullName, stateId, weekWorkdays, dailyHours, preferredTitleType)
loadProfile()
}
fun changePassword(oldPassword: String, newPassword: String, confirmPassword: String) = launchMutation("Passwort geändert") {
repository.changePassword(oldPassword, newPassword, confirmPassword)
}
fun loadTimewishes() = launchLoad {
copy(timewishes = repository.getTimewishes())
}
fun createTimewish(day: Int, wishtype: Int, hours: Double?, startDate: String, endDate: String?) = launchMutation("Zeitwunsch gespeichert") {
repository.createTimewish(day, wishtype, hours, startDate, endDate)
loadTimewishes()
}
fun deleteTimewish(id: String) = launchMutation("Zeitwunsch gelöscht") {
repository.deleteTimewish(id)
loadTimewishes()
}
fun loadInvites() = launchLoad {
copy(invites = repository.getInvites())
}
fun sendInvite(email: String) = launchMutation("Einladung gesendet") {
repository.sendInvite(email)
loadInvites()
}
fun loadWatchers() = launchLoad {
copy(watchers = repository.getWatchers())
}
fun addWatcher(email: String) = launchMutation("Zugriff gespeichert") {
repository.addWatcher(email)
loadWatchers()
}
fun deleteWatcher(id: String) = launchMutation("Zugriff entfernt") {
repository.deleteWatcher(id)
loadWatchers()
}
private fun launchLoad(reducer: suspend SettingsUiState.() -> SettingsUiState) {
viewModelScope.launch {
_uiState.update { it.copy(loading = true, error = null, success = null) }
runCatching { _uiState.value.reducer() }
.onSuccess { next -> _uiState.value = next.copy(loading = false, error = null) }
.onFailure { e -> _uiState.update { it.copy(loading = false, error = e.message ?: "Daten konnten nicht geladen werden") } }
}
}
private fun launchMutation(successMessage: String, block: suspend () -> Unit) {
viewModelScope.launch {
_uiState.update { it.copy(loading = true, error = null, success = null) }
runCatching { block() }
.onSuccess { _uiState.update { it.copy(success = successMessage) } }
.onFailure { e -> _uiState.update { it.copy(error = e.message ?: "Aktion fehlgeschlagen") } }
_uiState.update { it.copy(loading = false) }
}
}
class Factory(private val application: Application) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val tokenStore = TokenStore(application)
return SettingsViewModel(SettingsRepository(TimeClockApiClient(tokenStore))) as T
}
}
}

View File

@@ -0,0 +1,87 @@
package de.tsschulz.timeclock.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
object TcColors {
val Background = Color.White
val Text = Color.Black
val TextMuted = Color(0xFF555555)
val Border = Color(0xFFE0E0E0)
val BorderSoft = Color(0xFFE0FFE0)
val Navbar = Color(0xFFF0FFEC)
val Card = Color(0xFFFAFAFA)
val Button = Color(0xFFF5F5F5)
val ButtonHover = Color(0xFFE5E5E5)
val ButtonBorder = Color(0xFFCCCCCC)
val Primary = Color(0xFF5BC0DE)
val PrimaryBorder = Color(0xFF46B8DA)
val PrimaryHover = Color(0xFF31B0D5)
val Success = Color(0xFF5CB85C)
val SuccessBorder = Color(0xFF4CAE4C)
val Danger = Color(0xFFD9534F)
val DangerBorder = Color(0xFFD43F3A)
val Secondary = Color(0xFF6C757D)
val InputBorder = Color(0xFFDDDDDD)
val ActiveMenu = Color(0xFFE8F5E9)
/** Login-Formular (Web: `.auth-form-container` / `h2`) */
val FormSurface = Color.White
val FormHeaderBg = Color(0xFFF5F5F5)
val AuthErrorBg = Color(0xFFF2DEDE)
val AuthErrorBorder = Color(0xFFEBCCD1)
val AuthErrorText = Color(0xFFA94442)
val GoogleText = Color(0xFF444444)
val DividerText = Color(0xFF999999)
}
object TcSpacing {
val Xs: Dp = 4.dp
val Sm: Dp = 8.dp
val Md: Dp = 12.dp
val Lg: Dp = 16.dp
val Xl: Dp = 24.dp
val Xxl: Dp = 32.dp
val WebContainer: Dp = 48.dp
}
object TcRadius {
val Small: Dp = 3.dp
val Medium: Dp = 4.dp
val Card: Dp = 6.dp
/** Web-Login-Container */
val AuthPanel: Dp = 8.dp
}
private val TimeClockColorScheme = lightColorScheme(
primary = TcColors.Primary,
secondary = TcColors.Secondary,
background = TcColors.Background,
surface = TcColors.Background,
surfaceVariant = TcColors.FormHeaderBg,
surfaceContainer = TcColors.Background,
surfaceContainerHigh = TcColors.FormSurface,
outline = TcColors.Border,
outlineVariant = TcColors.InputBorder,
onPrimary = Color.White,
onSecondary = Color.White,
onBackground = TcColors.Text,
onSurface = TcColors.Text,
onSurfaceVariant = TcColors.TextMuted,
)
@Composable
fun TimeClockTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = TimeClockColorScheme,
typography = MaterialTheme.typography,
content = content,
)
}

View File

@@ -0,0 +1,152 @@
package de.tsschulz.timeclock.ui.time
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.tsschulz.timeclock.data.api.TimeEntryDto
import de.tsschulz.timeclock.data.api.TimeStatsDto
import de.tsschulz.timeclock.ui.components.TcButton
import de.tsschulz.timeclock.ui.components.TcCard
import de.tsschulz.timeclock.ui.components.TcError
import de.tsschulz.timeclock.ui.components.TcLoading
import de.tsschulz.timeclock.ui.model.ButtonVariant
import de.tsschulz.timeclock.ui.theme.TcColors
import de.tsschulz.timeclock.ui.theme.TcRadius
import de.tsschulz.timeclock.ui.theme.TcSpacing
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
@Composable
fun EntriesScreen(
entries: List<TimeEntryDto>,
loading: Boolean,
error: String?,
onRefresh: () -> Unit,
onDelete: (String) -> Unit,
) {
TcCard {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
SectionTitle("${entries.size} Einträge")
TcButton("Aktualisieren", variant = ButtonVariant.Secondary, onClick = onRefresh)
}
}
if (loading) TcLoading("Lade Einträge...")
error?.let { TcError(it) }
if (!loading && entries.isEmpty()) {
TcCard { Text("Keine Einträge vorhanden.", color = TcColors.TextMuted, fontSize = 14.sp) }
} else {
entries.forEach { entry -> EntryRow(entry, onDelete) }
}
}
@Composable
fun StatsScreen(
stats: TimeStatsDto?,
loading: Boolean,
error: String?,
onRefresh: () -> Unit,
) {
TcCard {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
SectionTitle("Statistiken")
TcButton("Aktualisieren", variant = ButtonVariant.Secondary, onClick = onRefresh)
}
}
if (loading) TcLoading("Lade Statistiken...")
error?.let { TcError(it) }
val data = stats
if (data == null && !loading) {
TcCard { Text("Keine Statistiken vorhanden.", color = TcColors.TextMuted, fontSize = 14.sp) }
return
}
data?.let {
Row(horizontalArrangement = Arrangement.spacedBy(TcSpacing.Lg), modifier = Modifier.fillMaxWidth()) {
StatCard("Arbeitszeit heute", it.currentlyWorked ?: "-", Modifier.weight(1f))
StatCard("Offen heute", it.open ?: "-", Modifier.weight(1f))
}
Row(horizontalArrangement = Arrangement.spacedBy(TcSpacing.Lg), modifier = Modifier.fillMaxWidth()) {
StatCard("Woche", it.weekWorktime ?: "-", Modifier.weight(1f))
StatCard("Überstunden", it.totalOvertime ?: it.overtime ?: "-", Modifier.weight(1f))
}
TcCard {
Detail("Reguläres Ende", it.regularEnd ?: "-")
Detail("Angepasstes Ende", it.adjustedEndTodayGeneral ?: it.adjustedEndToday ?: "-")
Detail("Fehlende Pause", it.missingBreakMinutes?.let { minutes -> "$minutes min" } ?: "-")
Detail("Nicht-Arbeitszeit", it.nonWorkingHours ?: "-")
}
}
}
@Composable
private fun EntryRow(entry: TimeEntryDto, onDelete: (String) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(TcColors.Background, RoundedCornerShape(TcRadius.Medium))
.border(1.dp, TcColors.Border, RoundedCornerShape(TcRadius.Medium))
.padding(TcSpacing.Md),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TcSpacing.Md),
) {
Column(modifier = Modifier.weight(1f)) {
Text(entry.project ?: "Allgemein", color = TcColors.Text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
Text(
"${entry.startTime.toDisplayDateTime()} - ${entry.endTime.toDisplayDateTime()} · ${entry.duration.toDurationText()}",
color = TcColors.TextMuted,
fontSize = 13.sp,
)
if (!entry.description.isNullOrBlank()) Text(entry.description, color = TcColors.TextMuted, fontSize = 13.sp)
}
Text(if (entry.isRunning) "Läuft" else "Beendet", color = if (entry.isRunning) TcColors.Danger else TcColors.Success, fontSize = 13.sp)
TcButton("Löschen", variant = ButtonVariant.Danger, onClick = { onDelete(entry.id) })
}
}
@Composable
private fun StatCard(label: String, value: String, modifier: Modifier = Modifier) {
TcCard(modifier = modifier) {
Text(value, color = TcColors.Text, fontSize = 22.sp, fontWeight = FontWeight.Bold)
Text(label, color = TcColors.TextMuted, fontSize = 13.sp)
}
}
@Composable
private fun SectionTitle(title: String) {
Text(title, color = TcColors.Text, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
}
@Composable
private fun Detail(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(label, color = TcColors.TextMuted, fontSize = 14.sp)
Text(value, color = TcColors.Text, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
}
private fun String?.toDisplayDateTime(): String {
if (this.isNullOrBlank()) return "-"
return runCatching {
OffsetDateTime.parse(this).format(DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm", Locale.GERMANY))
}.getOrDefault(this)
}
private fun Long?.toDurationText(): String {
if (this == null) return "-"
val hours = this / 3600
val minutes = (this % 3600) / 60
val seconds = this % 60
return "%02d:%02d:%02d".format(hours, minutes, seconds)
}

View File

@@ -0,0 +1,224 @@
package de.tsschulz.timeclock.ui.time
import android.app.Application
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import de.tsschulz.timeclock.data.api.TimeClockApiClient
import de.tsschulz.timeclock.data.api.TimeEntryDto
import de.tsschulz.timeclock.data.api.TimeStatsDto
import de.tsschulz.timeclock.data.api.WeekOverviewDto
import de.tsschulz.timeclock.data.auth.TokenStore
import de.tsschulz.timeclock.data.offline.OfflineClockQueue
import de.tsschulz.timeclock.data.time.TimeDashboard
import de.tsschulz.timeclock.data.time.TimeRepository
import de.tsschulz.timeclock.ui.model.ButtonVariant
import de.tsschulz.timeclock.ui.model.StatusAction
import de.tsschulz.timeclock.ui.model.StatusRow
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
data class TimeUiState(
val loading: Boolean = false,
val clockInProgress: Boolean = false,
val error: String? = null,
val dashboard: TimeDashboard? = null,
val statusRows: List<StatusRow> = listOf(StatusRow(label = "Heute", isHeading = true), StatusRow("Status", "")),
val primaryAction: StatusAction? = null,
val secondaryAction: StatusAction? = null,
val weekLoading: Boolean = false,
val weekOffset: Int = 0,
val week: WeekOverviewDto? = null,
val weekError: String? = null,
val entries: List<TimeEntryDto> = emptyList(),
val entriesLoading: Boolean = false,
val entriesError: String? = null,
val stats: TimeStatsDto? = null,
val statsLoading: Boolean = false,
val statsError: String? = null,
)
class TimeViewModel(
private val repository: TimeRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(TimeUiState())
val uiState: StateFlow<TimeUiState> = _uiState.asStateFlow()
private var refreshJob: Job? = null
fun start() {
if (refreshJob != null) return
refreshJob = viewModelScope.launch {
refreshAll()
while (true) {
delay(30_000)
refreshDashboard()
}
}
}
fun stop() {
refreshJob?.cancel()
refreshJob = null
_uiState.value = TimeUiState()
}
fun refreshAll() {
refreshDashboard()
loadWeek(_uiState.value.weekOffset)
}
fun refreshDashboard() {
viewModelScope.launch {
_uiState.update { it.copy(loading = true, error = null) }
runCatching { repository.loadDashboard() }
.onSuccess { dashboard ->
_uiState.update {
it.copy(
loading = false,
dashboard = dashboard,
statusRows = dashboard.toStatusRows(),
primaryAction = dashboard.primaryAction(),
secondaryAction = dashboard.secondaryAction(),
error = null,
)
}
}
.onFailure { e ->
_uiState.update { it.copy(loading = false, error = e.message ?: "Status konnte nicht geladen werden") }
}
}
}
fun clock(action: String) {
viewModelScope.launch {
_uiState.update { it.copy(clockInProgress = true, error = null) }
runCatching { repository.clock(action) }
.onSuccess { dashboard ->
_uiState.update {
it.copy(
clockInProgress = false,
dashboard = dashboard,
statusRows = dashboard.toStatusRows(),
primaryAction = dashboard.primaryAction(),
secondaryAction = dashboard.secondaryAction(),
error = null,
)
}
loadWeek(_uiState.value.weekOffset)
}
.onFailure { e ->
_uiState.update { it.copy(clockInProgress = false, error = e.message ?: "Stempeln fehlgeschlagen") }
}
}
}
fun loadWeek(weekOffset: Int) {
viewModelScope.launch {
_uiState.update { it.copy(weekLoading = true, weekError = null, weekOffset = weekOffset) }
runCatching { repository.loadWeek(weekOffset) }
.onSuccess { week ->
_uiState.update { it.copy(weekLoading = false, week = week, weekError = null) }
}
.onFailure { e ->
_uiState.update {
it.copy(weekLoading = false, weekError = e.message ?: "Wochenübersicht konnte nicht geladen werden")
}
}
}
}
fun loadEntries() {
viewModelScope.launch {
_uiState.update { it.copy(entriesLoading = true, entriesError = null) }
runCatching { repository.loadEntries() }
.onSuccess { entries -> _uiState.update { it.copy(entriesLoading = false, entries = entries) } }
.onFailure { e -> _uiState.update { it.copy(entriesLoading = false, entriesError = e.message ?: "Einträge konnten nicht geladen werden") } }
}
}
fun deleteEntry(id: String) {
viewModelScope.launch {
_uiState.update { it.copy(entriesLoading = true, entriesError = null) }
runCatching {
repository.deleteEntry(id)
repository.loadEntries()
}
.onSuccess { entries -> _uiState.update { it.copy(entriesLoading = false, entries = entries) } }
.onFailure { e -> _uiState.update { it.copy(entriesLoading = false, entriesError = e.message ?: "Eintrag konnte nicht gelöscht werden") } }
}
}
fun loadStats() {
viewModelScope.launch {
_uiState.update { it.copy(statsLoading = true, statsError = null) }
runCatching { repository.loadStats() }
.onSuccess { stats -> _uiState.update { it.copy(statsLoading = false, stats = stats) } }
.onFailure { e -> _uiState.update { it.copy(statsLoading = false, statsError = e.message ?: "Statistiken konnten nicht geladen werden") } }
}
}
private fun TimeDashboard.toStatusRows(): List<StatusRow> {
val stats = stats
return buildList {
add(StatusRow(label = "Heute", isHeading = true))
add(StatusRow("Status", state.toStatusText()))
if (runningStartTime != null) add(StatusRow("Beginn", runningStartTime.toDisplayTime()))
if (currentPauseStart != null) add(StatusRow("Pause seit", currentPauseStart.toDisplayTime()))
add(StatusRow("Arbeitszeit", stats.currentlyWorked ?: ""))
add(StatusRow("Offen", stats.open ?: ""))
add(StatusRow("Woche", stats.weekWorktime ?: ""))
add(StatusRow("Überstunden", stats.overtime ?: ""))
add(StatusRow("Gesamt", stats.totalOvertime ?: ""))
add(StatusRow("Arbeitsende", stats.adjustedEndTodayGeneral ?: stats.regularEnd ?: ""))
}
}
private fun TimeDashboard.primaryAction(): StatusAction? =
when (state) {
null, "stop work" -> StatusAction("Kommen", ButtonVariant.Success, "start work")
"start work", "stop pause" -> StatusAction("Pause starten", ButtonVariant.Default, "start pause")
"start pause" -> StatusAction("Pause beenden", ButtonVariant.Success, "stop pause")
else -> StatusAction("Kommen", ButtonVariant.Success, "start work")
}
private fun TimeDashboard.secondaryAction(): StatusAction? =
when (state) {
"start work", "stop pause" -> StatusAction("Gehen", ButtonVariant.Secondary, "stop work")
else -> null
}
private fun String?.toStatusText(): String =
when (this) {
null, "stop work" -> "Nicht eingestempelt"
"start work" -> "Arbeit läuft"
"start pause" -> "Pause läuft"
"stop pause" -> "Arbeit läuft"
else -> this
}
private fun String.toDisplayTime(): String =
runCatching {
OffsetDateTime.parse(this).toLocalTime().format(DateTimeFormatter.ofPattern("HH:mm", Locale.GERMANY))
}.getOrDefault(this)
class Factory(
private val application: Application,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val tokenStore = TokenStore(application)
val api = TimeClockApiClient(tokenStore)
val repo = TimeRepository(api, OfflineClockQueue(application))
return TimeViewModel(repo) as T
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -0,0 +1,4 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_stechuhr_logo" />
</adaptive-icon>

View File

@@ -0,0 +1,4 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_stechuhr_logo" />
</adaptive-icon>

View File

@@ -0,0 +1,3 @@
<resources>
<color name="ic_launcher_background">#F0FFEC</color>
</resources>

View File

@@ -0,0 +1,8 @@
<resources>
<style name="Theme.TimeClock" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:statusBarColor">#F0FFEC</item>
<item name="android:navigationBarColor">#FFFFFF</item>
</style>
</resources>

View File

@@ -0,0 +1,111 @@
package de.tsschulz.timeclock.data.api
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ApiSerializationTest {
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
coerceInputValues = true
}
@Test
fun loginResponseMapsWebUserFields() {
val raw = """
{
"success": true,
"token": "jwt",
"user": {
"id": 1,
"full_name": "Max Mustermann",
"email": "max@example.com",
"role": 1,
"daily_hours": 8,
"week_workdays": 5
}
}
""".trimIndent()
val decoded = json.decodeFromString(LoginResponse.serializer(), raw)
assertTrue(decoded.success)
assertEquals("jwt", decoded.token)
assertEquals("Max Mustermann", decoded.user?.fullName)
assertEquals(1, decoded.user?.role)
assertEquals(5, decoded.user?.weekWorkdays)
}
@Test
fun protectedIdsDecodeAsStrings() {
val raw = """
[
{
"id": "abc.def",
"project": "Allgemein",
"description": "Test",
"startTime": "2026-05-14T08:00:00+02:00",
"endTime": null,
"duration": 3600,
"isRunning": true
}
]
""".trimIndent()
val decoded = json.decodeFromString(ListSerializer(TimeEntryDto.serializer()), raw)
assertEquals("abc.def", decoded.first().id)
assertTrue(decoded.first().isRunning)
assertEquals(3600L, decoded.first().duration)
}
@Test
fun holidayResponseKeepsFutureAndPastBuckets() {
val raw = """
{
"future": [
{
"id": "holiday.hash",
"date": "2026-10-03",
"hours": 8,
"description": "Tag der Deutschen Einheit",
"states": [],
"isFederal": true
}
],
"past": []
}
""".trimIndent()
val decoded = json.decodeFromString(HolidaysResponse.serializer(), raw)
assertEquals(1, decoded.future.size)
assertEquals("Tag der Deutschen Einheit", decoded.future.first().description)
assertTrue(decoded.future.first().isFederal)
assertTrue(decoded.past.isEmpty())
}
@Test
fun roleUserDecodesUserRole() {
val raw = """
{
"id": "user.hash",
"fullName": "Erika Musterfrau",
"role": 0,
"roleString": "user",
"stateName": "Nordrhein-Westfalen"
}
""".trimIndent()
val decoded = json.decodeFromString(RoleUserDto.serializer(), raw)
assertEquals("user.hash", decoded.id)
assertEquals("Erika Musterfrau", decoded.fullName)
assertFalse(decoded.role == 1)
assertEquals("Nordrhein-Westfalen", decoded.stateName)
}
}

View File

@@ -0,0 +1,25 @@
package de.tsschulz.timeclock.data.offline
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Test
class PendingClockActionTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun pendingClockActionsRoundTrip() {
val actions = listOf(
PendingClockAction(id = "1", action = "start work", createdAtEpochMillis = 1_777_000_000_000),
PendingClockAction(id = "2", action = "stop work", createdAtEpochMillis = 1_777_000_300_000),
)
val raw = json.encodeToString(ListSerializer(PendingClockAction.serializer()), actions)
val decoded = json.decodeFromString(ListSerializer(PendingClockAction.serializer()), raw)
assertEquals(actions, decoded)
assertEquals("start work", decoded.first().action)
assertEquals("stop work", decoded.last().action)
}
}

View File

@@ -0,0 +1,4 @@
android.useAndroidX=true
android.nonTransitiveRClass=true
org.gradle.jvmargs=-Xmx3072m -Dfile.encoding=UTF-8
org.jetbrains.kotlin.code.style=official

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

248
mobile-app/gradlew vendored Executable file
View File

@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

93
mobile-app/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "mobile-app"
include(":composeApp")