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:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -46,6 +46,12 @@ tmp/
|
|||||||
temp/
|
temp/
|
||||||
*.tmp
|
*.tmp
|
||||||
|
|
||||||
|
# Android / Gradle
|
||||||
|
.gradle/
|
||||||
|
*/.gradle/
|
||||||
|
local.properties
|
||||||
|
*/local.properties
|
||||||
|
|
||||||
# SQL scripts (optional - nur wenn sie sensible Daten enthalten)
|
# SQL scripts (optional - nur wenn sie sensible Daten enthalten)
|
||||||
# backend/*.sql
|
# backend/*.sql
|
||||||
|
|
||||||
|
|||||||
427
ANDROID_FRONTEND_PLAN.md
Normal file
427
ANDROID_FRONTEND_PLAN.md
Normal 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.
|
||||||
58
ANDROID_TECHNICAL_NEXT_STEPS.md
Normal file
58
ANDROID_TECHNICAL_NEXT_STEPS.md
Normal 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.
|
||||||
@@ -11,7 +11,8 @@ class TimeEntryController {
|
|||||||
*/
|
*/
|
||||||
async getAllEntries(req, res) {
|
async getAllEntries(req, res) {
|
||||||
try {
|
try {
|
||||||
const entries = timeEntryService.getAllEntries();
|
const userId = req.user?.userId;
|
||||||
|
const entries = await timeEntryService.getAllEntries(userId);
|
||||||
res.json(entries);
|
res.json(entries);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Abrufen der Einträge:', error);
|
console.error('Fehler beim Abrufen der Einträge:', error);
|
||||||
@@ -119,7 +120,7 @@ class TimeEntryController {
|
|||||||
async deleteEntry(req, res) {
|
async deleteEntry(req, res) {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
timeEntryService.deleteEntry(id);
|
await timeEntryService.deleteEntry(id);
|
||||||
|
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -262,4 +263,3 @@ class TimeEntryController {
|
|||||||
|
|
||||||
// Singleton-Instanz exportieren
|
// Singleton-Instanz exportieren
|
||||||
module.exports = new TimeEntryController();
|
module.exports = new TimeEntryController();
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ class TimefixService {
|
|||||||
|
|
||||||
// Hole auch die Timefixes für diese Einträge
|
// Hole auch die Timefixes für diese Einträge
|
||||||
const entryIds = entries.map(e => e.id);
|
const entryIds = entries.map(e => e.id);
|
||||||
|
if (entryIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const timefixesForEntries = await sequelize.query(
|
const timefixesForEntries = await sequelize.query(
|
||||||
`SELECT worklog_id, fix_type, fix_date_time FROM timefix WHERE worklog_id IN (?)`,
|
`SELECT worklog_id, fix_type, fix_date_time FROM timefix WHERE worklog_id IN (?)`,
|
||||||
{
|
{
|
||||||
@@ -349,4 +353,3 @@ class TimefixService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = new TimefixService();
|
module.exports = new TimefixService();
|
||||||
|
|
||||||
|
|||||||
BIN
frontend/images/stechuhr.png
Normal file
BIN
frontend/images/stechuhr.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -2,7 +2,9 @@
|
|||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<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">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>TimeClock v3 - Zeiterfassung</title>
|
<title>TimeClock v3 - Zeiterfassung</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
BIN
frontend/public/stechuhr.png
Normal file
BIN
frontend/public/stechuhr.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -3,9 +3,7 @@
|
|||||||
<div class="navbar" v-if="authStore.isAuthenticated">
|
<div class="navbar" v-if="authStore.isAuthenticated">
|
||||||
<div class="navbar-inner">
|
<div class="navbar-inner">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1 class="brand">
|
<AppBrand />
|
||||||
<RouterLink to="/">Stechuhr</RouterLink>
|
|
||||||
</h1>
|
|
||||||
<div class="nav-title-menu">
|
<div class="nav-title-menu">
|
||||||
<h2 class="page-title" v-if="pageTitle">{{ pageTitle }}</h2>
|
<h2 class="page-title" v-if="pageTitle">{{ pageTitle }}</h2>
|
||||||
<div class="nav-collapse">
|
<div class="nav-collapse">
|
||||||
@@ -47,12 +45,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { RouterLink, RouterView, useRoute } from 'vue-router'
|
import { RouterView, useRoute } from 'vue-router'
|
||||||
import { useAuthStore } from './stores/authStore'
|
import { useAuthStore } from './stores/authStore'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import StatusBox from './components/StatusBox.vue'
|
import StatusBox from './components/StatusBox.vue'
|
||||||
import SideMenu from './components/SideMenu.vue'
|
import SideMenu from './components/SideMenu.vue'
|
||||||
|
import AppBrand from './components/AppBrand.vue'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -133,24 +132,6 @@ const pageTitle = computed(() => {
|
|||||||
flex-direction: row;
|
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 {
|
.nav-collapse {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
54
frontend/src/components/AppBrand.vue
Normal file
54
frontend/src/components/AppBrand.vue
Normal 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>
|
||||||
@@ -3,9 +3,7 @@
|
|||||||
<div class="navbar">
|
<div class="navbar">
|
||||||
<div class="navbar-inner">
|
<div class="navbar-inner">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1 class="brand">
|
<AppBrand />
|
||||||
<router-link to="/">Stechuhr</router-link>
|
|
||||||
</h1>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,6 +113,7 @@ import { ref } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '../stores/authStore'
|
import { useAuthStore } from '../stores/authStore'
|
||||||
import { API_BASE_URL } from '@/config/api'
|
import { API_BASE_URL } from '@/config/api'
|
||||||
|
import AppBrand from '../components/AppBrand.vue'
|
||||||
|
|
||||||
const API_URL = API_BASE_URL
|
const API_URL = API_BASE_URL
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -189,24 +188,6 @@ body {
|
|||||||
border-bottom: 1px solid #e0ffe0;
|
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 {
|
.contents {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -3,9 +3,7 @@
|
|||||||
<div class="navbar">
|
<div class="navbar">
|
||||||
<div class="navbar-inner">
|
<div class="navbar-inner">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1 class="brand">
|
<AppBrand />
|
||||||
<router-link to="/">Stechuhr</router-link>
|
|
||||||
</h1>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,6 +67,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useAuthStore } from '../stores/authStore'
|
import { useAuthStore } from '../stores/authStore'
|
||||||
|
import AppBrand from '../components/AppBrand.vue'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const email = ref('')
|
const email = ref('')
|
||||||
@@ -137,24 +136,6 @@ const copyResetLink = async () => {
|
|||||||
border-bottom: 1px solid #e0ffe0;
|
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 {
|
.contents {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -3,9 +3,7 @@
|
|||||||
<div class="navbar">
|
<div class="navbar">
|
||||||
<div class="navbar-inner">
|
<div class="navbar-inner">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1 class="brand">
|
<AppBrand />
|
||||||
<router-link to="/">Stechuhr</router-link>
|
|
||||||
</h1>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,6 +79,7 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useAuthStore } from '../stores/authStore'
|
import { useAuthStore } from '../stores/authStore'
|
||||||
|
import AppBrand from '../components/AppBrand.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
@@ -143,24 +142,6 @@ const handleReset = async () => {
|
|||||||
border-bottom: 1px solid #e0ffe0;
|
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 {
|
.contents {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -3,9 +3,7 @@
|
|||||||
<div class="navbar">
|
<div class="navbar">
|
||||||
<div class="navbar-inner">
|
<div class="navbar-inner">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1 class="brand">
|
<AppBrand />
|
||||||
<router-link to="/">Stechuhr</router-link>
|
|
||||||
</h1>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,6 +94,7 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '../stores/authStore'
|
import { useAuthStore } from '../stores/authStore'
|
||||||
|
import AppBrand from '../components/AppBrand.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
@@ -163,24 +162,6 @@ const handleRegister = async () => {
|
|||||||
border-bottom: 1px solid #e0ffe0;
|
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 {
|
.contents {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
67
mobile-app/README.md
Normal file
67
mobile-app/README.md
Normal 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 Phase‑1-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
|
||||||
|
```
|
||||||
5
mobile-app/build.gradle.kts
Normal file
5
mobile-app/build.gradle.kts
Normal 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
|
||||||
|
}
|
||||||
65
mobile-app/composeApp/build.gradle.kts
Normal file
65
mobile-app/composeApp/build.gradle.kts
Normal 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")
|
||||||
|
}
|
||||||
BIN
mobile-app/composeApp/release/composeApp-release.aab
Normal file
BIN
mobile-app/composeApp/release/composeApp-release.aab
Normal file
Binary file not shown.
5
mobile-app/composeApp/src/debug/AndroidManifest.xml
Normal file
5
mobile-app/composeApp/src/debug/AndroidManifest.xml
Normal 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>
|
||||||
20
mobile-app/composeApp/src/main/AndroidManifest.xml
Normal file
20
mobile-app/composeApp/src/main/AndroidManifest.xml
Normal 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>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package de.tsschulz.timeclock.data.api
|
||||||
|
|
||||||
|
class ApiException(
|
||||||
|
message: String,
|
||||||
|
val code: Int,
|
||||||
|
) : Exception(message)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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>
|
||||||
@@ -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>
|
||||||
3
mobile-app/composeApp/src/main/res/values/colors.xml
Normal file
3
mobile-app/composeApp/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#F0FFEC</color>
|
||||||
|
</resources>
|
||||||
8
mobile-app/composeApp/src/main/res/values/styles.xml
Normal file
8
mobile-app/composeApp/src/main/res/values/styles.xml
Normal 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>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
4
mobile-app/gradle.properties
Normal file
4
mobile-app/gradle.properties
Normal 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
|
||||||
BIN
mobile-app/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
mobile-app/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
mobile-app/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
mobile-app/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
248
mobile-app/gradlew
vendored
Executable 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
93
mobile-app/gradlew.bat
vendored
Normal 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
|
||||||
18
mobile-app/settings.gradle.kts
Normal file
18
mobile-app/settings.gradle.kts
Normal 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")
|
||||||
Reference in New Issue
Block a user