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/
|
||||
*.tmp
|
||||
|
||||
# Android / Gradle
|
||||
.gradle/
|
||||
*/.gradle/
|
||||
local.properties
|
||||
*/local.properties
|
||||
|
||||
# SQL scripts (optional - nur wenn sie sensible Daten enthalten)
|
||||
# backend/*.sql
|
||||
|
||||
|
||||
427
ANDROID_FRONTEND_PLAN.md
Normal file
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) {
|
||||
try {
|
||||
const entries = timeEntryService.getAllEntries();
|
||||
const userId = req.user?.userId;
|
||||
const entries = await timeEntryService.getAllEntries(userId);
|
||||
res.json(entries);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Abrufen der Einträge:', error);
|
||||
@@ -119,7 +120,7 @@ class TimeEntryController {
|
||||
async deleteEntry(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
timeEntryService.deleteEntry(id);
|
||||
await timeEntryService.deleteEntry(id);
|
||||
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
@@ -262,4 +263,3 @@ class TimeEntryController {
|
||||
|
||||
// Singleton-Instanz exportieren
|
||||
module.exports = new TimeEntryController();
|
||||
|
||||
|
||||
@@ -34,6 +34,10 @@ class TimefixService {
|
||||
|
||||
// Hole auch die Timefixes für diese Einträge
|
||||
const entryIds = entries.map(e => e.id);
|
||||
if (entryIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const timefixesForEntries = await sequelize.query(
|
||||
`SELECT worklog_id, fix_type, fix_date_time FROM timefix WHERE worklog_id IN (?)`,
|
||||
{
|
||||
@@ -349,4 +353,3 @@ class TimefixService {
|
||||
}
|
||||
|
||||
module.exports = new TimefixService();
|
||||
|
||||
|
||||
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">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||
<link rel="icon" type="image/png" href="/stechuhr.png">
|
||||
<link rel="apple-touch-icon" href="/stechuhr.png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TimeClock v3 - Zeiterfassung</title>
|
||||
</head>
|
||||
|
||||
BIN
frontend/public/favicon.ico
Normal file
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-inner">
|
||||
<div class="container">
|
||||
<h1 class="brand">
|
||||
<RouterLink to="/">Stechuhr</RouterLink>
|
||||
</h1>
|
||||
<AppBrand />
|
||||
<div class="nav-title-menu">
|
||||
<h2 class="page-title" v-if="pageTitle">{{ pageTitle }}</h2>
|
||||
<div class="nav-collapse">
|
||||
@@ -47,12 +45,13 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { RouterLink, RouterView, useRoute } from 'vue-router'
|
||||
import { RouterView, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from './stores/authStore'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed } from 'vue'
|
||||
import StatusBox from './components/StatusBox.vue'
|
||||
import SideMenu from './components/SideMenu.vue'
|
||||
import AppBrand from './components/AppBrand.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
@@ -133,24 +132,6 @@ const pageTitle = computed(() => {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.brand {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.brand a {
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
padding: 12px 20px 12px 0;
|
||||
font-size: 24px;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.brand a:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.nav-collapse {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
|
||||
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-inner">
|
||||
<div class="container">
|
||||
<h1 class="brand">
|
||||
<router-link to="/">Stechuhr</router-link>
|
||||
</h1>
|
||||
<AppBrand />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -115,6 +113,7 @@ import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
import { API_BASE_URL } from '@/config/api'
|
||||
import AppBrand from '../components/AppBrand.vue'
|
||||
|
||||
const API_URL = API_BASE_URL
|
||||
const router = useRouter()
|
||||
@@ -189,24 +188,6 @@ body {
|
||||
border-bottom: 1px solid #e0ffe0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.brand a {
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
padding: 12px 20px 12px 0;
|
||||
font-size: 24px;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.brand a:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.contents {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
<div class="navbar">
|
||||
<div class="navbar-inner">
|
||||
<div class="container">
|
||||
<h1 class="brand">
|
||||
<router-link to="/">Stechuhr</router-link>
|
||||
</h1>
|
||||
<AppBrand />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,6 +67,7 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
import AppBrand from '../components/AppBrand.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const email = ref('')
|
||||
@@ -137,24 +136,6 @@ const copyResetLink = async () => {
|
||||
border-bottom: 1px solid #e0ffe0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.brand a {
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
padding: 12px 20px 12px 0;
|
||||
font-size: 24px;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.brand a:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.contents {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
<div class="navbar">
|
||||
<div class="navbar-inner">
|
||||
<div class="container">
|
||||
<h1 class="brand">
|
||||
<router-link to="/">Stechuhr</router-link>
|
||||
</h1>
|
||||
<AppBrand />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -81,6 +79,7 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
import AppBrand from '../components/AppBrand.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
@@ -143,24 +142,6 @@ const handleReset = async () => {
|
||||
border-bottom: 1px solid #e0ffe0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.brand a {
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
padding: 12px 20px 12px 0;
|
||||
font-size: 24px;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.brand a:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.contents {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
<div class="navbar">
|
||||
<div class="navbar-inner">
|
||||
<div class="container">
|
||||
<h1 class="brand">
|
||||
<router-link to="/">Stechuhr</router-link>
|
||||
</h1>
|
||||
<AppBrand />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,6 +94,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
import AppBrand from '../components/AppBrand.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
@@ -163,24 +162,6 @@ const handleRegister = async () => {
|
||||
border-bottom: 1px solid #e0ffe0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.brand a {
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
padding: 12px 20px 12px 0;
|
||||
font-size: 24px;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.brand a:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.contents {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
67
mobile-app/README.md
Normal file
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