28
.github/workflows/android-ci.yml
vendored
Normal file
28
.github/workflows/android-ci.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
name: Android CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper/
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
- name: Build
|
||||
run: |
|
||||
./gradlew :app:assembleDebug
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -88,6 +88,12 @@ out
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Android / Gradle generated and machine-local files
|
||||
/android-app/.gradle/
|
||||
/android-app/.kotlin/
|
||||
/android-app/**/build/
|
||||
/android-app/local.properties
|
||||
|
||||
# Build output (but keep production data!)
|
||||
.output
|
||||
!.output/.gitkeep
|
||||
|
||||
36
ANDROID_ARCHITECTURE.md
Normal file
36
ANDROID_ARCHITECTURE.md
Normal file
@@ -0,0 +1,36 @@
|
||||
Android Architektur (Kotlin + Jetpack Compose) — Vorschlag
|
||||
|
||||
Packages/Module Struktur:
|
||||
- app/ (Android-App module)
|
||||
- src/main/java/de/harheimertc/
|
||||
- ui/
|
||||
- navigation/ (NavGraph, Routes)
|
||||
- screens/ (HomeScreen, TermineScreen, SpielplanScreen, GalleryScreen, ContactScreen, AuthScreens, CMS Screens)
|
||||
- components/ (TopBar, BottomNav, Cards, Modals)
|
||||
- theme/ (Color.kt, Typography.kt, Theme.kt)
|
||||
- data/
|
||||
- api/ (Retrofit interfaces, DTOs)
|
||||
- repository/ (Repositories für Domain-Modelle)
|
||||
- local/ (Room DAOs, Entities)
|
||||
- di/ (Hilt Modules)
|
||||
- domain/ (UseCases, Business-Logic)
|
||||
- util/ (Extensions, DateUtils, ImageUtils)
|
||||
- auth/ (AuthManager, Passkeys helper)
|
||||
|
||||
Wichtige Dateien:
|
||||
- `MainActivity.kt` — Hosts Compose NavHost
|
||||
- `AppTheme.kt` — Compose Material3 Theme mit Token-Mapping
|
||||
- `NetworkModule` (Hilt) — Retrofit + OkHttp + Auth Interceptor
|
||||
- `Repository` Layer — entkoppelt UI von Netz
|
||||
- `Room` Entities — für Caching von Termine/News/Galerie
|
||||
|
||||
Auth-Strategie:
|
||||
- AuthRepository verwaltet Login/Logout, `checkAuth()` (mirroring `/api/auth/status`).
|
||||
- Token/Cookie-Speicherung: `EncryptedSharedPreferences` für Tokens oder `CookieJar` mit OKHttp-Client.
|
||||
- Passkeys: `Fido2Client` wrapper + Bridge zu Server-API (Formate prüfen).
|
||||
|
||||
Build / Module Tipps:
|
||||
- Start mit Single Module `app/` und später evtl. `:data`, `:domain` Trennung.
|
||||
- Verwende Gradle Kotlin DSL (build.gradle.kts).
|
||||
|
||||
Diese Architekturdatei wurde generiert; ich kann nun ein initiales Gradle-Kotlin-Scaffold erzeugen. Soll ich das direkt in `android-app/` ablegen? (Ja/Nein)
|
||||
196
ANDROID_KOTLIN_PLAN.md
Normal file
196
ANDROID_KOTLIN_PLAN.md
Normal file
@@ -0,0 +1,196 @@
|
||||
Android App — Kotlin (Jetpack Compose) Plan und Abhakliste
|
||||
|
||||
Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web-UI 1:1 abbildet (Farben, Typografie, Funktionalitäten). Diese Datei enthält Architekturentscheidungen, empfohlene Bibliotheken und eine detaillierte Abhakliste (schrittweise).
|
||||
|
||||
1) Zusammenfassung der Entscheidungen
|
||||
- Plattform: Native Android
|
||||
- Sprache: Kotlin
|
||||
- UI-Toolkit: Jetpack Compose (Compose Material3)
|
||||
- Architektur: MVVM mit `ViewModel` + Kotlin Coroutines + Flow
|
||||
- DI: Hilt
|
||||
- HTTP-Client: Ktor Client oder Retrofit + OkHttp (empfohlen: Retrofit für breite Community-Docs)
|
||||
- Bild-Loading: Coil
|
||||
- Lokale DB / Caching: Room + DataStore (Preferences)
|
||||
- Background/Sync: WorkManager
|
||||
- Auth: kurzlebiges JWT-Access-Token plus rotierendes, widerrufbares Refresh-Token pro Android-Gerätesitzung; Speicherung in `EncryptedSharedPreferences`/Android Keystore; Unterstützung für Passkeys (Android Passkeys / WebAuthn Interop über FIDO2 APIs)
|
||||
- Auth-Sicherheitsentscheidung: kein statischer App-Key bzw. kein in der APK hinterlegtes Client-Secret. Native Apps können ein gemeinsames Secret nicht vertraulich halten. Optional später: Refresh-Sitzung an ein pro Installation im Android Keystore erzeugtes Schlüsselpaar binden.
|
||||
- Rich-Text: WebView-basierte Anzeige; Editoren: ggf. hybride Lösung (Server-side HTML editor + WebView) oder `RichEditor`-Libs
|
||||
- Crash-Reporting & Monitoring: Firebase Crashlytics oder Sentry
|
||||
|
||||
2) Design & Farben
|
||||
- Material Theme (Material3) mit Farben aus `tailwind.config.js` (Primary + Accent).
|
||||
- Fonts: Inter & Montserrat via Google Fonts (Download/Bundle oder Play-Services-Download at runtime).
|
||||
- Mapping: Tailwind-Token → `colors.xml` / Compose `Color` tokens.
|
||||
|
||||
3) Empfohlene Abhängigkeiten (erste Implementierung)
|
||||
- androidx.compose.* (ui, material3, navigation)
|
||||
- androidx.lifecycle:lifecycle-viewmodel-ktx
|
||||
- com.google.dagger:hilt-android
|
||||
- retrofit2 + converter-moshi / kotlinx-serialization
|
||||
- io.coil-kt:coil-compose
|
||||
- androidx.room:room-runtime + room-ktx
|
||||
- androidx.work:work-runtime-ktx
|
||||
- androidx.datastore:datastore-preferences
|
||||
- com.google.android.gms:play-services-auth (für passkeys falls nötig)
|
||||
- io.sentry:sentry-android (optional)
|
||||
|
||||
4) Detaillierte Abhakliste (Schritte)
|
||||
[x] 1. Repo-Analyse: Liste der externen Endpunkte und Auth-Anforderungen exportieren
|
||||
[x] 2. Projekt-Scaffold: Android Studio Projekt mit Kotlin + Compose anlegen
|
||||
[x] 3. App-Architektur: Module / Packages anlegen (ui, data, domain, di, util)
|
||||
[x] 4. CI-Build: Gradle-Config und GitHub Actions Skeleton
|
||||
[x] 5. Theme: `Color.kt`, `Typography.kt`, `Theme.kt` erstellen und Tailwind-Farben mappen
|
||||
[x] 6. Fonts: Inter + Montserrat einbinden (res/font oder GoogleFonts)
|
||||
[ ] 7. Navigation: Compose Navigation-Graph mit Routen für alle Web-Seiten anlegen
|
||||
[x] 7a. Umgebungen: Android-Varianten fuer lokal, Test-Instanz und Produktion mit eigener API-Basis konfigurieren
|
||||
[x] 7b. Adaptive Navigation: Tablet mit persistentem Header/Hauptmenue, Smartphone mit bestehender screenbezogener Navigation
|
||||
[x] 7c. Branding: vorhandenes Web-Logo als optimierte Android-Ressource in der App-Navigation verwenden
|
||||
[x] 7d. Tablet-Navigation: öffentliche Haupt- und Subnavigation der Web-UI mit Portierungszielen abbilden
|
||||
[x] 7e. Navigation: dynamische Mannschaftslinks, Galerie-Sichtbarkeit und rollenabhängiges `Intern` wie in der Web-UI anbinden
|
||||
[x] 8. Start-Screen: `HomeScreen` webnah mit Hero, Navigation, Termine, Spielen, News und Aktionen umsetzen
|
||||
[x] 9. Komponenten: NavBar, Footer, Cards, ImageGrid und News-Dialog implementieren
|
||||
[x] 10. Öffentliche Screens aus der Web-Navigation portieren
|
||||
- [x] `/` Startseite
|
||||
- [x] `/termine`: öffentliche Terminliste mit Lade-, Leer- und Fehlerzustand
|
||||
- [x] `/mannschaften/spielplaene`: Saison-, Wettbewerbs- und Mannschaftsfilter mit Spielkarten
|
||||
- [x] `/verein/galerie`: Anzeige-Screen vorhanden
|
||||
- [x] `/kontakt`: Formular-Screen vorhanden
|
||||
- [x] `/mitgliedschaft`: Antrag, Validierung, PDF-Erzeugung und PDF-Öffnen
|
||||
- [x] `/verein/ueber-uns`: CMS-Inhalt aus der öffentlichen Konfiguration
|
||||
- [x] `/vorstand`: öffentliche Vorstandsangaben aus der Konfiguration
|
||||
- [x] `/verein/geschichte`: CMS-Inhalt aus der öffentlichen Konfiguration
|
||||
- [x] `/verein/satzung`: CMS-Inhalt und PDF-Aufruf aus der öffentlichen Konfiguration
|
||||
- [x] `/vereinsmeisterschaften`: Ergebnisliste mit Jahresfilter und Statistik
|
||||
- [x] `/links`: strukturierte CMS-Links mit Fallback-Verweisen
|
||||
- [x] `/mannschaften`: Übersicht aus saisonaler Mannschafts-CSV
|
||||
- [x] `/mannschaften/[slug]`: dynamische Mannschaftsdetails mit aktuellem Spielplan und Umschaltung `Matches`/`Tabelle`
|
||||
- [x] `/spielsysteme`: Spielsystemkarten mit Kategoriefilter aus CSV
|
||||
- [x] `/training`: Trainingsort und gruppierte Trainingszeiten aus der Konfiguration
|
||||
- [x] `/training/trainer`
|
||||
- [x] `/training/anfaenger`
|
||||
- [x] `/tt-regeln`: Regelübersicht mit DTTB- und PDF-Aufruf
|
||||
[ ] 10a. Weitere öffentliche bzw. bestehende Web-Routen prüfen und portieren
|
||||
- [ ] `/anlagen`
|
||||
- [ ] `/impressum`
|
||||
- [ ] Legacy-/Doppelrouten klären: `/galerie`, `/geschichte`, `/satzung`, `/ueber-uns`, `/spielplan`, `/verein/tt-regeln`, `/mannschaft/[slug]`, `/mannschaften/herren`, `/mannschaften/damen`, `/mannschaften/jugend`
|
||||
[ ] 10b. Newsletter-Screens portieren
|
||||
- [ ] `/newsletter/subscribe`
|
||||
- [ ] `/newsletter/unsubscribe`
|
||||
- [ ] `/newsletter/confirm`, `/newsletter/confirmed`, `/newsletter/unsubscribed`
|
||||
[x] 10c. Auth-Screens portieren
|
||||
- [x] `/login`: Passwort-Login und Logout in der laufenden Sitzung
|
||||
- [x] `/registrieren`
|
||||
- [x] `/passwort-vergessen`
|
||||
[ ] 10d. Mitgliederbereich portieren
|
||||
- [ ] `/mitgliederbereich`: Übersicht
|
||||
- [ ] `/mitgliederbereich/mitglieder`
|
||||
- [ ] `/mitgliederbereich/news`
|
||||
- [ ] `/mitgliederbereich/profil`
|
||||
- [ ] `/mitgliederbereich/api`
|
||||
[ ] 10e. CMS-Screens nach Rollenberechtigung portieren
|
||||
- [ ] `/cms`, `/cms/startseite`, `/cms/inhalte`, `/cms/vereinsmeisterschaften`
|
||||
- [ ] `/cms/sportbetrieb`, `/cms/mitgliederverwaltung`, `/cms/kontaktanfragen`
|
||||
- [ ] `/cms/newsletter`, `/cms/einstellungen`, `/cms/benutzer`
|
||||
[ ] 11. API-Client: Retrofit/Ktor-Client implementieren, Auth-Interceptor (Token Refresh)
|
||||
- [x] Retrofit/OkHttp/Moshi und Hilt-Verdrahtung
|
||||
- [x] Öffentliche Endpunkte für Startseite, Termine, Spielplan, Galerie und Kontakt
|
||||
- [x] Mitgliedschafts-PDF-Endpunkte mit Cookie-Jar und `FileProvider`
|
||||
- [x] Passwort-Login-Endpunkt und Token-Übergabe an den Interceptor
|
||||
- [x] Verschlüsselte Token-Persistenz sowie Status/Logout per Bearer-Token
|
||||
- [ ] Bearer-Unterstützung aller später portierten geschützten Bereiche
|
||||
- [ ] `POST /api/auth/refresh` anbinden und Access-Token bei Ablauf automatisch erneuern
|
||||
- [ ] OkHttp-`Authenticator` mit genau einem synchronisierten Refresh-Versuch pro fehlgeschlagenem Request ergänzen
|
||||
[x] 12. Auth: Login/Register/Logout + sichere Token-Speicherung (EncryptedSharedPreferences)
|
||||
- [x] Login/Logout und verschlüsselte Token-Speicherung
|
||||
- [x] Registrierung und Passwort-Reset
|
||||
- [ ] Backend: JWT-Access-Token von aktuell 7 Tagen auf kurze Laufzeit (Ziel: ca. 15 Minuten) reduzieren
|
||||
- [ ] Backend: langlebige, zufällige Refresh-Token pro Gerätesitzung mit serverseitig gespeichertem Token-Hash einführen
|
||||
- [ ] Backend: Refresh-Token bei jeder Erneuerung rotieren und Wiederverwendung eines verbrauchten Tokens als Sitzungsdiebstahl behandeln
|
||||
- [ ] Backend: Logout, Kontodeaktivierung und Passwortänderung widerrufen betroffene Refresh-Sitzungen
|
||||
- [ ] App: Access- und Refresh-Token verschlüsselt speichern, Sitzung beim App-Start durch Refresh wiederherstellen
|
||||
- [ ] Optional nach MVP: pro Installation ein nicht exportierbares Keystore-Schlüsselpaar erzeugen und Refresh-Requests daran binden
|
||||
[ ] 13. Passkeys: Integration prüfen (FIDO2 / Passkeys) und Fallback auf Passwort
|
||||
[ ] 14. Image-Upload: Multipart-Upload + Coil für Anzeige + Bildkompression (u. a. Sharp-Äquivalent evtl. serverseitig)
|
||||
[ ] 15. Rich-Text: Anzeige von HTML (Compose + WebView) und ggf. Editor via WebView-bridge
|
||||
[ ] 16. Formulare: Validierung (clientseitig) und Fehlerdarstellung
|
||||
[ ] 17. Offline & Caching: Room für persistente Daten, Response-Caching, Sync-Strategie
|
||||
[ ] 18. Lokalisierung: `strings.xml` (DE + EN) und i18n-Check
|
||||
[ ] 19. Accessibility: ContentDescription, Focus, Farben/Kontrast prüfen
|
||||
[ ] 20. Tests: Unit-Tests für ViewModels + UI-Tests mit Compose Testing
|
||||
[ ] 21. Performance: Bildoptimierung, LazyLists, Paging (falls große Daten)
|
||||
[ ] 22. Analytics: Firebase / Matomo Integration (je nach Datenschutz)
|
||||
[ ] 23. Crash-Reporting: Sentry / Crashlytics integrieren
|
||||
[ ] 24. Build/Release: App signing, Release-Notes, Play-Store-Metadaten vorbereiten
|
||||
[ ] 25. Dokumentation: `README-android.md` mit Setup, Architektur und Release-Anleitung
|
||||
|
||||
5) Kurzzeit-MVP (Priorität für erste Version)
|
||||
- [x] A. Auth (Login/Logout)
|
||||
- [x] Passwort-Login und Logout in der aktuellen App-Sitzung
|
||||
- [x] Persistente Statuswiederherstellung/Logout für die Auth-Endpunkte
|
||||
- [ ] Dauerhaftes Eingeloggtbleiben durch rotierendes Refresh-Token pro Android-Gerätesitzung
|
||||
- [x] B. Home, Termine, Spielplan, Galerie (anzeigen)
|
||||
- [x] C. Kontaktformular (absenden)
|
||||
- [ ] D. Bildanzeige + Caching
|
||||
- [x] E. Theme & Fonts
|
||||
|
||||
6) Nächste Aktionen (sofort)
|
||||
- Dauerhaftes Android-Login umsetzen: Backend-Refresh-Sitzungen, Token-Rotation, serverseitigen Widerruf und App-Refresh-Flow ergänzen.
|
||||
- Passkey-Anmeldung über Android Credential Manager anbinden.
|
||||
- Die noch fehlenden öffentlichen Routen aus `10a` und die Newsletter-Screens aus `10b` nativ portieren.
|
||||
- Saisonwahl für Mannschaftsübersicht/-details wie in der Web-UI ergänzen.
|
||||
- Mitgliederbereich/CMS-Screens portieren; dabei rollenabhängige `Intern`-Navigation und Bearer-Unterstützung der verwendeten Backend-Endpunkte ergänzen.
|
||||
|
||||
7) Umsetzungsprotokoll
|
||||
- 2026-05-27: Webnahe Startseite mit öffentlichen Live-Daten umgesetzt; Hilt/Moshi-App-Verdrahtung ergänzt.
|
||||
- 2026-05-27: `Termine` und `Spielplan` als native Screens umgesetzt; Spielplan unterstützt Saison, Wettbewerb, Mannschaft, Ergebnis und zweizeilige Gruppeninformation.
|
||||
- 2026-05-27: `Mitgliedschaft` mit Antrag/PDF-Abruf sowie Passwort-`Login`/`Logout` umgesetzt; offene Auth-Härtung separat ausgewiesen.
|
||||
- 2026-05-27: Tokens verschlüsselt persistiert; Session-Wiederherstellung sowie Logout per Bearer-Token in den Auth-Endpunkten ermöglicht.
|
||||
- 2026-05-27: Registrierung und Passwort-Reset an die vorhandenen Auth-Endpunkte angebunden.
|
||||
- 2026-05-27: Product-Flavors `local`, `instantTest` und `production` eingerichtet; lokale Basis-URL ist per Gradle-Parameter überschreibbar.
|
||||
- 2026-05-27: Gradle-Heap/Worker für Flavor-Builds festgelegt, nachdem paralleles D8/KSP mit dem 512-MiB-Standardheap nicht ausreichend Speicher hatte.
|
||||
- 2026-05-27: Lokales Testsetup gegen Emulator geprüft; bei IPv6-gebundenem Nuxt-Dev-Server wird die von Nuxt ausgegebene Network-URL per `LOCAL_API_BASE_URL` verwendet.
|
||||
- 2026-05-27: Adaptive Navigation umgesetzt; Tablet-Layouts ab `600dp` zeigen Header und Hauptmenue dauerhaft, Smartphone-Layouts behalten die vorhandene Navigation.
|
||||
- 2026-05-27: Platzhalterlogo in der Android-Navigation durch das vorhandene Harheimer-TC-Weblogo als skalierte lokale PNG-Ressource ersetzt.
|
||||
- 2026-05-27: Web-Navigation und `pages/` vollständig inventarisiert; Tablet-Haupt-/Subnavigation für die öffentlichen Bereiche strukturell angeglichen und alle fehlenden Screens einzeln in die Portierungsliste aufgenommen.
|
||||
- 2026-05-27: Tablet-Header auf Web-Verhalten angepasst (Bereichswechsel öffnet Startseite und Submenü) und die native ActionBar zugunsten des App-Headers entfernt.
|
||||
- 2026-05-27: Navigation mit Live-Mannschaftslinks, öffentlicher Galerie-Sichtbarkeit und rollenabhängigem `Intern` ergänzt; Mannschaftsübersicht/-detail sowie Training, Trainer und Anfänger nativ portiert.
|
||||
- 2026-05-27: Mannschaftsdetail um die Web-Untertabs `Matches` und `Tabelle` erweitert; Tabellenzeilen werden aus `/api/spielplan/table` geladen und die eigene Mannschaft hervorgehoben.
|
||||
- 2026-05-27: Tabellenraster in den Mannschaftsdetails mit gemeinsamen Spaltenbreiten für Tablet und Smartphone ausgerichtet; die Zustandswiederherstellung dynamischer Mannschaftslinks korrigiert.
|
||||
- 2026-05-27: Die verbleibenden öffentlichen Screens aus Punkt 10 portiert: Verein/CMS-Inhalte, Vorstand, Satzung/PDF, Links, Vereinsmeisterschaften mit Personenbild-Dialog, Spielsysteme und TT-Regeln.
|
||||
- 2026-05-27: Architektur für dauerhaftes Android-Login festgelegt: kein eingebetteter App-Key, sondern kurzlebige Access-Tokens und rotierende, widerrufbare Refresh-Tokens pro Gerätesitzung; optionale spätere Gerätebindung per Keystore-Schlüsselpaar.
|
||||
|
||||
8) Android-Testumgebungen
|
||||
- Lokal im Emulator: `./gradlew :app:installLocalDebug` verwendet `http://10.0.2.2:3100/` und die App-ID `de.harheimertc.local`.
|
||||
- Lokal, wenn `10.0.2.2` nicht erreichbar ist: `./gradlew :app:installLocalDebug -PLOCAL_API_BASE_URL=http://<NUXT-NETWORK-HOST>:3100/`; die passende URL steht in der `npm run dev`-Ausgabe (hier `http://torstens:3100/`).
|
||||
- Test-Instanz: `./gradlew :app:installInstantTestDebug` verwendet `https://harheimertc.tsschulz.de/` und die App-ID `de.harheimertc.test`.
|
||||
- Produktion: `./gradlew :app:installProductionDebug` verwendet `https://harheimertc.de/` und die App-ID `de.harheimertc`.
|
||||
- Nur APKs erzeugen: `./gradlew :app:assembleLocalDebug :app:assembleInstantTestDebug :app:assembleProductionDebug`.
|
||||
|
||||
9) Dauerhaftes Android-Login: Architektur und Umsetzung
|
||||
- Ausgangslage: Das Backend gibt derzeit ein sieben Tage gültiges JWT aus. Die App speichert es bereits verschlüsselt und sendet es als Bearer-Token. Die vorhandene serverseitige Sessiondatei wird beim Authentifizieren geschützter Requests derzeit nicht zur Widerrufsprüfung herangezogen.
|
||||
- Ziel: Ein Benutzer bleibt auf einem bekannten Gerät angemeldet, ohne dass ein langfristig gültiges Bearer-JWT oder ein extrahierbares App-Secret verwendet wird.
|
||||
- Token-Modell:
|
||||
- Access-Token: JWT mit kurzer Laufzeit, Zielwert ca. 15 Minuten; wird für normale API-Requests als Bearer-Token verwendet.
|
||||
- Refresh-Token: kryptografisch zufälliges, undurchsichtiges Token mit längerer Laufzeit, Zielwert z. B. 90 Tage mit Erneuerung bei aktiver Nutzung.
|
||||
- Server speichert ausschließlich den Hash des Refresh-Tokens zusammen mit `sessionId`, `userId`, `createdAt`, `lastUsedAt`, `expiresAt`, `revokedAt` und optional Gerätebezeichnung.
|
||||
- Backend-Arbeitspaket:
|
||||
- Login-Antwort um `accessToken`, `refreshToken`, `sessionId` und Ablaufmetadaten erweitern; bestehendes `token` nur befristet kompatibel halten.
|
||||
- `POST /api/auth/refresh` implementieren: gültiges Refresh-Token konsumieren, rotieren und ein neues Token-Paar zurückgeben.
|
||||
- Token-Wiederverwendung erkennen: Wird ein rotiertes Refresh-Token erneut präsentiert, die betroffene Token-Familie bzw. Gerätesitzung widerrufen.
|
||||
- `POST /api/auth/logout` auf Widerruf der Gerätesitzung erweitern; optional Endpunkte zum Anzeigen und Widerrufen eigener Geräte-Sitzungen vorsehen.
|
||||
- Kontodeaktivierung und Passwortänderung müssen sämtliche Refresh-Sitzungen des Benutzers widerrufen.
|
||||
- Rate-Limits und Audit-Events für Login, Refresh-Erfolg/-Fehlschlag, Wiederverwendung und Widerruf ergänzen.
|
||||
- Android-Arbeitspaket:
|
||||
- `AuthRepository` auf Access-Token, Refresh-Token und Session-ID erweitern; Speicherung weiter über Keystore-geschützte Preferences.
|
||||
- `ApiService`/DTOs um Refresh-Request und Token-Paar-Antwort ergänzen.
|
||||
- Einen OkHttp-`Authenticator` einsetzen, der auf `401` einmalig ein Access-Token erneuert, parallele Refreshes synchronisiert und den ursprünglichen Request wiederholt.
|
||||
- Beim App-Start zunächst Access-Token prüfen und bei Ablauf transparent mit dem Refresh-Token erneuern; nur bei fehlgeschlagenem Refresh zum Login zurückkehren.
|
||||
- Beim Logout lokale Tokens auch bei Netzwerkfehler entfernen; serverseitiger Widerruf erfolgt best effort bzw. bei nächster Konnektivität.
|
||||
- Sicherheitsregeln:
|
||||
- Kein gemeinsamer App-Key und kein statisches Client-Secret in Sourcecode, `BuildConfig` oder APK.
|
||||
- Refresh-Tokens nie im Klartext serverseitig speichern oder protokollieren.
|
||||
- Nur HTTPS für Test-/Produktionsumgebungen; Token-Werte nicht in Logging-Interceptors ausgeben.
|
||||
- Optional nach MVP: App erzeugt pro Installation ein Keystore-Schlüsselpaar; Backend bindet Refresh-Sitzungen an den öffentlichen Schlüssel und prüft signierte Refresh-Anfragen.
|
||||
|
||||
---
|
||||
Datei: [ANDROID_KOTLIN_PLAN.md](ANDROID_KOTLIN_PLAN.md)
|
||||
84
ANDROID_PORT_TODO.md
Normal file
84
ANDROID_PORT_TODO.md
Normal file
@@ -0,0 +1,84 @@
|
||||
ANDROID App - 1:1 Portierung der Web-UI (TODO)
|
||||
|
||||
Ziel: Die Web-UI des Projekts 1:1 in eine native (oder cross-platform) Android-App überführen, inklusive Farben, Designsystem und aller Funktionalitäten.
|
||||
|
||||
1) Analyse Codebasis & Assets
|
||||
- Analysiere `package.json`, `nuxt.config.js`, `tailwind.config.js` und zentrale Server-/API-Endpunkte.
|
||||
- Liste alle verwendeten Farben, CSS-Variablen, Tailwind-Konfigurationen.
|
||||
- Sammle alle statischen Assets: Bilder, Icons, SVGs, Fonts, PDF-Dokumente.
|
||||
- Identifiziere dynamische Komponenten: Formulare, Rich-Text-Editor, Uploads, Kalender, Navigation.
|
||||
|
||||
2) Projektziele und Scope
|
||||
- Entscheide: Native Android (Kotlin/Jetpack Compose) oder Cross-Platform (React Native, Flutter, Kotlin Multiplatform).
|
||||
- Priorisiere Features für MVP vs. Post-Launch.
|
||||
|
||||
3) Designsystem und Farben extrahieren
|
||||
- Extrahiere Farbpalette, Typografie, Abstände, Buttons, Karten, Form-Controls.
|
||||
- Erstelle eine Design-Token-Liste (Hex/RGBA, Namen, Einsatzbereiche).
|
||||
|
||||
4) Technologie-Stack wählen
|
||||
- Empfohlene Optionen: Kotlin + Jetpack Compose (native), Flutter (UI-First), React Native (Wiederverwendung von JS/nuxt-Logik).
|
||||
- Bibliotheken: Navigation, HTTP-Client, Bild-Handling, Auth (WebAuthn falls nötig), Local DB.
|
||||
|
||||
5) Android-Projekt aufsetzen
|
||||
- Erstelle Projekt-Scaffold, CI-Build, Signing-Config.
|
||||
|
||||
6) Theme & Farben implementieren
|
||||
- Implementiere App-Theme mit Farben/Typografie-Token.
|
||||
|
||||
7) Navigation-Struktur implementieren
|
||||
- Bottom/Navigations-Drawer/Stack wie Web-Navigation abbilden.
|
||||
|
||||
8) Screens für Seiten anlegen
|
||||
- Erstelle Screens für: Startseite, Termine, Spielplan, Galerie, Kontakt, News, Mitgliedschaft, Login, CMS-Bereiche.
|
||||
|
||||
9) UI-Komponenten portieren
|
||||
- Navbar, Footer, Cards, Image-Grid, Modal/Dialog, Rich-Text-Viewer/Editor, Date-Picker, Tabellen.
|
||||
|
||||
10) Formulare & Validierung implementieren
|
||||
- Registrieren, Login, Passwort vergessen, Mitgliedschaftsformulare mit Client- und Server-Validierung.
|
||||
|
||||
11) Authentifizierungs-Flow implementieren
|
||||
- JWT / Session, OAuth oder WebAuthn falls benötigt; Token-Handling sicher speichern.
|
||||
|
||||
12) API-Client implementieren
|
||||
- Einheitlicher HTTP-Client, Error-Handling, Retry-Strategien, Pagination.
|
||||
|
||||
13) Bilderupload & Storage einrichten
|
||||
- Multi-part Upload, Progress, Bildkompression, lokale Cache-Strategie.
|
||||
|
||||
14) Offline-Support und Caching
|
||||
- Caching von API-Responses, Bild-Caching, Sync-Strategie für Formulare.
|
||||
|
||||
15) Lokalisierung und Texte prüfen
|
||||
- Alle statischen Texte extrahieren, deutsche Strings prüfen und in Resource-Files ablegen.
|
||||
|
||||
16) Accessibility-Prüfung und Anpassungen
|
||||
- Farbkontrast, Touch-Targets, Screenreader-Labels.
|
||||
|
||||
17) Unit- und UI-Tests schreiben
|
||||
- Komponenten- und Integrations-Tests, E2E (falls möglich).
|
||||
|
||||
18) Performance-Optimierung durchführen
|
||||
- Bilder, Netzwerk, Render-Perf.
|
||||
|
||||
19) CI/CD für Builds einrichten
|
||||
- GitHub Actions / GitLab CI: Build, Test, Lint, Release.
|
||||
|
||||
20) Play Store Release vorbereiten
|
||||
- App-Icons, Screenshots, Privacy-Policy, Datensparsamkeit.
|
||||
|
||||
21) Monitoring & Crash-Reporting einrichten
|
||||
- Sentry / Firebase Crashlytics, Analytics.
|
||||
|
||||
22) Dokumentation: Setup & Architektur
|
||||
- README, Architekturdiagramm, API-Spec, Onboarding-Guide.
|
||||
|
||||
23) Design Review und Abnahme
|
||||
- UX/Design-Review mit Stakeholdern.
|
||||
|
||||
24) Launch und Feedbackrunde durchführen
|
||||
- Release-Notes, Feedback-Formular, Bug-Fixing-Plan.
|
||||
|
||||
|
||||
Datei erstellt: Bitte bestätige, wenn ich mit der in-depth Analyse der Codebasis und Assets beginnen soll (Suche nach Farben, verwendeten Komponenten, Images, Fonts, relevanten Scripts).
|
||||
58
ANDROID_REPO_ENDPOINTS.md
Normal file
58
ANDROID_REPO_ENDPOINTS.md
Normal file
@@ -0,0 +1,58 @@
|
||||
Repo API Endpoints — Übersicht
|
||||
|
||||
Hinweis: Viele Frontend-Requests verwenden relative Pfade (`/api/...`) und Nuxt's `NUXT_PUBLIC_BASE_URL`.
|
||||
|
||||
Öffentliche/Frontend-Endpunkte (häufig genutzt):
|
||||
- GET /api/config
|
||||
- GET /api/news-public
|
||||
- GET /api/news
|
||||
- GET /api/termine
|
||||
- GET /api/spielplaene
|
||||
- GET /api/spielplan
|
||||
- GET /api/mannschaften
|
||||
- GET /api/galerie
|
||||
- GET /api/media/galerie/{id}
|
||||
- GET /api/personen/{filename}?width=...&height=...
|
||||
- POST /api/contact
|
||||
- POST /api/news (CMS)
|
||||
|
||||
Galerie / Media:
|
||||
- POST /api/galerie/upload
|
||||
- GET /api/galerie/list
|
||||
- GET /api/galerie/[id]
|
||||
- DELETE /api/galerie/[id]
|
||||
|
||||
Authentifizierung:
|
||||
- POST /api/auth/login
|
||||
- POST /api/auth/logout
|
||||
- POST /api/auth/register
|
||||
- POST /api/auth/reset-password
|
||||
- GET /api/auth/status
|
||||
- POST /api/auth/passkeys/authentication-options (Passkeys start: server returns WebAuthn options)
|
||||
- POST /api/auth/passkeys/login (Passkeys finish: credential verification)
|
||||
|
||||
CMS / geschützte Endpunkte (erfordern Auth):
|
||||
- GET /api/cms/* (z.B. /api/cms/users/list, /api/cms/contact-requests)
|
||||
- POST /api/cms/save-csv
|
||||
- POST /api/cms/upload-spielplan-pdf
|
||||
- POST /api/cms/satzung-upload
|
||||
- POST /api/members, DELETE /api/members, POST /api/members/bulk
|
||||
- POST /api/membership/update-status
|
||||
- POST /api/termine-manage, DELETE /api/termine-manage, GET /api/termine-manage
|
||||
|
||||
Weitere (Datei-Uploads, Personen):
|
||||
- POST /api/personen/upload
|
||||
- GET /api/app/version
|
||||
- Various CMS-specific routes under /api/cms
|
||||
|
||||
Auth-Anforderungen & Hinweise:
|
||||
- Frontend nutzt `$fetch('/api/...')` (Nuxt) — serverseitig vermutlich Session-Cookie oder JWT.
|
||||
- `stores/auth.js` verwendet `/api/auth/status` to check login state and `passkeyLogin()` which calls `/api/auth/passkeys/*`.
|
||||
- Passkeys-Flow verwendet `@simplewebauthn/browser` on web; Android port should support FIDO2 / Passkeys (Google Passkeys API) or provide password fallback.
|
||||
- CMS- und Manage-Endpunkte require authentication and role checks (admin/vorstand etc.).
|
||||
|
||||
Empfehlung für Android-Client:
|
||||
- Nutze Retrofit/OkHttp mit anpassbarem Auth-Interceptor (Cookie-jar or token storage). Prüfe, ob Server bevorzugt Cookies (then use CookieJar) or JWT Authorization header.
|
||||
- Implementiere Passkeys via Android FIDO2 / Passkeys APIs as optional fast-login path; for servers expecting WebAuthn payloads adapt encoding accordingly.
|
||||
|
||||
Datei automatisch erzeugt — wenn du möchtest, kann ich nun alle Dateien in `public/` und `assets/` auflisten und exportieren (Bilder, Fonts, PDFs).
|
||||
112
android-app/app/build.gradle.kts
Normal file
112
android-app/app/build.gradle.kts
Normal file
@@ -0,0 +1,112 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("com.google.devtools.ksp")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("com.google.dagger.hilt.android")
|
||||
}
|
||||
|
||||
val localApiBaseUrl = providers.gradleProperty("LOCAL_API_BASE_URL")
|
||||
.orElse("http://10.0.2.2:3100/")
|
||||
.get()
|
||||
|
||||
android {
|
||||
namespace = "de.harheimertc"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "de.harheimertc"
|
||||
minSdk = 24
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
}
|
||||
|
||||
flavorDimensions += "environment"
|
||||
productFlavors {
|
||||
create("local") {
|
||||
dimension = "environment"
|
||||
applicationIdSuffix = ".local"
|
||||
versionNameSuffix = "-local"
|
||||
buildConfigField("String", "API_BASE_URL", "\"$localApiBaseUrl\"")
|
||||
buildConfigField("String", "ENVIRONMENT_NAME", "\"LOCAL\"")
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
||||
}
|
||||
create("instantTest") {
|
||||
dimension = "environment"
|
||||
applicationIdSuffix = ".test"
|
||||
versionNameSuffix = "-test"
|
||||
buildConfigField("String", "API_BASE_URL", "\"https://harheimertc.tsschulz.de/\"")
|
||||
buildConfigField("String", "ENVIRONMENT_NAME", "\"TEST\"")
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||
}
|
||||
create("production") {
|
||||
dimension = "environment"
|
||||
buildConfigField("String", "API_BASE_URL", "\"https://harheimertc.de/\"")
|
||||
buildConfigField("String", "ENVIRONMENT_NAME", "\"\"")
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
|
||||
implementation("androidx.core:core-ktx:1.10.1")
|
||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||
|
||||
// Compose
|
||||
implementation("androidx.compose.ui:ui:1.5.0")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview:1.5.0")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling:1.5.0")
|
||||
implementation("androidx.compose.material3:material3:1.1.0")
|
||||
implementation("androidx.navigation:navigation-compose:2.6.0")
|
||||
implementation("androidx.hilt:hilt-navigation-compose:1.0.0")
|
||||
|
||||
// Lifecycle
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1")
|
||||
|
||||
// Hilt
|
||||
implementation("com.google.dagger:hilt-android:2.59.2")
|
||||
ksp("com.google.dagger:hilt-compiler:2.59.2")
|
||||
|
||||
// Networking
|
||||
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.11.0")
|
||||
implementation("com.squareup.okhttp3:logging-interceptor:4.11.0")
|
||||
implementation("com.squareup.okhttp3:okhttp-urlconnection:4.11.0")
|
||||
implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
|
||||
implementation("com.squareup.moshi:moshi-kotlin:1.15.1")
|
||||
|
||||
// Coil
|
||||
implementation("io.coil-kt:coil-compose:2.4.0")
|
||||
|
||||
// Room
|
||||
implementation("androidx.room:room-runtime:2.6.1")
|
||||
ksp("androidx.room:room-compiler:2.6.1")
|
||||
implementation("androidx.room:room-ktx:2.6.1")
|
||||
|
||||
// WorkManager, DataStore
|
||||
implementation("androidx.work:work-runtime-ktx:2.8.1")
|
||||
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
|
||||
// Testing (skeleton)
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
}
|
||||
28
android-app/app/src/main/AndroidManifest.xml
Normal file
28
android-app/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,28 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<application
|
||||
android:name=".HarheimerApplication"
|
||||
android:label="HarheimerTC"
|
||||
android:theme="@style/Theme.HarheimerTC"
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||
<activity android:name="de.harheimertc.MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.files"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.harheimertc
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class HarheimerApplication : Application()
|
||||
35
android-app/app/src/main/java/de/harheimertc/MainActivity.kt
Normal file
35
android-app/app/src/main/java/de/harheimertc/MainActivity.kt
Normal file
@@ -0,0 +1,35 @@
|
||||
package de.harheimertc
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.harheimertc.ui.navigation.NavGraph
|
||||
import de.harheimertc.ui.theme.HarheimerTheme
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
App()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun App() {
|
||||
HarheimerTheme {
|
||||
val navController = rememberNavController()
|
||||
NavGraph(navController = navController)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewMain() {
|
||||
App()
|
||||
}
|
||||
269
android-app/app/src/main/java/de/harheimertc/data/ApiService.kt
Normal file
269
android-app/app/src/main/java/de/harheimertc/data/ApiService.kt
Normal file
@@ -0,0 +1,269 @@
|
||||
package de.harheimertc.data
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Query
|
||||
import retrofit2.http.Url
|
||||
import retrofit2.http.Streaming
|
||||
import okhttp3.ResponseBody
|
||||
|
||||
data class ContactRequest(val name: String, val email: String, val message: String)
|
||||
data class ContactResponse(val ok: Boolean, val id: String? = null)
|
||||
data class TermineResponse(val success: Boolean = true, val termine: List<TerminDto> = emptyList())
|
||||
data class TerminDto(
|
||||
val datum: String = "",
|
||||
val uhrzeit: String? = null,
|
||||
val titel: String = "",
|
||||
val beschreibung: String? = null,
|
||||
val kategorie: String? = null,
|
||||
)
|
||||
data class SpielplanResponse(
|
||||
val success: Boolean = false,
|
||||
val message: String? = null,
|
||||
val data: List<SpielDto> = emptyList(),
|
||||
val headers: List<String> = emptyList(),
|
||||
val season: String? = null,
|
||||
val seasons: List<SeasonDto> = emptyList(),
|
||||
)
|
||||
data class SeasonDto(val slug: String = "", val label: String = "")
|
||||
data class SpielDto(
|
||||
@param:Json(name = "Termin") val termin: String = "",
|
||||
@param:Json(name = "HeimMannschaft") val heimMannschaft: String = "",
|
||||
@param:Json(name = "GastMannschaft") val gastMannschaft: String = "",
|
||||
@param:Json(name = "HeimMannschaftAltersklasse") val heimAltersklasse: String = "",
|
||||
@param:Json(name = "GastMannschaftAltersklasse") val gastAltersklasse: String = "",
|
||||
@param:Json(name = "Altersklasse") val altersklasse: String = "",
|
||||
@param:Json(name = "Liga") val liga: String = "",
|
||||
@param:Json(name = "Staffel") val staffel: String = "",
|
||||
@param:Json(name = "Runde") val runde: String? = null,
|
||||
@param:Json(name = "SpieleHeim") val spieleHeim: String = "",
|
||||
@param:Json(name = "SpieleGast") val spieleGast: String = "",
|
||||
)
|
||||
data class TeamTableResponse(
|
||||
val success: Boolean = false,
|
||||
val message: String? = null,
|
||||
val season: String? = null,
|
||||
val table: TeamTableDto? = null,
|
||||
)
|
||||
data class TeamTableDto(
|
||||
val teamName: String = "",
|
||||
val leagueName: String = "",
|
||||
val table: LeagueTableDto? = null,
|
||||
)
|
||||
data class LeagueTableDto(
|
||||
val leagueTable: List<LeagueTableRowDto> = emptyList(),
|
||||
)
|
||||
data class LeagueTableRowDto(
|
||||
@param:Json(name = "table_rank") val rank: Int? = null,
|
||||
@param:Json(name = "team_name") val teamName: String = "",
|
||||
@param:Json(name = "meetings_count") val meetings: Int? = null,
|
||||
@param:Json(name = "meetings_won") val won: Int? = null,
|
||||
@param:Json(name = "meetings_tie") val tied: Int? = null,
|
||||
@param:Json(name = "meetings_lost") val lost: Int? = null,
|
||||
@param:Json(name = "sets_won") val setsWon: Int? = null,
|
||||
@param:Json(name = "sets_lost") val setsLost: Int? = null,
|
||||
@param:Json(name = "games_won") val gamesWon: Int? = null,
|
||||
@param:Json(name = "games_lost") val gamesLost: Int? = null,
|
||||
@param:Json(name = "points_won") val pointsWon: Int? = null,
|
||||
@param:Json(name = "points_lost") val pointsLost: Int? = null,
|
||||
@param:Json(name = "rise_fall_state") val movement: String? = null,
|
||||
)
|
||||
data class NewsPublicResponse(val news: List<NewsDto> = emptyList())
|
||||
data class NewsDto(
|
||||
val id: Int? = null,
|
||||
val title: String = "",
|
||||
val content: String = "",
|
||||
val created: String? = null,
|
||||
)
|
||||
data class PublicGalleryImageDto(
|
||||
val filename: String = "",
|
||||
val title: String = "",
|
||||
)
|
||||
data class MembershipRequest(
|
||||
val vorname: String,
|
||||
val nachname: String,
|
||||
val strasse: String,
|
||||
val plz: String,
|
||||
val ort: String,
|
||||
val geburtsdatum: String,
|
||||
val email: String,
|
||||
val telefon_privat: String? = null,
|
||||
val telefon_mobil: String? = null,
|
||||
val mitgliedschaftsart: String,
|
||||
val lastschrift_erlaubt: Boolean,
|
||||
val kontoinhaber: String,
|
||||
val iban: String,
|
||||
val bic: String? = null,
|
||||
val bank: String? = null,
|
||||
val datenschutz_einverstanden: Boolean,
|
||||
val satzung_anerkannt: Boolean,
|
||||
)
|
||||
data class MembershipResponse(
|
||||
val success: Boolean = false,
|
||||
val message: String? = null,
|
||||
val downloadUrl: String? = null,
|
||||
)
|
||||
data class LoginRequest(val email: String, val password: String)
|
||||
data class AuthUserDto(
|
||||
val id: String? = null,
|
||||
val email: String = "",
|
||||
val name: String? = null,
|
||||
val roles: List<String> = emptyList(),
|
||||
)
|
||||
data class LoginResponse(
|
||||
val success: Boolean = false,
|
||||
val token: String? = null,
|
||||
val user: AuthUserDto? = null,
|
||||
val role: String? = null,
|
||||
)
|
||||
data class AuthStatusResponse(
|
||||
val isLoggedIn: Boolean = false,
|
||||
val user: AuthUserDto? = null,
|
||||
val roles: List<String> = emptyList(),
|
||||
val role: String? = null,
|
||||
)
|
||||
data class ResetPasswordRequest(val email: String)
|
||||
data class AuthMessageResponse(val success: Boolean = false, val message: String? = null)
|
||||
data class RegistrationVisibility(val showBirthday: Boolean)
|
||||
data class RegistrationRequest(
|
||||
val name: String,
|
||||
val email: String,
|
||||
val phone: String? = null,
|
||||
val password: String,
|
||||
val geburtsdatum: String,
|
||||
val visibility: RegistrationVisibility,
|
||||
)
|
||||
data class TrainingLocationDto(
|
||||
val name: String = "",
|
||||
val strasse: String = "",
|
||||
val plz: String = "",
|
||||
val ort: String = "",
|
||||
)
|
||||
data class TrainingTimeDto(
|
||||
val id: String = "",
|
||||
val tag: String = "",
|
||||
val von: String = "",
|
||||
val bis: String = "",
|
||||
val gruppe: String = "",
|
||||
val info: String? = null,
|
||||
)
|
||||
data class TrainingDto(
|
||||
val ort: TrainingLocationDto = TrainingLocationDto(),
|
||||
val zeiten: List<TrainingTimeDto> = emptyList(),
|
||||
)
|
||||
data class TrainerDto(
|
||||
val id: String = "",
|
||||
val name: String = "",
|
||||
val lizenz: String = "",
|
||||
val schwerpunkt: String = "",
|
||||
val zusatz: String? = null,
|
||||
val imageFilename: String? = null,
|
||||
)
|
||||
data class BoardMemberDto(
|
||||
val vorname: String = "",
|
||||
val nachname: String = "",
|
||||
val strasse: String = "",
|
||||
val plz: String = "",
|
||||
val ort: String = "",
|
||||
val telefon: String = "",
|
||||
val email: String = "",
|
||||
val imageFilename: String? = null,
|
||||
)
|
||||
data class VorstandDto(
|
||||
val vorsitzender: BoardMemberDto = BoardMemberDto(),
|
||||
val stellvertreter: BoardMemberDto = BoardMemberDto(),
|
||||
val kassenwart: BoardMemberDto = BoardMemberDto(),
|
||||
val schriftfuehrer: BoardMemberDto = BoardMemberDto(),
|
||||
val sportwart: BoardMemberDto = BoardMemberDto(),
|
||||
val jugendwart: BoardMemberDto = BoardMemberDto(),
|
||||
)
|
||||
data class SatzungDto(
|
||||
val pdfUrl: String = "",
|
||||
val content: String = "",
|
||||
)
|
||||
data class LinkItemDto(
|
||||
val label: String = "",
|
||||
val href: String = "",
|
||||
val description: String = "",
|
||||
)
|
||||
data class LinkSectionDto(
|
||||
val title: String = "",
|
||||
val items: List<LinkItemDto> = emptyList(),
|
||||
)
|
||||
data class SeitenDto(
|
||||
val ueberUns: String = "",
|
||||
val geschichte: String = "",
|
||||
val ttRegeln: String = "",
|
||||
val satzung: SatzungDto = SatzungDto(),
|
||||
val links: String = "",
|
||||
val linksStructured: List<LinkSectionDto> = emptyList(),
|
||||
)
|
||||
data class ConfigResponse(
|
||||
val training: TrainingDto = TrainingDto(),
|
||||
val trainer: List<TrainerDto> = emptyList(),
|
||||
val vorstand: VorstandDto = VorstandDto(),
|
||||
val seiten: SeitenDto = SeitenDto(),
|
||||
)
|
||||
|
||||
interface ApiService {
|
||||
@POST("/api/contact")
|
||||
suspend fun postContact(@Body req: ContactRequest): Response<ContactResponse>
|
||||
|
||||
@GET("/api/galerie/list")
|
||||
suspend fun galerieList(): Response<List<String>>
|
||||
|
||||
@GET("/api/galerie")
|
||||
suspend fun publicGalleryImages(): Response<List<PublicGalleryImageDto>>
|
||||
|
||||
@GET("/api/termine")
|
||||
suspend fun termine(): Response<TermineResponse>
|
||||
|
||||
@GET("/api/spielplan")
|
||||
suspend fun spielplan(@Query("season") season: String? = null): Response<SpielplanResponse>
|
||||
|
||||
@GET("/api/spielplan/table")
|
||||
suspend fun spielplanTable(
|
||||
@Query("team") team: String,
|
||||
@Query("season") season: String? = null,
|
||||
): Response<TeamTableResponse>
|
||||
|
||||
@GET("/api/news-public")
|
||||
suspend fun publicNews(): Response<NewsPublicResponse>
|
||||
|
||||
@GET("/api/mannschaften")
|
||||
suspend fun mannschaften(@Query("season") season: String? = null): Response<ResponseBody>
|
||||
|
||||
@GET("/api/config")
|
||||
suspend fun config(): Response<ConfigResponse>
|
||||
|
||||
@GET("/data/spielsysteme.csv")
|
||||
suspend fun spielsysteme(): Response<ResponseBody>
|
||||
|
||||
@GET("/api/vereinsmeisterschaften")
|
||||
suspend fun vereinsmeisterschaften(): Response<ResponseBody>
|
||||
|
||||
@POST("/api/membership/generate-pdf")
|
||||
suspend fun generateMembershipPdf(@Body request: MembershipRequest): Response<MembershipResponse>
|
||||
|
||||
@Streaming
|
||||
@GET
|
||||
suspend fun downloadMembershipPdf(@Url downloadUrl: String): Response<ResponseBody>
|
||||
|
||||
@POST("/api/auth/login")
|
||||
suspend fun login(@Body request: LoginRequest): Response<LoginResponse>
|
||||
|
||||
@POST("/api/auth/logout")
|
||||
suspend fun logout(): Response<Unit>
|
||||
|
||||
@GET("/api/auth/status")
|
||||
suspend fun authStatus(): Response<AuthStatusResponse>
|
||||
|
||||
@POST("/api/auth/reset-password")
|
||||
suspend fun resetPassword(@Body request: ResetPasswordRequest): Response<AuthMessageResponse>
|
||||
|
||||
@POST("/api/auth/register")
|
||||
suspend fun register(@Body request: RegistrationRequest): Response<AuthMessageResponse>
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.harheimertc.data
|
||||
|
||||
import de.harheimertc.repositories.AuthRepository
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import javax.inject.Inject
|
||||
|
||||
class AuthInterceptor @Inject constructor(private val authRepository: AuthRepository) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val requestBuilder = chain.request().newBuilder()
|
||||
val token = authRepository.getToken()
|
||||
if (!token.isNullOrBlank()) {
|
||||
requestBuilder.addHeader("Authorization", "Bearer $token")
|
||||
}
|
||||
return chain.proceed(requestBuilder.build())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package de.harheimertc.data
|
||||
|
||||
import de.harheimertc.BuildConfig
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.JavaNetCookieJar
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import javax.inject.Singleton
|
||||
import java.net.CookieManager
|
||||
import java.net.CookiePolicy
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object NetworkModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideMoshi(): Moshi = Moshi.Builder()
|
||||
.addLast(KotlinJsonAdapterFactory())
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient {
|
||||
val logging = HttpLoggingInterceptor()
|
||||
logging.level = HttpLoggingInterceptor.Level.BASIC
|
||||
val cookies = CookieManager().apply {
|
||||
setCookiePolicy(CookiePolicy.ACCEPT_ALL)
|
||||
}
|
||||
return OkHttpClient.Builder()
|
||||
.cookieJar(JavaNetCookieJar(cookies))
|
||||
.addInterceptor(authInterceptor)
|
||||
.addInterceptor(logging)
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRetrofit(moshi: Moshi, client: OkHttpClient): Retrofit {
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(BuildConfig.API_BASE_URL)
|
||||
.client(client)
|
||||
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideApiService(retrofit: Retrofit): ApiService = retrofit.create(ApiService::class.java)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.harheimertc.di
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.harheimertc.repositories.AuthRepository
|
||||
import de.harheimertc.repositories.AuthRepositoryImpl
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class RepositoryModule {
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface AuthRepository {
|
||||
fun getToken(): String?
|
||||
fun setToken(token: String?)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AuthRepositoryImpl @Inject constructor(@param:ApplicationContext private val context: Context) : AuthRepository {
|
||||
private val tokenKey = "auth_token"
|
||||
private val preferences by lazy {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"harheimertc_auth",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
}
|
||||
|
||||
override fun getToken(): String? = preferences.getString(tokenKey, null)
|
||||
|
||||
override fun setToken(token: String?) {
|
||||
preferences.edit().apply {
|
||||
if (token == null) remove(tokenKey) else putString(tokenKey, token)
|
||||
}.apply()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.ContactRequest
|
||||
import de.harheimertc.data.ContactResponse
|
||||
import retrofit2.Response
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ContactRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun sendContact(req: ContactRequest): Response<ContactResponse> = api.postContact(req)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class GalleryRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun hasPublicImages(): Result<Boolean> = runCatching {
|
||||
val response = api.publicGalleryImages()
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
response.body().orEmpty().isNotEmpty()
|
||||
}
|
||||
|
||||
suspend fun fetchImages(): Result<List<String>> {
|
||||
return try {
|
||||
val resp = api.galerieList()
|
||||
if (resp.isSuccessful) {
|
||||
Result.success(resp.body() ?: emptyList())
|
||||
} else {
|
||||
Result.failure(Exception("HTTP ${resp.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.NewsDto
|
||||
import de.harheimertc.data.SpielDto
|
||||
import de.harheimertc.data.TerminDto
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
data class HomeData(
|
||||
val termine: List<TerminDto>,
|
||||
val spiele: List<SpielDto>,
|
||||
val news: List<NewsDto>,
|
||||
)
|
||||
|
||||
@Singleton
|
||||
class HomeRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchHomeData(): Result<HomeData> = runCatching {
|
||||
val termine = api.termine().body()?.termine.orEmpty()
|
||||
val spiele = api.spielplan().body()?.data.orEmpty()
|
||||
val news = api.publicNews().body()?.news.orEmpty()
|
||||
HomeData(termine, spiele, news)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.LoginRequest
|
||||
import de.harheimertc.data.LoginResponse
|
||||
import de.harheimertc.data.AuthStatusResponse
|
||||
import de.harheimertc.data.AuthMessageResponse
|
||||
import de.harheimertc.data.RegistrationRequest
|
||||
import de.harheimertc.data.ResetPasswordRequest
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class LoginRepository @Inject constructor(
|
||||
private val api: ApiService,
|
||||
private val authRepository: AuthRepository,
|
||||
) {
|
||||
suspend fun login(email: String, password: String): Result<LoginResponse> = runCatching {
|
||||
val response = api.login(LoginRequest(email.trim(), password))
|
||||
if (!response.isSuccessful) error("Anmeldung fehlgeschlagen. Bitte prüfen Sie Ihre Zugangsdaten.")
|
||||
val body = response.body() ?: error("Leere Antwort")
|
||||
val token = body.token?.takeIf(String::isNotBlank)
|
||||
?: error("Der Server hat kein Zugriffstoken geliefert.")
|
||||
authRepository.setToken(token)
|
||||
body
|
||||
}
|
||||
|
||||
suspend fun logout(): Result<Unit> = runCatching {
|
||||
try {
|
||||
api.logout()
|
||||
} finally {
|
||||
authRepository.setToken(null)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun status(): Result<AuthStatusResponse> = runCatching {
|
||||
if (authRepository.getToken().isNullOrBlank()) return@runCatching AuthStatusResponse()
|
||||
val response = api.authStatus()
|
||||
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
|
||||
val status = response.body() ?: AuthStatusResponse()
|
||||
if (!status.isLoggedIn) authRepository.setToken(null)
|
||||
status
|
||||
}
|
||||
|
||||
suspend fun resetPassword(email: String): Result<AuthMessageResponse> = runCatching {
|
||||
val response = api.resetPassword(ResetPasswordRequest(email.trim()))
|
||||
if (!response.isSuccessful) error("Anfrage konnte nicht gesendet werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun register(request: RegistrationRequest): Result<AuthMessageResponse> = runCatching {
|
||||
val response = api.register(request)
|
||||
if (!response.isSuccessful) error("Registrierung fehlgeschlagen.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
data class Mannschaft(
|
||||
val mannschaft: String,
|
||||
val liga: String,
|
||||
val staffelleiter: String,
|
||||
val telefon: String,
|
||||
val heimspieltag: String,
|
||||
val spielsystem: String,
|
||||
val mannschaftsfuehrer: String,
|
||||
val spieler: List<String>,
|
||||
val informationenLink: String,
|
||||
val letzteAktualisierung: String,
|
||||
) {
|
||||
val slug: String
|
||||
get() = mannschaft.lowercase(Locale.GERMANY).replace(Regex("\\s+"), "-")
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class MannschaftenRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchMannschaften(season: String? = null): Result<List<Mannschaft>> = runCatching {
|
||||
val response = api.mannschaften(season)
|
||||
if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.")
|
||||
parseCsv(response.body()?.string().orEmpty())
|
||||
}
|
||||
|
||||
private fun parseCsv(csv: String): List<Mannschaft> = csv.lineSequence()
|
||||
.filter(String::isNotBlank)
|
||||
.drop(1)
|
||||
.mapNotNull { row ->
|
||||
val fields = parseCsvRow(row)
|
||||
if (fields.size < 10 || fields[0].isBlank()) return@mapNotNull null
|
||||
Mannschaft(
|
||||
mannschaft = fields[0],
|
||||
liga = fields[1],
|
||||
staffelleiter = fields[2],
|
||||
telefon = fields[3],
|
||||
heimspieltag = fields[4],
|
||||
spielsystem = fields[5],
|
||||
mannschaftsfuehrer = fields[6],
|
||||
spieler = fields[7].split(';').map(String::trim).filter(String::isNotBlank),
|
||||
informationenLink = fields[8],
|
||||
letzteAktualisierung = fields[9],
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
|
||||
private fun parseCsvRow(row: String): List<String> {
|
||||
val values = mutableListOf<String>()
|
||||
val current = StringBuilder()
|
||||
var inQuotes = false
|
||||
var index = 0
|
||||
while (index < row.length) {
|
||||
val character = row[index]
|
||||
when {
|
||||
character == '"' && inQuotes && row.getOrNull(index + 1) == '"' -> {
|
||||
current.append('"')
|
||||
index++
|
||||
}
|
||||
character == '"' -> inQuotes = !inQuotes
|
||||
character == ',' && !inQuotes -> {
|
||||
values += current.toString().trim()
|
||||
current.clear()
|
||||
}
|
||||
else -> current.append(character)
|
||||
}
|
||||
index++
|
||||
}
|
||||
values += current.toString().trim()
|
||||
return values
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.FileProvider
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.MembershipRequest
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
data class MembershipDocument(val message: String, val uri: String)
|
||||
|
||||
@Singleton
|
||||
class MembershipRepository @Inject constructor(
|
||||
private val api: ApiService,
|
||||
@param:ApplicationContext private val context: Context,
|
||||
) {
|
||||
suspend fun submit(request: MembershipRequest): Result<MembershipDocument> = runCatching {
|
||||
val response = api.generateMembershipPdf(request)
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
val body = response.body() ?: error("Leere Antwort")
|
||||
if (!body.success) error(body.message ?: "Antrag konnte nicht erstellt werden.")
|
||||
val downloadUrl = body.downloadUrl ?: error("PDF-Download fehlt.")
|
||||
val documentResponse = api.downloadMembershipPdf(downloadUrl)
|
||||
if (!documentResponse.isSuccessful) error("PDF konnte nicht heruntergeladen werden.")
|
||||
val directory = File(context.cacheDir, "membership").apply { mkdirs() }
|
||||
val file = File(directory, "beitrittserklaerung.pdf")
|
||||
documentResponse.body()?.byteStream()?.use { input ->
|
||||
file.outputStream().use { output -> input.copyTo(output) }
|
||||
} ?: error("Leere PDF-Antwort")
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", file)
|
||||
MembershipDocument(
|
||||
message = body.message ?: "Beitrittsformular erfolgreich erstellt.",
|
||||
uri = uri.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.ConfigResponse
|
||||
import de.harheimertc.data.LinkItemDto
|
||||
import de.harheimertc.data.LinkSectionDto
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
data class Spielsystem(
|
||||
val name: String,
|
||||
val description: String,
|
||||
val teamSize: String,
|
||||
val category: String,
|
||||
val sequence: String,
|
||||
val gameCount: String,
|
||||
val features: String,
|
||||
)
|
||||
|
||||
data class MeisterschaftResult(
|
||||
val year: String,
|
||||
val category: String,
|
||||
val rank: String,
|
||||
val playerOne: String,
|
||||
val playerTwo: String,
|
||||
val note: String,
|
||||
val imageOne: String,
|
||||
val imageTwo: String,
|
||||
)
|
||||
|
||||
@Singleton
|
||||
class PublicPagesRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchConfig(): Result<ConfigResponse> = runCatching {
|
||||
val response = api.config()
|
||||
if (!response.isSuccessful) error("Inhalte konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun fetchSpielsysteme(): Result<List<Spielsystem>> = runCatching {
|
||||
val response = api.spielsysteme()
|
||||
if (!response.isSuccessful) error("Spielsysteme konnten nicht geladen werden.")
|
||||
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
|
||||
if (values.size < 8) return@mapNotNull null
|
||||
Spielsystem(
|
||||
name = values[0],
|
||||
description = values[1],
|
||||
teamSize = values[2],
|
||||
category = values[3],
|
||||
sequence = values[5],
|
||||
gameCount = values[6],
|
||||
features = values[7],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchVereinsmeisterschaften(): Result<List<MeisterschaftResult>> = runCatching {
|
||||
val response = api.vereinsmeisterschaften()
|
||||
if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht geladen werden.")
|
||||
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
|
||||
if (values.size < 6) return@mapNotNull null
|
||||
MeisterschaftResult(
|
||||
year = values[0],
|
||||
category = values[1],
|
||||
rank = values[2],
|
||||
playerOne = values[3],
|
||||
playerTwo = values[4],
|
||||
note = values[5],
|
||||
imageOne = values.getOrElse(6) { "" },
|
||||
imageTwo = values.getOrElse(7) { "" },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseCsv(csv: String): List<List<String>> =
|
||||
csv.lineSequence().filter(String::isNotBlank).map(::parseCsvLine).toList()
|
||||
|
||||
private fun parseCsvLine(line: String): List<String> {
|
||||
val values = mutableListOf<String>()
|
||||
val value = StringBuilder()
|
||||
var quoted = false
|
||||
var index = 0
|
||||
while (index < line.length) {
|
||||
when (val char = line[index]) {
|
||||
'"' -> {
|
||||
if (quoted && index + 1 < line.length && line[index + 1] == '"') {
|
||||
value.append('"')
|
||||
index++
|
||||
} else {
|
||||
quoted = !quoted
|
||||
}
|
||||
}
|
||||
',' -> if (quoted) value.append(char) else {
|
||||
values += value.toString().trim()
|
||||
value.clear()
|
||||
}
|
||||
else -> value.append(char)
|
||||
}
|
||||
index++
|
||||
}
|
||||
values += value.toString().trim()
|
||||
return values
|
||||
}
|
||||
|
||||
fun ConfigResponse.linkSections(): List<LinkSectionDto> =
|
||||
seiten.linksStructured.filter { it.title.isNotBlank() && it.items.isNotEmpty() }
|
||||
.ifEmpty {
|
||||
parseLinkSections(seiten.links).ifEmpty { defaultLinkSections }
|
||||
}
|
||||
|
||||
private fun parseLinkSections(html: String): List<LinkSectionDto> {
|
||||
if (html.isBlank()) return emptyList()
|
||||
val sectionRegex = Regex("""<h2[^>]*>(.*?)</h2>(.*?)(?=<h2[^>]*>|$)""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
|
||||
val itemRegex = Regex("""<li[^>]*>(.*?)</li>""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
|
||||
val anchorRegex = Regex("""<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)</a>""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
|
||||
return sectionRegex.findAll(html).mapNotNull { section ->
|
||||
val title = stripHtml(section.groupValues[1])
|
||||
val items = itemRegex.findAll(section.groupValues[2]).mapNotNull { item ->
|
||||
val match = anchorRegex.find(item.groupValues[1]) ?: return@mapNotNull null
|
||||
LinkItemDto(
|
||||
href = match.groupValues[1].trim(),
|
||||
label = stripHtml(match.groupValues[2]),
|
||||
description = stripHtml(item.groupValues[1].replace(match.value, "")),
|
||||
)
|
||||
}.toList()
|
||||
title.takeIf { it.isNotBlank() && items.isNotEmpty() }?.let { LinkSectionDto(it, items) }
|
||||
}.toList()
|
||||
}
|
||||
|
||||
private fun stripHtml(html: String): String = html
|
||||
.replace(Regex("<[^>]*>"), "")
|
||||
.replace("&", "&")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace(" ", " ")
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.trim()
|
||||
|
||||
private val defaultLinkSections = listOf(
|
||||
LinkSectionDto("Ergebnisse & Portale", listOf(
|
||||
LinkItemDto("MyTischtennis.de", "http://www.mytischtennis.de/public/home", "(offizielle QTTR-Werte)"),
|
||||
LinkItemDto("Click-tt Ergebnisse", "http://httv.click-tt.de/", "(offizieller Ergebnisdienst HTTV)"),
|
||||
LinkItemDto("Tischtennis Pur", "https://www.tischtennis-pur.de/", "(Informationen, Blogs und Tipps)"),
|
||||
LinkItemDto("Liveticker 2. und 3. TT-Bundesliga", "https://ticker.tt-news.com/"),
|
||||
)),
|
||||
LinkSectionDto("Verbände", listOf(
|
||||
LinkItemDto("Hessischer Tischtennisverband (HTTV)", "http://www.httv.de/"),
|
||||
LinkItemDto("Deutscher Tischtennisbund (DTTB)", "http://www.tischtennis.de/aktuelles/"),
|
||||
LinkItemDto("European Table Tennis Union (ETTU)", "http://www.ettu.org/"),
|
||||
LinkItemDto("International Table Tennis Federation (ITTF)", "https://www.ittf.com/"),
|
||||
)),
|
||||
LinkSectionDto("Regionale Links", listOf(
|
||||
LinkItemDto("Stadt Frankfurt", "http://www.frankfurt.de/"),
|
||||
LinkItemDto("Vereinsring Harheim", "http://www.harheim.com/"),
|
||||
)),
|
||||
LinkSectionDto("Partner & Vereine", listOf(
|
||||
LinkItemDto("TTC OE Bad Homburg", "http://www.ttcoe.de/"),
|
||||
LinkItemDto("SpVgg Steinkirchen e.V.", "https://www.spvgg-steinkirchen.de/menue-abteilungen/abteilungen/tischtennis"),
|
||||
LinkItemDto("Ergebnisse SpVgg Steinkirchen", "https://www.mytischtennis.de/clicktt/ByTTV/24-25/ligen/Bezirksklasse-A-Gruppe-2-IN-PAF/gruppe/466925/tabelle/gesamt/"),
|
||||
)),
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.SpielplanResponse
|
||||
import de.harheimertc.data.TeamTableResponse
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class SpielplanRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchSpielplan(season: String? = null): Result<SpielplanResponse> = runCatching {
|
||||
val response = api.spielplan(season)
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
val body = response.body() ?: error("Leere Antwort")
|
||||
if (!body.success) error(body.message ?: "Spielplan konnte nicht geladen werden.")
|
||||
body
|
||||
}
|
||||
|
||||
suspend fun fetchTeamTable(team: String, season: String? = null): Result<TeamTableResponse> = runCatching {
|
||||
val response = api.spielplanTable(team, season)
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
val body = response.body() ?: error("Leere Antwort")
|
||||
if (!body.success) error(body.message ?: "Tabelle konnte nicht geladen werden.")
|
||||
body
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.TerminDto
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class TermineRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchTermine(): Result<List<TerminDto>> = runCatching {
|
||||
val response = api.termine()
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
response.body()?.termine.orEmpty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.ConfigResponse
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class TrainingRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchConfig(): Result<ConfigResponse> = runCatching {
|
||||
val response = api.config()
|
||||
if (!response.isSuccessful) error("Trainingsinformationen konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package de.harheimertc.ui.components
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.R
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import de.harheimertc.ui.navigation.NavigationUiState
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
import de.harheimertc.ui.theme.Primary900
|
||||
|
||||
private enum class MenuSection {
|
||||
VEREIN,
|
||||
MANNSCHAFTEN,
|
||||
TRAINING,
|
||||
NEWSLETTER,
|
||||
INTERN,
|
||||
}
|
||||
|
||||
private data class MenuTarget(val label: String, val route: String)
|
||||
|
||||
@Composable
|
||||
fun AppNavigationHeader(
|
||||
selectedRoute: String?,
|
||||
onNavigate: (String) -> Unit,
|
||||
webTabletNavigation: Boolean = false,
|
||||
navigationState: NavigationUiState = NavigationUiState(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Brush.horizontalGradient(listOf(Accent900, Primary900, Accent900)))
|
||||
.padding(horizontal = 18.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (webTabletNavigation) {
|
||||
WebTabletNavigation(selectedRoute, onNavigate, navigationState)
|
||||
} else {
|
||||
CompactNavigation(selectedRoute, onNavigate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompactNavigation(selectedRoute: String?, onNavigate: (String) -> Unit) {
|
||||
BrandRow(onLogin = { onNavigate(Destinations.Login.route) })
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
||||
CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
||||
CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
||||
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
||||
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WebTabletNavigation(
|
||||
selectedRoute: String?,
|
||||
onNavigate: (String) -> Unit,
|
||||
navigationState: NavigationUiState,
|
||||
) {
|
||||
val section = menuSection(selectedRoute)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Brand()
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Row(
|
||||
modifier = Modifier.weight(1f).horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
MainLink("Start", selectedRoute == Destinations.Home.route, onClick = { onNavigate(Destinations.Home.route) })
|
||||
MainLink("Verein", section == MenuSection.VEREIN, onClick = { onNavigate(Destinations.VereinAbout.route) })
|
||||
MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = { onNavigate(Destinations.Mannschaften.route) })
|
||||
MainLink("Training", section == MenuSection.TRAINING, onClick = { onNavigate(Destinations.Training.route) })
|
||||
MainLink("Mitgliedschaft", selectedRoute == Destinations.Membership.route, onClick = { onNavigate(Destinations.Membership.route) })
|
||||
MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = { onNavigate(Destinations.Termine.route) })
|
||||
if (navigationState.showGallery) {
|
||||
MainLink("Galerie", selectedRoute == Destinations.Gallery.route, onClick = { onNavigate(Destinations.Gallery.route) })
|
||||
}
|
||||
MainLink("Newsletter", section == MenuSection.NEWSLETTER, onClick = { onNavigate(Destinations.NewsletterSubscribe.route) })
|
||||
if (navigationState.loggedIn) {
|
||||
MainLink("Intern", section == MenuSection.INTERN, onClick = { onNavigate(Destinations.MemberArea.route) })
|
||||
}
|
||||
MainLink("Kontakt", selectedRoute == Destinations.Contact.route, primary = true, onClick = { onNavigate(Destinations.Contact.route) })
|
||||
TextButton(onClick = { onNavigate(Destinations.Login.route) }) { Text("Login", color = Color.White) }
|
||||
}
|
||||
}
|
||||
val subItems = submenu(section, navigationState)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(top = 3.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
subItems.forEach { item ->
|
||||
SubLink(item.label, item.route == selectedRoute) { onNavigate(item.route) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BrandRow(onLogin: () -> Unit) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Brand()
|
||||
Spacer(Modifier.weight(1f))
|
||||
TextButton(onClick = onLogin) { Text("Login", color = Color.White) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Brand() {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.harheimer_tc_logo),
|
||||
contentDescription = "Harheimer TC Logo",
|
||||
modifier = Modifier.size(42.dp),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text("Harheimer ", color = Color.White, style = MaterialTheme.typography.titleLarge)
|
||||
Text("TC", color = Color(0xFFF87171), style = MaterialTheme.typography.titleLarge)
|
||||
if (BuildConfig.ENVIRONMENT_NAME.isNotBlank()) {
|
||||
Text(
|
||||
BuildConfig.ENVIRONMENT_NAME,
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
.background(Primary600, RoundedCornerShape(5.dp))
|
||||
.padding(horizontal = 6.dp, vertical = 3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainLink(
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
primary: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = if (selected || primary) Primary600 else Color.Transparent,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = Color.White.copy(alpha = if (selected || primary) 1f else 0.82f),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 9.dp),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompactLink(
|
||||
label: String,
|
||||
route: String,
|
||||
selectedRoute: String?,
|
||||
onNavigate: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
color = if (route == selectedRoute) Primary600 else Color.Transparent,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = modifier.clickable { onNavigate(route) },
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = if (route == selectedRoute) Color.White else Color(0xFFD4D4D8),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(vertical = 9.dp, horizontal = 2.dp),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubLink(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
Surface(
|
||||
color = if (selected) Primary600 else Color.Transparent,
|
||||
shape = RoundedCornerShape(5.dp),
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = if (selected) Color.White else Color(0xFFD4D4D8),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun menuSection(route: String?): MenuSection? = when (route) {
|
||||
Destinations.VereinAbout.route,
|
||||
Destinations.Vorstand.route,
|
||||
Destinations.Geschichte.route,
|
||||
Destinations.Satzung.route,
|
||||
Destinations.Vereinsmeisterschaften.route,
|
||||
Destinations.Links.route,
|
||||
Destinations.Gallery.route -> MenuSection.VEREIN
|
||||
|
||||
Destinations.Mannschaften.route,
|
||||
Destinations.Spielplan.route,
|
||||
Destinations.Spielsysteme.route -> MenuSection.MANNSCHAFTEN
|
||||
|
||||
Destinations.Training.route,
|
||||
Destinations.Trainer.route,
|
||||
Destinations.Anfaenger.route,
|
||||
Destinations.Regeln.route -> MenuSection.TRAINING
|
||||
|
||||
Destinations.NewsletterSubscribe.route,
|
||||
Destinations.NewsletterUnsubscribe.route -> MenuSection.NEWSLETTER
|
||||
Destinations.MemberArea.route,
|
||||
Destinations.Members.route,
|
||||
Destinations.MemberNews.route,
|
||||
Destinations.Profile.route,
|
||||
Destinations.MemberApi.route,
|
||||
Destinations.CmsNewsletter.route,
|
||||
Destinations.CmsContactRequests.route,
|
||||
Destinations.Cms.route -> MenuSection.INTERN
|
||||
else -> null
|
||||
}.let { section ->
|
||||
if (section == null && route?.startsWith("mannschaften/") == true) MenuSection.MANNSCHAFTEN else section
|
||||
}
|
||||
|
||||
private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuTarget> = when (section) {
|
||||
MenuSection.VEREIN -> listOf(
|
||||
MenuTarget("Über uns", Destinations.VereinAbout.route),
|
||||
MenuTarget("Vorstand", Destinations.Vorstand.route),
|
||||
MenuTarget("Geschichte", Destinations.Geschichte.route),
|
||||
MenuTarget("Satzung", Destinations.Satzung.route),
|
||||
MenuTarget("Vereinsmeisterschaften", Destinations.Vereinsmeisterschaften.route),
|
||||
MenuTarget("Galerie", Destinations.Gallery.route),
|
||||
MenuTarget("Links", Destinations.Links.route),
|
||||
)
|
||||
MenuSection.MANNSCHAFTEN -> listOf(
|
||||
MenuTarget("Übersicht", Destinations.Mannschaften.route),
|
||||
) + state.teams.map { MenuTarget(it.mannschaft, Destinations.MannschaftDetail.create(it.slug)) } + listOf(
|
||||
MenuTarget("Spielpläne", Destinations.Spielplan.route),
|
||||
MenuTarget("Spielsysteme", Destinations.Spielsysteme.route),
|
||||
)
|
||||
MenuSection.TRAINING -> listOf(
|
||||
MenuTarget("Trainingszeiten", Destinations.Training.route),
|
||||
MenuTarget("Trainer", Destinations.Trainer.route),
|
||||
MenuTarget("Anfänger", Destinations.Anfaenger.route),
|
||||
MenuTarget("TT-Regeln", Destinations.Regeln.route),
|
||||
)
|
||||
MenuSection.NEWSLETTER -> listOf(
|
||||
MenuTarget("Abonnieren", Destinations.NewsletterSubscribe.route),
|
||||
MenuTarget("Abmelden", Destinations.NewsletterUnsubscribe.route),
|
||||
)
|
||||
MenuSection.INTERN -> buildList {
|
||||
add(MenuTarget("Übersicht", Destinations.MemberArea.route))
|
||||
add(MenuTarget("Mitgliederliste", Destinations.Members.route))
|
||||
add(MenuTarget("News", Destinations.MemberNews.route))
|
||||
add(MenuTarget("Mein Profil", Destinations.Profile.route))
|
||||
add(MenuTarget("API-Dokumentation", Destinations.MemberApi.route))
|
||||
if (state.canAccessNewsletter) add(MenuTarget("Newsletter", Destinations.CmsNewsletter.route))
|
||||
if (state.canAccessContactRequests) add(MenuTarget("Kontaktanfragen", Destinations.CmsContactRequests.route))
|
||||
if (state.isAdmin) add(MenuTarget("CMS", Destinations.Cms.route))
|
||||
}
|
||||
null -> emptyList()
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package de.harheimertc.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
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.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
|
||||
@Composable
|
||||
fun HeroComponent(
|
||||
imageUrl: String,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
ctaText: String,
|
||||
onPrimaryCta: () -> Unit,
|
||||
heightDp: Int = 280
|
||||
) {
|
||||
Box(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(heightDp.dp)) {
|
||||
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = "Hero Image",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(heightDp.dp),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
Box(modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(Color(0x66000000), Color(0x00000000), Color(0x88000000))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
Box(modifier = Modifier
|
||||
.matchParentSize()
|
||||
.padding(20.dp)) {
|
||||
Column(modifier = Modifier.align(Alignment.CenterStart)) {
|
||||
Text(text = title, style = MaterialTheme.typography.titleLarge, color = Color.White)
|
||||
Text(text = subtitle, style = MaterialTheme.typography.bodyMedium, color = Color.White)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 16.dp)
|
||||
.clip(RoundedCornerShape(12.dp)),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
Button(
|
||||
onClick = onPrimaryCta,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary),
|
||||
modifier = Modifier
|
||||
) {
|
||||
Text(ctaText, color = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.padding(horizontal = 12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package de.harheimertc.ui.components
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.window.Dialog
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ImageGrid(images: List<String>, modifier: Modifier = Modifier) {
|
||||
val selected = remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LazyVerticalGrid(columns = GridCells.Fixed(3), modifier = modifier.padding(8.dp)) {
|
||||
items(images) { img ->
|
||||
Card(modifier = Modifier.padding(4.dp), elevation = CardDefaults.cardElevation(4.dp)) {
|
||||
AsyncImage(
|
||||
model = img,
|
||||
contentDescription = "Gallery image",
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
.clickable { selected.value = img },
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.value != null) {
|
||||
Dialog(onDismissRequest = { selected.value = null }) {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
AsyncImage(model = selected.value, contentDescription = "Full image", modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Fit)
|
||||
Button(onClick = { selected.value = null }, modifier = Modifier.align(Alignment.TopEnd), colors = ButtonDefaults.buttonColors()) {
|
||||
Text("Schließen")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package de.harheimertc.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary100
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
import de.harheimertc.ui.theme.Primary900
|
||||
|
||||
@Composable
|
||||
fun PendingPage(
|
||||
navController: NavController,
|
||||
title: String,
|
||||
webPath: String,
|
||||
showBackNavigation: Boolean,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
item {
|
||||
if (showBackNavigation) {
|
||||
TextButton(onClick = { navController.popBackStack() }) {
|
||||
Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 30.dp, bottom = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.displayLarge, color = Accent900, textAlign = TextAlign.Center)
|
||||
Text("Webseite: $webPath", color = Accent500)
|
||||
}
|
||||
}
|
||||
item {
|
||||
Surface(color = Primary100, shape = RoundedCornerShape(9.dp)) {
|
||||
Text(
|
||||
"Die native Android-Seite wird in einem der nächsten Portierungsschritte umgesetzt.",
|
||||
color = Primary900,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package de.harheimertc.ui.navigation
|
||||
|
||||
sealed class Destinations(val route: String) {
|
||||
object Home : Destinations("home")
|
||||
object VereinAbout : Destinations("verein/about")
|
||||
object Vorstand : Destinations("verein/vorstand")
|
||||
object Geschichte : Destinations("verein/geschichte")
|
||||
object Satzung : Destinations("verein/satzung")
|
||||
object Vereinsmeisterschaften : Destinations("verein/vereinsmeisterschaften")
|
||||
object Links : Destinations("verein/links")
|
||||
object Mannschaften : Destinations("mannschaften")
|
||||
object MannschaftDetail : Destinations("mannschaften/{slug}") {
|
||||
fun create(slug: String): String = "mannschaften/$slug"
|
||||
}
|
||||
object Termine : Destinations("termine")
|
||||
object Spielplan : Destinations("spielplan")
|
||||
object Spielsysteme : Destinations("mannschaften/spielsysteme")
|
||||
object Training : Destinations("training")
|
||||
object Trainer : Destinations("training/trainer")
|
||||
object Anfaenger : Destinations("training/anfaenger")
|
||||
object Regeln : Destinations("training/regeln")
|
||||
object Gallery : Destinations("gallery")
|
||||
object NewsletterSubscribe : Destinations("newsletter/subscribe")
|
||||
object NewsletterUnsubscribe : Destinations("newsletter/unsubscribe")
|
||||
object Contact : Destinations("contact")
|
||||
object Membership : Destinations("membership")
|
||||
object Login : Destinations("login")
|
||||
object PasswordReset : Destinations("passwordReset")
|
||||
object Register : Destinations("register")
|
||||
object MemberArea : Destinations("intern")
|
||||
object Members : Destinations("intern/mitglieder")
|
||||
object MemberNews : Destinations("intern/news")
|
||||
object Profile : Destinations("intern/profil")
|
||||
object MemberApi : Destinations("intern/api")
|
||||
object CmsNewsletter : Destinations("cms/newsletter")
|
||||
object CmsContactRequests : Destinations("cms/kontaktanfragen")
|
||||
object Cms : Destinations("cms")
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package de.harheimertc.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import de.harheimertc.ui.components.AppNavigationHeader
|
||||
import de.harheimertc.ui.components.PendingPage
|
||||
|
||||
@Composable
|
||||
fun NavGraph(
|
||||
navController: NavHostController,
|
||||
startDestination: String = Destinations.Home.route,
|
||||
navigationViewModel: NavigationViewModel = hiltViewModel(),
|
||||
) {
|
||||
val backStackEntry = navController.currentBackStackEntryAsState().value
|
||||
val route = backStackEntry?.destination?.route
|
||||
val currentRoute = if (route == Destinations.MannschaftDetail.route) {
|
||||
backStackEntry.arguments?.getString("slug")?.let(Destinations.MannschaftDetail::create)
|
||||
} else route
|
||||
val navigationState by navigationViewModel.state.collectAsState()
|
||||
LaunchedEffect(currentRoute) {
|
||||
navigationViewModel.refreshSession()
|
||||
}
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val persistentNavigation = maxWidth >= 600.dp
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
if (persistentNavigation) {
|
||||
AppNavigationHeader(
|
||||
selectedRoute = currentRoute,
|
||||
onNavigate = navController::navigateTopLevel,
|
||||
webTabletNavigation = true,
|
||||
navigationState = navigationState,
|
||||
)
|
||||
}
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
composable(Destinations.Home.route) {
|
||||
de.harheimertc.ui.screens.home.HomeScreen(
|
||||
navController = navController,
|
||||
showNavigationHeader = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.VereinAbout.route) {
|
||||
de.harheimertc.ui.screens.publicpages.AboutScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Vorstand.route) {
|
||||
de.harheimertc.ui.screens.publicpages.VorstandScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Geschichte.route) {
|
||||
de.harheimertc.ui.screens.publicpages.GeschichteScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Satzung.route) {
|
||||
de.harheimertc.ui.screens.publicpages.SatzungScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Vereinsmeisterschaften.route) {
|
||||
de.harheimertc.ui.screens.publicpages.VereinsmeisterschaftenScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Links.route) {
|
||||
de.harheimertc.ui.screens.publicpages.LinksScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Mannschaften.route) {
|
||||
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.MannschaftDetail.route) { entry ->
|
||||
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
|
||||
slug = entry.arguments?.getString("slug").orEmpty(),
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Termine.route) {
|
||||
de.harheimertc.ui.screens.termine.TermineScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Spielplan.route) {
|
||||
de.harheimertc.ui.screens.spielplan.SpielplanScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Spielsysteme.route) {
|
||||
de.harheimertc.ui.screens.publicpages.SpielsystemeScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Training.route) {
|
||||
de.harheimertc.ui.screens.training.TrainingScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Trainer.route) {
|
||||
de.harheimertc.ui.screens.training.TrainerScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Anfaenger.route) {
|
||||
de.harheimertc.ui.screens.training.AnfaengerScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Regeln.route) {
|
||||
de.harheimertc.ui.screens.publicpages.RegelnScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Gallery.route) {
|
||||
de.harheimertc.ui.screens.gallery.GalleryScreen()
|
||||
}
|
||||
composable(Destinations.NewsletterSubscribe.route) {
|
||||
PendingPage(navController, "Newsletter abonnieren", "/newsletter/subscribe", !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.NewsletterUnsubscribe.route) {
|
||||
PendingPage(navController, "Newsletter abmelden", "/newsletter/unsubscribe", !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.Contact.route) {
|
||||
de.harheimertc.ui.screens.contact.ContactScreen()
|
||||
}
|
||||
composable(Destinations.Membership.route) {
|
||||
de.harheimertc.ui.screens.membership.MembershipScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Login.route) {
|
||||
de.harheimertc.ui.screens.login.LoginScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.PasswordReset.route) {
|
||||
de.harheimertc.ui.screens.login.PasswordResetScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Register.route) {
|
||||
de.harheimertc.ui.screens.login.RegisterScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.MemberArea.route) {
|
||||
PendingPage(navController, "Intern", "/mitgliederbereich", !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.Members.route) {
|
||||
PendingPage(navController, "Mitgliederliste", "/mitgliederbereich/mitglieder", !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.MemberNews.route) {
|
||||
PendingPage(navController, "News", "/mitgliederbereich/news", !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.Profile.route) {
|
||||
PendingPage(navController, "Mein Profil", "/mitgliederbereich/profil", !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.MemberApi.route) {
|
||||
PendingPage(navController, "API-Dokumentation", "/mitgliederbereich/api", !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.CmsNewsletter.route) {
|
||||
PendingPage(navController, "Newsletter", "/cms/newsletter", !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.CmsContactRequests.route) {
|
||||
PendingPage(navController, "Kontaktanfragen", "/cms/kontaktanfragen", !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.Cms.route) {
|
||||
PendingPage(navController, "CMS", "/cms", !persistentNavigation)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun NavHostController.navigateTopLevel(route: String) {
|
||||
val isTeamDetail = route.startsWith("mannschaften/") &&
|
||||
route != Destinations.Spielsysteme.route
|
||||
navigate(route) {
|
||||
launchSingleTop = !isTeamDetail
|
||||
restoreState = !isTeamDetail
|
||||
popUpTo(Destinations.Home.route) {
|
||||
saveState = !isTeamDetail
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package de.harheimertc.ui.navigation
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.repositories.GalleryRepository
|
||||
import de.harheimertc.repositories.LoginRepository
|
||||
import de.harheimertc.repositories.Mannschaft
|
||||
import de.harheimertc.repositories.MannschaftenRepository
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class NavigationUiState(
|
||||
val teams: List<Mannschaft> = emptyList(),
|
||||
val hasGalleryImages: Boolean = false,
|
||||
val loggedIn: Boolean = false,
|
||||
val roles: Set<String> = emptySet(),
|
||||
) {
|
||||
val isAdmin: Boolean get() = "admin" in roles
|
||||
val canAccessNewsletter: Boolean get() = roles.any { it in setOf("admin", "vorstand", "newsletter") }
|
||||
val canAccessContactRequests: Boolean get() = roles.any { it in setOf("admin", "vorstand", "trainer") }
|
||||
val showGallery: Boolean get() = hasGalleryImages || canAccessNewsletter
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class NavigationViewModel @Inject constructor(
|
||||
private val mannschaftenRepository: MannschaftenRepository,
|
||||
private val galleryRepository: GalleryRepository,
|
||||
private val loginRepository: LoginRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(NavigationUiState())
|
||||
val state: StateFlow<NavigationUiState> = _state
|
||||
|
||||
init {
|
||||
loadNavigationData()
|
||||
}
|
||||
|
||||
fun loadNavigationData() {
|
||||
viewModelScope.launch {
|
||||
val teams = async { mannschaftenRepository.fetchMannschaften().getOrDefault(emptyList()) }
|
||||
val gallery = async { galleryRepository.hasPublicImages().getOrDefault(false) }
|
||||
val auth = async { loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse()) }
|
||||
val status = auth.await()
|
||||
_state.value = NavigationUiState(
|
||||
teams = teams.await(),
|
||||
hasGalleryImages = gallery.await(),
|
||||
loggedIn = status.isLoggedIn,
|
||||
roles = (status.roles + status.user?.roles.orEmpty()).toSet(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshSession() {
|
||||
viewModelScope.launch {
|
||||
val status = loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse())
|
||||
_state.value = _state.value.copy(
|
||||
loggedIn = status.isLoggedIn,
|
||||
roles = (status.roles + status.user?.roles.orEmpty()).toSet(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package de.harheimertc.ui.screens.contact
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
|
||||
@Composable
|
||||
fun ContactScreen(viewModel: ContactViewModel = hiltViewModel()) {
|
||||
val name by viewModel.name.collectAsState()
|
||||
val email by viewModel.email.collectAsState()
|
||||
val message by viewModel.message.collectAsState()
|
||||
val sending by viewModel.sending.collectAsState()
|
||||
val result by viewModel.result.collectAsState()
|
||||
|
||||
Surface(modifier = Modifier.padding(16.dp)) {
|
||||
Column {
|
||||
OutlinedTextField(value = name, onValueChange = { viewModel.onName(it) }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = email, onValueChange = { viewModel.onEmail(it) }, label = { Text("E-Mail") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = message, onValueChange = { viewModel.onMessage(it) }, label = { Text("Nachricht") }, modifier = Modifier.fillMaxWidth())
|
||||
Button(onClick = { viewModel.send() }, enabled = !sending, modifier = Modifier.padding(top = 8.dp)) {
|
||||
Text(if (sending) "Sende…" else "Absenden")
|
||||
}
|
||||
if (result != null) {
|
||||
Text(text = result!!, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package de.harheimertc.ui.screens.contact
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.ContactRequest
|
||||
import de.harheimertc.repositories.ContactRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class ContactViewModel @Inject constructor(private val repo: ContactRepository) : ViewModel() {
|
||||
private val _name = MutableStateFlow("")
|
||||
val name: StateFlow<String> = _name
|
||||
|
||||
private val _email = MutableStateFlow("")
|
||||
val email: StateFlow<String> = _email
|
||||
|
||||
private val _message = MutableStateFlow("")
|
||||
val message: StateFlow<String> = _message
|
||||
|
||||
private val _sending = MutableStateFlow(false)
|
||||
val sending: StateFlow<Boolean> = _sending
|
||||
|
||||
private val _result = MutableStateFlow<String?>(null)
|
||||
val result: StateFlow<String?> = _result
|
||||
|
||||
fun onName(v: String) { _name.value = v }
|
||||
fun onEmail(v: String) { _email.value = v }
|
||||
fun onMessage(v: String) { _message.value = v }
|
||||
|
||||
fun send() {
|
||||
val n = _name.value.trim()
|
||||
val e = _email.value.trim()
|
||||
val m = _message.value.trim()
|
||||
if (n.isEmpty() || e.isEmpty() || m.isEmpty()) {
|
||||
_result.value = "Bitte alle Felder ausfüllen"
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_sending.value = true
|
||||
try {
|
||||
val resp = repo.sendContact(ContactRequest(n, e, m))
|
||||
if (resp.isSuccessful) {
|
||||
_result.value = "Nachricht gesendet"
|
||||
_name.value = ""
|
||||
_email.value = ""
|
||||
_message.value = ""
|
||||
} else {
|
||||
_result.value = "Fehler: ${resp.code()}"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_result.value = "Netzwerkfehler"
|
||||
} finally {
|
||||
_sending.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.harheimertc.ui.screens.gallery
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import de.harheimertc.ui.components.ImageGrid
|
||||
|
||||
@Composable
|
||||
fun GalleryScreen(viewModel: GalleryViewModel = hiltViewModel()) {
|
||||
val images by viewModel.images.collectAsState()
|
||||
val loading by viewModel.loading.collectAsState()
|
||||
val error by viewModel.error.collectAsState()
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
if (loading) {
|
||||
CircularProgressIndicator()
|
||||
} else if (error != null) {
|
||||
Text(text = "Fehler: $error")
|
||||
} else {
|
||||
ImageGrid(images = images)
|
||||
}
|
||||
}
|
||||
|
||||
// load on first composition
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) { viewModel.load() }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.harheimertc.ui.screens.gallery
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.repositories.GalleryRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class GalleryViewModel @Inject constructor(private val repo: GalleryRepository) : ViewModel() {
|
||||
private val _images = MutableStateFlow<List<String>>(emptyList())
|
||||
val images: StateFlow<List<String>> = _images
|
||||
|
||||
private val _loading = MutableStateFlow(false)
|
||||
val loading: StateFlow<Boolean> = _loading
|
||||
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
val error: StateFlow<String?> = _error
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_loading.value = true
|
||||
_error.value = null
|
||||
repo.fetchImages()
|
||||
.onSuccess { _images.value = it }
|
||||
.onFailure { _error.value = it.message ?: "Fehler" }
|
||||
_loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
package de.harheimertc.ui.screens.home
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.data.NewsDto
|
||||
import de.harheimertc.data.SpielDto
|
||||
import de.harheimertc.data.TerminDto
|
||||
import de.harheimertc.ui.components.AppNavigationHeader
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import de.harheimertc.ui.theme.Accent100
|
||||
import de.harheimertc.ui.theme.Accent200
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent700
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary100
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
import de.harheimertc.ui.theme.Primary900
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
navController: NavController,
|
||||
showNavigationHeader: Boolean = true,
|
||||
viewModel: HomeViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
var selectedNews by remember { mutableStateOf<NewsDto?>(null) }
|
||||
|
||||
selectedNews?.let { item ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { selectedNews = null },
|
||||
title = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(formatNewsDate(item.created), style = MaterialTheme.typography.labelSmall, color = Accent500)
|
||||
Text(item.title, style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
},
|
||||
text = { Text(item.content, style = MaterialTheme.typography.bodyMedium, color = Accent700) },
|
||||
confirmButton = { TextButton(onClick = { selectedNews = null }) { Text("Schließen") } },
|
||||
)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color.White),
|
||||
) {
|
||||
if (showNavigationHeader) {
|
||||
item {
|
||||
AppNavigationHeader(
|
||||
selectedRoute = Destinations.Home.route,
|
||||
onNavigate = navController::navigate,
|
||||
)
|
||||
}
|
||||
}
|
||||
item { WebHero() }
|
||||
item {
|
||||
HomeTermineSection(
|
||||
termine = state.termine,
|
||||
loading = state.loading,
|
||||
onAll = { navController.navigate(Destinations.Termine.route) },
|
||||
)
|
||||
}
|
||||
item {
|
||||
HomeGamesSection(
|
||||
spiele = state.spiele,
|
||||
loading = state.loading,
|
||||
onAll = { navController.navigate(Destinations.Spielplan.route) },
|
||||
)
|
||||
}
|
||||
if (state.news.isNotEmpty()) {
|
||||
item {
|
||||
HomeNewsSection(
|
||||
news = state.news,
|
||||
onOpen = { selectedNews = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
HomeActionSection(
|
||||
onMembership = { navController.navigate(Destinations.Membership.route) },
|
||||
onContact = { navController.navigate(Destinations.Contact.route) },
|
||||
)
|
||||
}
|
||||
item { HomeFooter() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WebHero() {
|
||||
val years = Calendar.getInstance().get(Calendar.YEAR) - 1954
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 390.dp)
|
||||
.background(Brush.verticalGradient(listOf(Color(0xFFFAFAFA), Color(0xFFF4F4F5)))),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = "${BuildConfig.API_BASE_URL}images/club_about_us.png",
|
||||
contentDescription = null,
|
||||
modifier = Modifier.matchParentSize().alpha(0.10f),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 58.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
Text(
|
||||
"Willkommen beim",
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
color = Accent900,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
"Harheimer TC",
|
||||
style = MaterialTheme.typography.displayLarge.copy(fontSize = 40.sp),
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Primary600,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
"Tradition trifft Moderne - Ihr Tischtennisverein in Frankfurt-Harheim seit $years Jahren",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = Accent700,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeTermineSection(termine: List<TerminDto>, loading: Boolean, onAll: () -> Unit) {
|
||||
HomeSection(title = "Kommende Termine", background = Color(0xFFFAFAFA)) {
|
||||
if (loading) {
|
||||
LoadingRow("Termine werden geladen...")
|
||||
} else if (termine.isEmpty()) {
|
||||
EmptyRow("Keine kommenden Termine")
|
||||
} else {
|
||||
termine.forEach { termin -> AppointmentCard(termin) }
|
||||
}
|
||||
PrimaryAction("Alle Termine anzeigen", onAll)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeGamesSection(spiele: List<SpielDto>, loading: Boolean, onAll: () -> Unit) {
|
||||
HomeSection(title = "Nächste Spiele", background = Color.White) {
|
||||
if (loading) {
|
||||
LoadingRow("Spielplan wird geladen...")
|
||||
} else if (spiele.isEmpty()) {
|
||||
EmptyRow("Derzeit sind keine Spiele geplant.")
|
||||
} else {
|
||||
spiele.forEach { spiel -> MatchCard(spiel) }
|
||||
}
|
||||
PrimaryAction("Alle Spiele anzeigen", onAll)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeNewsSection(news: List<NewsDto>, onOpen: (NewsDto) -> Unit) {
|
||||
HomeSection(
|
||||
title = "Aktuelles",
|
||||
subtitle = "Die neuesten Nachrichten aus unserem Verein",
|
||||
background = Color.White,
|
||||
) {
|
||||
news.forEach { item ->
|
||||
Surface(
|
||||
color = Color(0xFFFAFAFA),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
tonalElevation = 0.dp,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp).clickable { onOpen(item) },
|
||||
) {
|
||||
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
Text(formatNewsDate(item.created), style = MaterialTheme.typography.labelSmall, color = Accent500)
|
||||
Text(item.title, style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
Text(
|
||||
item.content,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Accent700,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeActionSection(onMembership: () -> Unit, onContact: () -> Unit) {
|
||||
HomeSection(title = null, background = Color(0xFFFAFAFA)) {
|
||||
ActionCard(
|
||||
title = "Mitglied werden",
|
||||
body = "Werden Sie Teil unserer Tischtennisfamilie und profitieren Sie von Training, Wettkämpfen und Gemeinschaft.",
|
||||
action = "Mehr erfahren",
|
||||
onClick = onMembership,
|
||||
)
|
||||
ActionCard(
|
||||
title = "Kontakt aufnehmen",
|
||||
body = "Haben Sie Fragen oder möchten ein kostenloses Probetraining vereinbaren? Wir freuen uns auf Ihre Nachricht!",
|
||||
action = "Jetzt kontaktieren",
|
||||
onClick = onContact,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeSection(
|
||||
title: String?,
|
||||
subtitle: String? = null,
|
||||
background: Color,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().background(background).padding(horizontal = 18.dp, vertical = 38.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
title?.let {
|
||||
Text(it, style = MaterialTheme.typography.displayLarge, color = Accent900, textAlign = TextAlign.Center)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Box(Modifier.width(74.dp).height(4.dp).background(Primary600))
|
||||
subtitle?.let { text ->
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Text(text, style = MaterialTheme.typography.bodyLarge, color = Accent500, textAlign = TextAlign.Center)
|
||||
}
|
||||
Spacer(Modifier.height(26.dp))
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppointmentCard(termin: TerminDto) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(9.dp),
|
||||
color = Color(0xFFF4F4F5),
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
) {
|
||||
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Surface(color = Primary600, shape = RoundedCornerShape(8.dp)) {
|
||||
Column(
|
||||
modifier = Modifier.width(65.dp).padding(vertical = 8.dp, horizontal = 3.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(formatDate(termin.datum, "dd"), color = Color.White, fontWeight = FontWeight.Bold, fontSize = 19.sp)
|
||||
Text(formatDate(termin.datum, "MMM yyyy"), color = Color.White, style = MaterialTheme.typography.labelSmall)
|
||||
termin.uhrzeit?.let { Text("$it Uhr", color = Color.White, style = MaterialTheme.typography.labelSmall) }
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(start = 13.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(termin.titel, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = Accent900)
|
||||
termin.beschreibung?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium, color = Accent700, maxLines = 2)
|
||||
}
|
||||
}
|
||||
termin.kategorie?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(
|
||||
it,
|
||||
color = Accent700,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.background(Primary100, RoundedCornerShape(14.dp)).padding(horizontal = 8.dp, vertical = 5.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MatchCard(spiel: SpielDto) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = Color.White,
|
||||
shadowElevation = 3.dp,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
|
||||
) {
|
||||
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(formatMatchDate(spiel.termin), fontWeight = FontWeight.SemiBold, color = Accent900)
|
||||
Text(spiel.termin.substringAfter(' ', "-"), style = MaterialTheme.typography.bodyMedium, color = Accent500)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
TeamLabel("Heim", spiel.heimMannschaft, Modifier.weight(1f))
|
||||
Box(
|
||||
Modifier.size(34.dp).background(Primary100, RoundedCornerShape(20.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { Text("vs", color = Primary600, fontWeight = FontWeight.Bold) }
|
||||
TeamLabel("Gast", spiel.gastMannschaft, Modifier.weight(1f), right = true)
|
||||
}
|
||||
spiel.runde?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium, color = Accent500)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TeamLabel(label: String, value: String, modifier: Modifier, right: Boolean = false) {
|
||||
Column(modifier.padding(horizontal = 8.dp), horizontalAlignment = if (right) Alignment.End else Alignment.Start) {
|
||||
Text(label, style = MaterialTheme.typography.labelSmall, color = Accent500)
|
||||
Text(value, fontWeight = FontWeight.SemiBold, color = Accent900, textAlign = if (right) TextAlign.End else TextAlign.Start)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionCard(title: String, body: String, action: String, onClick: () -> Unit) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = Color.White,
|
||||
shadowElevation = 3.dp,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 15.dp).clickable(onClick = onClick),
|
||||
) {
|
||||
Column(Modifier.padding(22.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
Modifier.size(48.dp).background(Primary100, RoundedCornerShape(10.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { Text("HTC", color = Primary600, fontWeight = FontWeight.Bold, fontSize = 11.sp) }
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
}
|
||||
Text(body, style = MaterialTheme.typography.bodyMedium, color = Accent700)
|
||||
Text("$action >", color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PrimaryAction(label: String, onClick: () -> Unit) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Button(
|
||||
onClick = onClick,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) { Text("$label >", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp)) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyRow(text: String) {
|
||||
Surface(color = Accent100, shape = RoundedCornerShape(9.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Text(text, color = Accent500, textAlign = TextAlign.Center, modifier = Modifier.padding(vertical = 30.dp, horizontal = 12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingRow(text: String) {
|
||||
Column(Modifier.fillMaxWidth().padding(22.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(28.dp), color = Primary600)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(text, color = Accent500)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeFooter() {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().background(Accent900).padding(horizontal = 18.dp, vertical = 28.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text("Harheimer TC", style = MaterialTheme.typography.titleLarge, color = Color.White)
|
||||
Text("Tischtennis in Frankfurt-Harheim seit 1954", color = Accent200, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDate(value: String, pattern: String): String = runCatching {
|
||||
val source = SimpleDateFormat("yyyy-MM-dd", Locale.GERMANY)
|
||||
SimpleDateFormat(pattern, Locale.GERMANY).format(source.parse(value)!!)
|
||||
}.getOrDefault(value)
|
||||
|
||||
private fun formatMatchDate(value: String): String = runCatching {
|
||||
val source = SimpleDateFormat("dd.MM.yyyy", Locale.GERMANY)
|
||||
val date = source.parse(value.substringBefore(' '))!!
|
||||
SimpleDateFormat("EEE dd.MM.yyyy", Locale.GERMANY).format(date)
|
||||
}.getOrDefault(value.substringBefore(' '))
|
||||
|
||||
private fun formatNewsDate(value: String?): String {
|
||||
if (value.isNullOrBlank()) return ""
|
||||
return runCatching {
|
||||
val source = SimpleDateFormat("yyyy-MM-dd", Locale.GERMANY)
|
||||
SimpleDateFormat("dd. MMMM yyyy", Locale.GERMANY).format(source.parse(value.take(10))!!)
|
||||
}.getOrDefault(value.take(10))
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package de.harheimertc.ui.screens.home
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.NewsDto
|
||||
import de.harheimertc.data.SpielDto
|
||||
import de.harheimertc.data.TerminDto
|
||||
import de.harheimertc.repositories.HomeRepository
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class HomeUiState(
|
||||
val loading: Boolean = true,
|
||||
val termine: List<TerminDto> = emptyList(),
|
||||
val spiele: List<SpielDto> = emptyList(),
|
||||
val news: List<NewsDto> = emptyList(),
|
||||
val error: Boolean = false,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class HomeViewModel @Inject constructor(private val repository: HomeRepository) : ViewModel() {
|
||||
private val _state = MutableStateFlow(HomeUiState())
|
||||
val state: StateFlow<HomeUiState> = _state
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, error = false)
|
||||
repository.fetchHomeData()
|
||||
.onSuccess { data ->
|
||||
_state.value = HomeUiState(
|
||||
loading = false,
|
||||
termine = data.termine
|
||||
.filter { it.asDateTime()?.isBefore(LocalDateTime.now()) != true }
|
||||
.sortedBy { it.asDateTime() }
|
||||
.take(3),
|
||||
spiele = data.spiele
|
||||
.filter { game ->
|
||||
game.asDate()?.let { date ->
|
||||
!date.isBefore(LocalDate.now()) &&
|
||||
!date.isAfter(LocalDate.now().plusDays(7))
|
||||
} == true
|
||||
}
|
||||
.sortedBy { it.asDate() }
|
||||
.take(3),
|
||||
news = data.news.take(3),
|
||||
)
|
||||
}
|
||||
.onFailure {
|
||||
_state.value = HomeUiState(loading = false, error = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun TerminDto.asDateTime(): LocalDateTime? = runCatching {
|
||||
val time = uhrzeit?.takeIf { it.matches(Regex("\\d{2}:\\d{2}")) } ?: "00:00"
|
||||
LocalDateTime.parse("$datum $time", DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
|
||||
}.getOrNull()
|
||||
|
||||
fun SpielDto.asDate(): LocalDate? = runCatching {
|
||||
LocalDate.parse(termin.substringBefore(' '), DateTimeFormatter.ofPattern("dd.MM.yyyy"))
|
||||
}.getOrNull()
|
||||
@@ -0,0 +1,131 @@
|
||||
package de.harheimertc.ui.screens.login
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary100
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
import de.harheimertc.ui.theme.Primary900
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean = true,
|
||||
viewModel: LoginViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
item {
|
||||
if (showBackNavigation) {
|
||||
TextButton(onClick = { navController.popBackStack() }) {
|
||||
Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 24.dp)) {
|
||||
Text(
|
||||
"Mitglieder-Login",
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
color = Accent900,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Text(
|
||||
"Melden Sie sich an, um auf den Mitgliederbereich zuzugreifen.",
|
||||
color = Accent500,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 9.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 3.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
if (state.restoring) {
|
||||
CircularProgressIndicator(color = Primary600, modifier = Modifier.size(28.dp))
|
||||
Text("Sitzung wird geprüft...", color = Accent500)
|
||||
} else if (!state.loggedIn) {
|
||||
OutlinedTextField(
|
||||
value = state.email,
|
||||
onValueChange = viewModel::setEmail,
|
||||
label = { Text("E-Mail-Adresse") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.password,
|
||||
onValueChange = viewModel::setPassword,
|
||||
label = { Text("Passwort") },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Button(onClick = viewModel::login, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) {
|
||||
if (state.loading) CircularProgressIndicator(color = Color.White, strokeWidth = 2.dp, modifier = Modifier.size(18.dp).padding(end = 4.dp))
|
||||
Text(if (state.loading) "Anmeldung läuft..." else "Anmelden")
|
||||
}
|
||||
TextButton(onClick = { navController.navigate(Destinations.PasswordReset.route) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Passwort vergessen?")
|
||||
}
|
||||
TextButton(onClick = { navController.navigate(Destinations.Register.route) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Registrierung beantragen")
|
||||
}
|
||||
} else {
|
||||
Text("Angemeldet", style = MaterialTheme.typography.titleLarge, color = Color(0xFF166534))
|
||||
Text(state.userName.orEmpty(), color = Accent900)
|
||||
if (state.roles.isNotEmpty()) Text(state.roles.joinToString(", "), color = Accent500)
|
||||
OutlinedButton(onClick = viewModel::logout, modifier = Modifier.fillMaxWidth()) { Text("Abmelden") }
|
||||
}
|
||||
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
state.message?.let { Text(it, color = Color(0xFF166534)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Surface(color = Primary100, shape = RoundedCornerShape(9.dp)) {
|
||||
Text(
|
||||
"Nur für Vereinsmitglieder. Kein Zugang? Kontaktieren Sie den Vorstand.",
|
||||
color = Primary900,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package de.harheimertc.ui.screens.login
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.repositories.LoginRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class LoginUiState(
|
||||
val email: String = "",
|
||||
val password: String = "",
|
||||
val loading: Boolean = false,
|
||||
val restoring: Boolean = true,
|
||||
val loggedIn: Boolean = false,
|
||||
val userName: String? = null,
|
||||
val roles: List<String> = emptyList(),
|
||||
val error: String? = null,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class LoginViewModel @Inject constructor(private val repository: LoginRepository) : ViewModel() {
|
||||
private val _state = MutableStateFlow(LoginUiState())
|
||||
val state: StateFlow<LoginUiState> = _state
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
repository.status()
|
||||
.onSuccess { status ->
|
||||
_state.value = _state.value.copy(
|
||||
restoring = false,
|
||||
loggedIn = status.isLoggedIn,
|
||||
userName = status.user?.name ?: status.user?.email,
|
||||
roles = status.roles.ifEmpty { status.user?.roles.orEmpty() },
|
||||
)
|
||||
}
|
||||
.onFailure { _state.value = _state.value.copy(restoring = false) }
|
||||
}
|
||||
}
|
||||
|
||||
fun setEmail(value: String) {
|
||||
_state.value = _state.value.copy(email = value, error = null)
|
||||
}
|
||||
|
||||
fun setPassword(value: String) {
|
||||
_state.value = _state.value.copy(password = value, error = null)
|
||||
}
|
||||
|
||||
fun login() {
|
||||
val current = _state.value
|
||||
if (current.email.isBlank() || current.password.isBlank()) {
|
||||
_state.value = current.copy(error = "Bitte E-Mail-Adresse und Passwort eingeben.")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.value = current.copy(loading = true, error = null, message = null)
|
||||
repository.login(current.email, current.password)
|
||||
.onSuccess { response ->
|
||||
_state.value = current.copy(
|
||||
password = "",
|
||||
loading = false,
|
||||
restoring = false,
|
||||
loggedIn = true,
|
||||
userName = response.user?.name ?: response.user?.email,
|
||||
roles = response.user?.roles.orEmpty(),
|
||||
message = "Anmeldung erfolgreich.",
|
||||
)
|
||||
}
|
||||
.onFailure {
|
||||
_state.value = current.copy(loading = false, error = it.message ?: "Anmeldung fehlgeschlagen.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch {
|
||||
repository.logout()
|
||||
_state.value = LoginUiState(restoring = false, message = "Sie wurden abgemeldet.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package de.harheimertc.ui.screens.login
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
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.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary100
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
import de.harheimertc.ui.theme.Primary900
|
||||
|
||||
@Composable
|
||||
fun PasswordResetScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean = true,
|
||||
viewModel: PasswordResetViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
AuthFormPage(
|
||||
title = "Passwort zurücksetzen",
|
||||
subtitle = "Geben Sie Ihre E-Mail-Adresse ein, um Ihr Passwort zurückzusetzen.",
|
||||
onBack = { navController.navigate(Destinations.Login.route) },
|
||||
showBackNavigation = showBackNavigation,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
state.email,
|
||||
viewModel::setEmail,
|
||||
label = { Text("E-Mail-Adresse") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
MessageLines(state.error, state.message)
|
||||
Button(onClick = viewModel::submit, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(if (state.loading) "Wird gesendet..." else "Passwort zurücksetzen")
|
||||
}
|
||||
TextButton(onClick = { navController.navigate(Destinations.Login.route) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Zurück zum Login")
|
||||
}
|
||||
AuthNotice("Sie erhalten eine E-Mail mit einem temporären Passwort, sofern ein Konto vorhanden ist.")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RegisterScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean = true,
|
||||
viewModel: RegisterViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val form = state.form
|
||||
AuthFormPage(
|
||||
title = "Registrierung",
|
||||
subtitle = "Beantragen Sie einen Zugang zum Mitgliederbereich.",
|
||||
onBack = { navController.navigate(Destinations.Login.route) },
|
||||
showBackNavigation = showBackNavigation,
|
||||
) {
|
||||
OutlinedTextField(form.name, { viewModel.update(form.copy(name = it)) }, label = { Text("Name *") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(
|
||||
form.email,
|
||||
{ viewModel.update(form.copy(email = it)) },
|
||||
label = { Text("E-Mail-Adresse *") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
form.phone,
|
||||
{ viewModel.update(form.copy(phone = it)) },
|
||||
label = { Text("Telefon") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
form.birthDate,
|
||||
{ viewModel.update(form.copy(birthDate = it)) },
|
||||
label = { Text("Geburtsdatum * (JJJJ-MM-TT)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
form.password,
|
||||
{ viewModel.update(form.copy(password = it)) },
|
||||
label = { Text("Passwort *") },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
form.passwordRepeat,
|
||||
{ viewModel.update(form.copy(passwordRepeat = it)) },
|
||||
label = { Text("Passwort wiederholen *") },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(form.showBirthday, { viewModel.update(form.copy(showBirthday = it)) })
|
||||
Text("Geburtstag im Mitgliederbereich anzeigen")
|
||||
}
|
||||
MessageLines(state.error, state.message)
|
||||
Button(onClick = viewModel::submit, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(if (state.loading) "Wird gesendet..." else "Registrierung beantragen")
|
||||
}
|
||||
AuthNotice("Ihre Registrierung muss vor der Anmeldung vom Vorstand freigegeben werden.")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AuthFormPage(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onBack: () -> Unit,
|
||||
showBackNavigation: Boolean,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
item {
|
||||
if (showBackNavigation) {
|
||||
TextButton(onClick = onBack) { Text("< Login", color = Primary600, fontWeight = FontWeight.SemiBold) }
|
||||
}
|
||||
Text(title, style = MaterialTheme.typography.displayLarge, color = Accent900, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(top = 22.dp))
|
||||
Text(subtitle, color = Accent500, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(top = 9.dp, bottom = 14.dp))
|
||||
}
|
||||
item {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 3.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(20.dp), verticalArrangement = Arrangement.spacedBy(15.dp)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageLines(error: String?, message: String?) {
|
||||
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
message?.let { Text(it, color = Color(0xFF166534)) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AuthNotice(text: String) {
|
||||
Surface(color = Primary100, shape = RoundedCornerShape(8.dp)) {
|
||||
Text(text, color = Primary900, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(13.dp))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package de.harheimertc.ui.screens.login
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.RegistrationRequest
|
||||
import de.harheimertc.data.RegistrationVisibility
|
||||
import de.harheimertc.repositories.LoginRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class PasswordResetUiState(
|
||||
val email: String = "",
|
||||
val loading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class PasswordResetViewModel @Inject constructor(private val repository: LoginRepository) : ViewModel() {
|
||||
private val _state = MutableStateFlow(PasswordResetUiState())
|
||||
val state: StateFlow<PasswordResetUiState> = _state
|
||||
|
||||
fun setEmail(value: String) {
|
||||
_state.value = _state.value.copy(email = value, error = null)
|
||||
}
|
||||
|
||||
fun submit() {
|
||||
val email = _state.value.email.trim()
|
||||
if (!email.contains("@")) {
|
||||
_state.value = _state.value.copy(error = "Bitte eine gültige E-Mail-Adresse eingeben.")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, error = null, message = null)
|
||||
repository.resetPassword(email)
|
||||
.onSuccess { response ->
|
||||
_state.value = PasswordResetUiState(message = response.message ?: "Anfrage wurde gesendet.")
|
||||
}
|
||||
.onFailure {
|
||||
_state.value = _state.value.copy(loading = false, error = "Anfrage konnte nicht gesendet werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class RegisterFormState(
|
||||
val name: String = "",
|
||||
val email: String = "",
|
||||
val phone: String = "",
|
||||
val birthDate: String = "",
|
||||
val password: String = "",
|
||||
val passwordRepeat: String = "",
|
||||
val showBirthday: Boolean = true,
|
||||
)
|
||||
|
||||
data class RegisterUiState(
|
||||
val form: RegisterFormState = RegisterFormState(),
|
||||
val loading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class RegisterViewModel @Inject constructor(private val repository: LoginRepository) : ViewModel() {
|
||||
private val _state = MutableStateFlow(RegisterUiState())
|
||||
val state: StateFlow<RegisterUiState> = _state
|
||||
|
||||
fun update(form: RegisterFormState) {
|
||||
_state.value = _state.value.copy(form = form, error = null)
|
||||
}
|
||||
|
||||
fun submit() {
|
||||
val form = _state.value.form
|
||||
val error = when {
|
||||
form.name.isBlank() || form.email.isBlank() || form.birthDate.isBlank() || form.password.isBlank() ->
|
||||
"Bitte alle Pflichtfelder ausfüllen."
|
||||
!form.email.contains("@") -> "Bitte eine gültige E-Mail-Adresse eingeben."
|
||||
form.password.length < 8 -> "Das Passwort muss mindestens 8 Zeichen lang sein."
|
||||
form.password != form.passwordRepeat -> "Die Passwörter stimmen nicht überein."
|
||||
else -> null
|
||||
}
|
||||
if (error != null) {
|
||||
_state.value = _state.value.copy(error = error)
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, error = null, message = null)
|
||||
repository.register(
|
||||
RegistrationRequest(
|
||||
name = form.name.trim(),
|
||||
email = form.email.trim(),
|
||||
phone = form.phone.trim().takeIf(String::isNotBlank),
|
||||
password = form.password,
|
||||
geburtsdatum = form.birthDate.trim(),
|
||||
visibility = RegistrationVisibility(showBirthday = form.showBirthday),
|
||||
),
|
||||
).onSuccess { response ->
|
||||
_state.value = RegisterUiState(message = response.message ?: "Registrierung wurde eingereicht.")
|
||||
}.onFailure {
|
||||
_state.value = _state.value.copy(loading = false, error = it.message ?: "Registrierung fehlgeschlagen.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
package de.harheimertc.ui.screens.mannschaften
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
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.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.data.SpielDto
|
||||
import de.harheimertc.data.LeagueTableRowDto
|
||||
import de.harheimertc.repositories.Mannschaft
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import de.harheimertc.ui.theme.Accent100
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary100
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
import de.harheimertc.ui.theme.Primary700
|
||||
|
||||
@Composable
|
||||
fun MannschaftenScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: MannschaftenViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||
contentPadding = PaddingValues(horizontal = 18.dp, vertical = 22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
item {
|
||||
BackLink(navController, showBackNavigation)
|
||||
Text("Unsere Mannschaften", style = MaterialTheme.typography.displayLarge, color = Accent900, modifier = Modifier.padding(top = 18.dp))
|
||||
Text("Unsere aktiven Mannschaften in der aktuellen Saison", color = Accent500, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
when {
|
||||
state.loading -> item { Loading() }
|
||||
state.error != null -> item { ErrorPanel(state.error.orEmpty(), viewModel::load) }
|
||||
state.teams.isEmpty() -> item { Text("Keine Mannschaftsdaten geladen", color = Accent500) }
|
||||
else -> items(state.teams) { team ->
|
||||
TeamCard(team) { navController.navigate(Destinations.MannschaftDetail.create(team.slug)) }
|
||||
}
|
||||
}
|
||||
item {
|
||||
Surface(color = Primary100, shape = RoundedCornerShape(8.dp)) {
|
||||
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Spielpläne & Ergebnisse", style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
Text("Alle aktuellen Spielpläne und Ergebnisse unserer Mannschaften.", color = Accent500)
|
||||
Button(
|
||||
onClick = { navController.navigate(Destinations.Spielplan.route) },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
|
||||
) { Text("Zu den Spielplänen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TeamCard(team: Mannschaft, onOpen: () -> Unit) {
|
||||
Surface(
|
||||
color = Color.White,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
shadowElevation = 2.dp,
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onOpen),
|
||||
) {
|
||||
Column {
|
||||
Column(Modifier.fillMaxWidth().background(Brush.horizontalGradient(listOf(Primary600, Primary700))).padding(16.dp)) {
|
||||
Text(team.mannschaft, style = MaterialTheme.typography.titleLarge, color = Color.White)
|
||||
Text(team.liga, color = Primary100, modifier = Modifier.padding(top = 4.dp))
|
||||
}
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TeamInfo("Staffelleiter", team.staffelleiter)
|
||||
TeamInfo("Heimspieltag", team.heimspieltag)
|
||||
TeamInfo("Spielsystem", team.spielsystem)
|
||||
Text("${team.spieler.size} Spieler - Details anzeigen", color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MannschaftDetailScreen(
|
||||
slug: String,
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: MannschaftDetailViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
var selectedTab by rememberSaveable(slug) { mutableStateOf(DetailTab.Matches) }
|
||||
LaunchedEffect(slug) { viewModel.load(slug) }
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||
contentPadding = PaddingValues(horizontal = 18.dp, vertical = 22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
item { BackLink(navController, showBackNavigation) }
|
||||
if (state.loading) {
|
||||
item { Loading() }
|
||||
} else {
|
||||
state.team?.let { team ->
|
||||
item { TeamHeader(team) }
|
||||
item {
|
||||
InfoCard("Liga-Informationen") {
|
||||
TeamInfo("Staffelleiter", team.staffelleiter)
|
||||
TeamInfo("Telefon", team.telefon)
|
||||
TeamInfo("Heimspieltag", team.heimspieltag)
|
||||
TeamInfo("Spielsystem", team.spielsystem)
|
||||
}
|
||||
}
|
||||
item {
|
||||
InfoCard("Mannschaftsaufstellung") {
|
||||
team.spieler.forEach { player ->
|
||||
val captain = player == team.mannschaftsfuehrer
|
||||
Surface(color = if (captain) Primary100 else Accent100, shape = RoundedCornerShape(6.dp)) {
|
||||
Row(Modifier.fillMaxWidth().padding(10.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(player, color = Accent900)
|
||||
if (captain) Text("Mannschaftsführer", color = Primary600, style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
SchedulePanelHeader(
|
||||
season = state.season,
|
||||
selectedTab = selectedTab,
|
||||
hasTable = team.informationenLink.isNotBlank(),
|
||||
onSelected = { selectedTab = it },
|
||||
)
|
||||
}
|
||||
when (selectedTab) {
|
||||
DetailTab.Matches -> {
|
||||
if (state.matchesError != null) item { Text(state.matchesError.orEmpty(), color = Primary700) }
|
||||
if (state.matches.isEmpty() && state.matchesError == null) {
|
||||
item { Text("Für diese Mannschaft sind aktuell keine Spiele vorhanden.", color = Accent500) }
|
||||
} else {
|
||||
items(state.matches) { MatchCard(it) }
|
||||
}
|
||||
}
|
||||
DetailTab.Table -> {
|
||||
when {
|
||||
state.tableLoading -> item { Loading() }
|
||||
state.tableError != null -> item { Text(state.tableError.orEmpty(), color = Primary700) }
|
||||
state.tableRows.isEmpty() -> item { Text("Für diese Mannschaft ist aktuell keine Tabelle hinterlegt.", color = Accent500) }
|
||||
else -> {
|
||||
item { TableLegend() }
|
||||
items(state.tableRows) { TableRow(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} ?: item { ErrorPanel(state.matchesError ?: "Mannschaft nicht gefunden.") { viewModel.load(slug) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class DetailTab { Matches, Table }
|
||||
|
||||
@Composable
|
||||
private fun SchedulePanelHeader(
|
||||
season: String?,
|
||||
selectedTab: DetailTab,
|
||||
hasTable: Boolean,
|
||||
onSelected: (DetailTab) -> Unit,
|
||||
) {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(8.dp), shadowElevation = 2.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Aktueller Spielplan", style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
season?.let { Text("Saison ${seasonLabel(it)}", color = Accent500) }
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
DetailTabButton("Matches", selectedTab == DetailTab.Matches) { onSelected(DetailTab.Matches) }
|
||||
if (hasTable) {
|
||||
DetailTabButton("Tabelle", selectedTab == DetailTab.Table) { onSelected(DetailTab.Table) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailTabButton(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
Surface(
|
||||
color = if (selected) Color.White else Accent100,
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
shadowElevation = if (selected) 2.dp else 0.dp,
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = if (selected) Primary700 else Accent500,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(horizontal = 15.dp, vertical = 9.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TeamHeader(team: Mannschaft) {
|
||||
Column(Modifier.fillMaxWidth().background(Primary600, RoundedCornerShape(8.dp)).padding(20.dp)) {
|
||||
Text(team.mannschaft, style = MaterialTheme.typography.displayLarge, color = Color.White)
|
||||
Text(team.liga, color = Primary100, modifier = Modifier.padding(top = 5.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoCard(title: String, content: @Composable ColumnScope.() -> Unit) {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(8.dp), shadowElevation = 2.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TeamInfo(label: String, value: String) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Text("$label:", color = Accent500, modifier = Modifier.weight(0.38f))
|
||||
Text(value.ifBlank { "-" }, color = Accent900, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(0.62f))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MatchCard(game: SpielDto) {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(8.dp), shadowElevation = 1.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(13.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(game.termin, color = Accent900, fontWeight = FontWeight.SemiBold)
|
||||
val result = if (game.spieleHeim.isNotBlank() || game.spieleGast.isNotBlank()) "${game.spieleHeim}:${game.spieleGast}" else "-"
|
||||
Text(result, color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
Text("${game.heimMannschaft} - ${game.gastMannschaft}", color = Accent900)
|
||||
Text("${game.altersklasse} / ${game.staffel.removePrefix("E")}", color = Accent500, style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TableLegend() {
|
||||
Surface(color = Accent100, shape = RoundedCornerShape(6.dp)) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||
val compact = maxWidth < 560.dp
|
||||
if (compact) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Platz / Mannschaft", color = Accent500, style = MaterialTheme.typography.labelSmall)
|
||||
TableMetricsHeader()
|
||||
}
|
||||
} else {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Platz", color = Accent500, style = MaterialTheme.typography.labelSmall, modifier = Modifier.width(55.dp))
|
||||
Text("Mannschaft", color = Accent500, style = MaterialTheme.typography.labelSmall, modifier = Modifier.weight(1f))
|
||||
TableMetricsHeader()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TableRow(row: LeagueTableRowDto) {
|
||||
val ourTeam = row.teamName.contains("Harheimer TC", ignoreCase = true)
|
||||
Surface(
|
||||
color = if (ourTeam) Primary100 else Color.White,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
shadowElevation = 1.dp,
|
||||
) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp)) {
|
||||
val compact = maxWidth < 560.dp
|
||||
Column(verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
if (compact) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
Text("${row.rank ?: "-"}.", color = Accent900, fontWeight = FontWeight.SemiBold)
|
||||
Text(row.teamName.ifBlank { "-" }, color = Accent900, fontWeight = if (ourTeam) FontWeight.Bold else FontWeight.Normal)
|
||||
Movement(row.movement)
|
||||
}
|
||||
TableMetrics(row)
|
||||
} else {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("${row.rank ?: "-"}.", color = Accent900, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(55.dp))
|
||||
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
Text(row.teamName.ifBlank { "-" }, color = Accent900, fontWeight = if (ourTeam) FontWeight.Bold else FontWeight.Normal)
|
||||
Movement(row.movement)
|
||||
}
|
||||
TableStanding(row)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"Sätze ${formatSets(row)} Bälle ${formatGames(row)}",
|
||||
color = Accent500,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Movement(value: String?) {
|
||||
when (value) {
|
||||
"rise" -> Text("↑", color = Color(0xFF15803D))
|
||||
"fall" -> Text("↓", color = Primary700)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TableMetricsHeader() {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
TableCell("Sp.", 48.dp, Accent500)
|
||||
TableCell("S", 38.dp, Accent500)
|
||||
TableCell("U", 38.dp, Accent500)
|
||||
TableCell("N", 38.dp, Accent500)
|
||||
TableCell("Punkte", 66.dp, Accent500)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TableStanding(row: LeagueTableRowDto) = TableMetrics(row)
|
||||
|
||||
@Composable
|
||||
private fun TableMetrics(row: LeagueTableRowDto) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
TableCell((row.meetings ?: "-").toString(), 48.dp, Accent900)
|
||||
TableCell((row.won ?: 0).toString(), 38.dp, Accent900)
|
||||
TableCell((row.tied ?: 0).toString(), 38.dp, Accent900)
|
||||
TableCell((row.lost ?: 0).toString(), 38.dp, Accent900)
|
||||
TableCell(formatPoints(row), 66.dp, Accent900)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TableCell(value: String, width: androidx.compose.ui.unit.Dp, color: Color) {
|
||||
Text(
|
||||
value,
|
||||
color = color,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.width(width),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BackLink(navController: NavController, visible: Boolean) {
|
||||
if (visible) TextButton(onClick = { navController.popBackStack() }) { Text("< Startseite", color = Primary600) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Loading() {
|
||||
Column(Modifier.fillMaxWidth().padding(30.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(color = Primary600)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorPanel(message: String, retry: () -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(message, color = Primary700)
|
||||
Button(onClick = retry, colors = ButtonDefaults.buttonColors(containerColor = Primary600)) { Text("Erneut laden") }
|
||||
}
|
||||
}
|
||||
|
||||
private fun seasonLabel(value: String): String =
|
||||
Regex("^(\\d{2})--(\\d{2})$").matchEntire(value)?.let { "20${it.groupValues[1]}/${it.groupValues[2]}" } ?: value
|
||||
|
||||
private fun formatSets(row: LeagueTableRowDto): String = "${row.setsWon ?: 0}:${row.setsLost ?: 0}"
|
||||
private fun formatGames(row: LeagueTableRowDto): String = "${row.gamesWon ?: 0}:${row.gamesLost ?: 0}"
|
||||
private fun formatPoints(row: LeagueTableRowDto): String = "${row.pointsWon ?: 0}:${row.pointsLost ?: 0}"
|
||||
@@ -0,0 +1,139 @@
|
||||
package de.harheimertc.ui.screens.mannschaften
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.SpielDto
|
||||
import de.harheimertc.data.LeagueTableRowDto
|
||||
import de.harheimertc.repositories.Mannschaft
|
||||
import de.harheimertc.repositories.MannschaftenRepository
|
||||
import de.harheimertc.repositories.SpielplanRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class MannschaftenUiState(
|
||||
val loading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val teams: List<Mannschaft> = emptyList(),
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class MannschaftenViewModel @Inject constructor(
|
||||
private val repository: MannschaftenRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(MannschaftenUiState())
|
||||
val state: StateFlow<MannschaftenUiState> = _state
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_state.value = MannschaftenUiState(loading = true)
|
||||
repository.fetchMannschaften()
|
||||
.onSuccess { _state.value = MannschaftenUiState(loading = false, teams = it) }
|
||||
.onFailure { _state.value = MannschaftenUiState(loading = false, error = "Mannschaften konnten nicht geladen werden.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class MannschaftDetailUiState(
|
||||
val loading: Boolean = true,
|
||||
val matchesError: String? = null,
|
||||
val team: Mannschaft? = null,
|
||||
val matches: List<SpielDto> = emptyList(),
|
||||
val season: String? = null,
|
||||
val tableLoading: Boolean = false,
|
||||
val tableError: String? = null,
|
||||
val tableRows: List<LeagueTableRowDto> = emptyList(),
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class MannschaftDetailViewModel @Inject constructor(
|
||||
private val mannschaftenRepository: MannschaftenRepository,
|
||||
private val spielplanRepository: SpielplanRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(MannschaftDetailUiState())
|
||||
val state: StateFlow<MannschaftDetailUiState> = _state
|
||||
private var loadedSlug: String? = null
|
||||
|
||||
fun load(slug: String) {
|
||||
if (loadedSlug == slug) return
|
||||
loadedSlug = slug
|
||||
viewModelScope.launch {
|
||||
_state.value = MannschaftDetailUiState(loading = true)
|
||||
val team = mannschaftenRepository.fetchMannschaften().getOrDefault(emptyList()).find { it.slug == slug }
|
||||
if (team == null) {
|
||||
_state.value = MannschaftDetailUiState(loading = false, matchesError = "Mannschaft nicht gefunden.")
|
||||
return@launch
|
||||
}
|
||||
spielplanRepository.fetchSpielplan()
|
||||
.onSuccess { plan ->
|
||||
_state.value = MannschaftDetailUiState(
|
||||
loading = false,
|
||||
team = team,
|
||||
matches = plan.data.filter { matchesTeam(it, team.mannschaft) },
|
||||
season = plan.season,
|
||||
)
|
||||
if (team.informationenLink.isNotBlank()) {
|
||||
loadTable(team, plan.season)
|
||||
}
|
||||
}
|
||||
.onFailure {
|
||||
_state.value = MannschaftDetailUiState(
|
||||
loading = false,
|
||||
team = team,
|
||||
matchesError = "Der aktuelle Spielplan konnte nicht geladen werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadTable(team: Mannschaft, season: String?) {
|
||||
_state.value = _state.value.copy(tableLoading = true, tableError = null)
|
||||
spielplanRepository.fetchTeamTable(team.mannschaft, season)
|
||||
.onSuccess { response ->
|
||||
_state.value = _state.value.copy(
|
||||
tableLoading = false,
|
||||
tableRows = response.table?.table?.leagueTable.orEmpty(),
|
||||
)
|
||||
}
|
||||
.onFailure {
|
||||
_state.value = _state.value.copy(
|
||||
tableLoading = false,
|
||||
tableError = "Tabelle konnte nicht geladen werden.",
|
||||
tableRows = emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun matchesTeam(game: SpielDto, cmsName: String): Boolean {
|
||||
val variant = when (cmsName) {
|
||||
"Erwachsene 1" -> "harheimer tc"
|
||||
"Erwachsene 2" -> "harheimer tc ii"
|
||||
"Erwachsene 3" -> "harheimer tc iii"
|
||||
"Erwachsene 4" -> "harheimer tc iv"
|
||||
"Erwachsene 5" -> "harheimer tc v"
|
||||
"Jugendmannschaft", "Jugend I" -> "harheimer tc"
|
||||
else -> return false
|
||||
}
|
||||
fun exact(value: String): Boolean =
|
||||
if (variant == "harheimer tc") {
|
||||
value == variant || (value.startsWith("$variant ") && !Regex("harheimer tc\\s+[ivx]+").containsMatchIn(value))
|
||||
} else value == variant || value.startsWith("$variant ")
|
||||
|
||||
val home = game.heimMannschaft.lowercase()
|
||||
val away = game.gastMannschaft.lowercase()
|
||||
if (!exact(home) && !exact(away)) return false
|
||||
return if (cmsName.startsWith("Erwachsene")) {
|
||||
(exact(home) && game.heimAltersklasse.contains("Erwachsene", true)) ||
|
||||
(exact(away) && game.gastAltersklasse.contains("Erwachsene", true))
|
||||
} else {
|
||||
game.heimAltersklasse.contains("Jugend", true) || game.gastAltersklasse.contains("Jugend", true) ||
|
||||
home.contains("jugend") || away.contains("jugend")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package de.harheimertc.ui.screens.membership
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent700
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary100
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
import de.harheimertc.ui.theme.Primary900
|
||||
|
||||
@Composable
|
||||
fun MembershipScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean = true,
|
||||
viewModel: MembershipViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val form = state.form
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
item {
|
||||
if (showBackNavigation) {
|
||||
TextButton(onClick = { navController.popBackStack() }) {
|
||||
Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
Text("Mitgliedschaft", style = MaterialTheme.typography.displayLarge, color = Accent900, modifier = Modifier.padding(top = 14.dp))
|
||||
Text(
|
||||
"Werden Sie Teil unserer Tischtennis-Familie.",
|
||||
color = Accent500,
|
||||
modifier = Modifier.padding(top = 7.dp, bottom = 10.dp),
|
||||
)
|
||||
}
|
||||
item {
|
||||
InfoCard(
|
||||
title = "Vereinssatzung",
|
||||
text = "Unsere aktuelle Vereinssatzung und der Mitgliedsantrag stehen auf der Website als PDF bereit.",
|
||||
)
|
||||
}
|
||||
item {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 3.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(13.dp)) {
|
||||
Text("Beitrittserklärung", style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
FormHeading("Persönliche Daten")
|
||||
TextInput("Vorname *", form.vorname) { viewModel.update(form.copy(vorname = it)) }
|
||||
TextInput("Nachname *", form.nachname) { viewModel.update(form.copy(nachname = it)) }
|
||||
TextInput("Straße und Hausnummer *", form.strasse) { viewModel.update(form.copy(strasse = it)) }
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
TextInput("PLZ *", form.plz, Modifier.weight(0.38f), KeyboardType.Number) { viewModel.update(form.copy(plz = it)) }
|
||||
TextInput("Wohnort *", form.ort, Modifier.weight(0.62f)) { viewModel.update(form.copy(ort = it)) }
|
||||
}
|
||||
TextInput("Geburtsdatum * (JJJJ-MM-TT)", form.geburtsdatum) { viewModel.update(form.copy(geburtsdatum = it)) }
|
||||
TextInput("E-Mail *", form.email, keyboard = KeyboardType.Email) { viewModel.update(form.copy(email = it)) }
|
||||
TextInput("Telefon (Mobil)", form.telefon, keyboard = KeyboardType.Phone) { viewModel.update(form.copy(telefon = it)) }
|
||||
FormHeading("Mitgliedschaftsart")
|
||||
ChoiceRow("Aktives Mitglied", form.art == "aktiv") { viewModel.update(form.copy(art = "aktiv")) }
|
||||
ChoiceRow("Passives Mitglied", form.art == "passiv") { viewModel.update(form.copy(art = "passiv")) }
|
||||
FeeInfo()
|
||||
AgreementRow("Hierzu erteile ich das SEPA-Lastschriftmandat. *", form.lastschrift) {
|
||||
viewModel.update(form.copy(lastschrift = it))
|
||||
}
|
||||
FormHeading("Bankdaten für SEPA-Lastschrift")
|
||||
TextInput("Kontoinhaber *", form.kontoinhaber) { viewModel.update(form.copy(kontoinhaber = it)) }
|
||||
TextInput("IBAN *", form.iban) { viewModel.update(form.copy(iban = it)) }
|
||||
TextInput("BIC", form.bic) { viewModel.update(form.copy(bic = it)) }
|
||||
TextInput("Kreditinstitut", form.bank) { viewModel.update(form.copy(bank = it)) }
|
||||
FormHeading("Datenschutz und Vereinssatzung")
|
||||
AgreementRow("Ich willige in die erforderliche Verarbeitung und Veröffentlichung gemäß Antrag ein. *", form.datenschutz) {
|
||||
viewModel.update(form.copy(datenschutz = it))
|
||||
}
|
||||
AgreementRow("Ich erkenne die Vereinssatzung an. *", form.satzung) {
|
||||
viewModel.update(form.copy(satzung = it))
|
||||
}
|
||||
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
state.message?.let { Text(it, color = Color(0xFF166534), fontWeight = FontWeight.SemiBold) }
|
||||
Button(onClick = viewModel::submit, enabled = !state.sending, modifier = Modifier.fillMaxWidth()) {
|
||||
if (state.sending) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = Color.White)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
Text(if (state.sending) "Formular wird erstellt..." else "Beitrittsformular erstellen")
|
||||
}
|
||||
state.pdfUri?.let { uri ->
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(Uri.parse(uri), "application/pdf")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
},
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Erstelltes PDF öffnen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Surface(color = Primary600, shape = RoundedCornerShape(14.dp)) {
|
||||
Column(Modifier.fillMaxWidth().padding(22.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Noch Fragen zur Mitgliedschaft?", color = Color.White, style = MaterialTheme.typography.titleLarge)
|
||||
Text("Kontaktieren Sie uns - wir beraten Sie gerne persönlich.", color = Primary100, modifier = Modifier.padding(vertical = 12.dp))
|
||||
OutlinedButton(onClick = { navController.navigate(Destinations.Contact.route) }) {
|
||||
Text("Jetzt Kontakt aufnehmen")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FormHeading(text: String) {
|
||||
Text(text, fontWeight = FontWeight.SemiBold, color = Accent900, modifier = Modifier.padding(top = 12.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TextInput(
|
||||
label: String,
|
||||
value: String,
|
||||
modifier: Modifier = Modifier.fillMaxWidth(),
|
||||
keyboard: KeyboardType = KeyboardType.Text,
|
||||
onChange: (String) -> Unit,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onChange,
|
||||
label = { Text(label) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = keyboard),
|
||||
modifier = modifier,
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChoiceRow(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(selected = selected, onClick = onClick)
|
||||
Text(label, color = Accent700)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgreementRow(label: String, selected: Boolean, onChange: (Boolean) -> Unit) {
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
Checkbox(checked = selected, onCheckedChange = onChange)
|
||||
Text(label, color = Accent700, modifier = Modifier.padding(top = 12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeeInfo() {
|
||||
Surface(color = Color(0xFFF4F4F5), shape = RoundedCornerShape(8.dp)) {
|
||||
Column(Modifier.fillMaxWidth().padding(13.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("Jährlicher Mitgliedsbeitrag", fontWeight = FontWeight.SemiBold, color = Accent900)
|
||||
Text("120 EUR Erwachsene | 72 EUR Jugendliche | 30 EUR passive Mitglieder", color = Accent700)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoCard(title: String, text: String) {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 2.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(18.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
Text(text, color = Accent500, modifier = Modifier.padding(top = 7.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package de.harheimertc.ui.screens.membership
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.MembershipRequest
|
||||
import de.harheimertc.repositories.MembershipRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class MembershipFormState(
|
||||
val vorname: String = "",
|
||||
val nachname: String = "",
|
||||
val strasse: String = "",
|
||||
val plz: String = "",
|
||||
val ort: String = "",
|
||||
val geburtsdatum: String = "",
|
||||
val email: String = "",
|
||||
val telefon: String = "",
|
||||
val art: String = "aktiv",
|
||||
val kontoinhaber: String = "",
|
||||
val iban: String = "",
|
||||
val bic: String = "",
|
||||
val bank: String = "",
|
||||
val lastschrift: Boolean = false,
|
||||
val datenschutz: Boolean = false,
|
||||
val satzung: Boolean = false,
|
||||
)
|
||||
|
||||
data class MembershipUiState(
|
||||
val form: MembershipFormState = MembershipFormState(),
|
||||
val sending: Boolean = false,
|
||||
val message: String? = null,
|
||||
val error: String? = null,
|
||||
val pdfUri: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class MembershipViewModel @Inject constructor(private val repository: MembershipRepository) : ViewModel() {
|
||||
private val _state = MutableStateFlow(MembershipUiState())
|
||||
val state: StateFlow<MembershipUiState> = _state
|
||||
|
||||
fun update(form: MembershipFormState) {
|
||||
_state.value = _state.value.copy(form = form, error = null)
|
||||
}
|
||||
|
||||
fun submit() {
|
||||
val form = _state.value.form
|
||||
validate(form)?.let {
|
||||
_state.value = _state.value.copy(error = it)
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(sending = true, error = null, message = null)
|
||||
val request = MembershipRequest(
|
||||
vorname = form.vorname.trim(),
|
||||
nachname = form.nachname.trim(),
|
||||
strasse = form.strasse.trim(),
|
||||
plz = form.plz.trim(),
|
||||
ort = form.ort.trim(),
|
||||
geburtsdatum = form.geburtsdatum.trim(),
|
||||
email = form.email.trim(),
|
||||
telefon_mobil = form.telefon.trim().takeIf(String::isNotBlank),
|
||||
mitgliedschaftsart = form.art,
|
||||
lastschrift_erlaubt = form.lastschrift,
|
||||
kontoinhaber = form.kontoinhaber.trim(),
|
||||
iban = form.iban.trim(),
|
||||
bic = form.bic.trim().takeIf(String::isNotBlank),
|
||||
bank = form.bank.trim().takeIf(String::isNotBlank),
|
||||
datenschutz_einverstanden = form.datenschutz,
|
||||
satzung_anerkannt = form.satzung,
|
||||
)
|
||||
repository.submit(request)
|
||||
.onSuccess { document ->
|
||||
_state.value = _state.value.copy(sending = false, message = document.message, pdfUri = document.uri)
|
||||
}
|
||||
.onFailure {
|
||||
_state.value = _state.value.copy(sending = false, error = "Beitrittsformular konnte nicht erstellt werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun validate(form: MembershipFormState): String? = when {
|
||||
listOf(form.vorname, form.nachname, form.strasse, form.plz, form.ort, form.geburtsdatum, form.email, form.kontoinhaber, form.iban)
|
||||
.any { it.isBlank() } -> "Bitte alle Pflichtfelder ausfüllen."
|
||||
!form.plz.matches(Regex("\\d{5}")) -> "Bitte eine gültige fünfstellige PLZ eingeben."
|
||||
!form.email.contains("@") -> "Bitte eine gültige E-Mail-Adresse eingeben."
|
||||
!form.lastschrift || !form.datenschutz || !form.satzung -> "Bitte die erforderlichen Einwilligungen bestätigen."
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package de.harheimertc.ui.screens.publicpages
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.ConfigResponse
|
||||
import de.harheimertc.repositories.MeisterschaftResult
|
||||
import de.harheimertc.repositories.PublicPagesRepository
|
||||
import de.harheimertc.repositories.Spielsystem
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class PublicConfigUiState(
|
||||
val loading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val config: ConfigResponse? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class PublicConfigViewModel @Inject constructor(private val repository: PublicPagesRepository) : ViewModel() {
|
||||
private val _state = MutableStateFlow(PublicConfigUiState())
|
||||
val state: StateFlow<PublicConfigUiState> = _state
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_state.value = PublicConfigUiState()
|
||||
repository.fetchConfig()
|
||||
.onSuccess { _state.value = PublicConfigUiState(loading = false, config = it) }
|
||||
.onFailure { _state.value = PublicConfigUiState(loading = false, error = "Inhalte konnten nicht geladen werden.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class SpielsystemeUiState(
|
||||
val loading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val systems: List<Spielsystem> = emptyList(),
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class SpielsystemeViewModel @Inject constructor(private val repository: PublicPagesRepository) : ViewModel() {
|
||||
private val _state = MutableStateFlow(SpielsystemeUiState())
|
||||
val state: StateFlow<SpielsystemeUiState> = _state
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_state.value = SpielsystemeUiState()
|
||||
repository.fetchSpielsysteme()
|
||||
.onSuccess { _state.value = SpielsystemeUiState(loading = false, systems = it) }
|
||||
.onFailure { _state.value = SpielsystemeUiState(loading = false, error = "Spielsysteme konnten nicht geladen werden.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class VereinsmeisterschaftenUiState(
|
||||
val loading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val results: List<MeisterschaftResult> = emptyList(),
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class VereinsmeisterschaftenViewModel @Inject constructor(private val repository: PublicPagesRepository) : ViewModel() {
|
||||
private val _state = MutableStateFlow(VereinsmeisterschaftenUiState())
|
||||
val state: StateFlow<VereinsmeisterschaftenUiState> = _state
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_state.value = VereinsmeisterschaftenUiState()
|
||||
repository.fetchVereinsmeisterschaften()
|
||||
.onSuccess { _state.value = VereinsmeisterschaftenUiState(loading = false, results = it) }
|
||||
.onFailure { _state.value = VereinsmeisterschaftenUiState(loading = false, error = "Vereinsmeisterschaften konnten nicht geladen werden.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package de.harheimertc.ui.screens.publicpages
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.widget.TextView
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.text.HtmlCompat
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
|
||||
@Composable
|
||||
internal fun PublicPage(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
title: String,
|
||||
subtitle: String? = null,
|
||||
content: LazyListScope.() -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||
contentPadding = PaddingValues(horizontal = 18.dp, vertical = 22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
item {
|
||||
if (showBackNavigation) {
|
||||
TextButton(onClick = { navController.popBackStack() }) { Text("< Startseite", color = Primary600) }
|
||||
}
|
||||
Text(title, style = MaterialTheme.typography.displayLarge, color = Accent900, modifier = Modifier.padding(top = 18.dp))
|
||||
subtitle?.let { Text(it, color = Accent500, modifier = Modifier.padding(top = 8.dp)) }
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PublicCard(title: String? = null, content: @Composable () -> Unit) {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(8.dp), shadowElevation = 2.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(17.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
title?.let { Text(it, style = MaterialTheme.typography.titleLarge, color = Accent900) }
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PublicLoading() {
|
||||
Column(Modifier.fillMaxWidth().padding(28.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(color = Primary600)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PublicError(message: String, retry: () -> Unit) {
|
||||
PublicCard {
|
||||
Text(message, color = MaterialTheme.colorScheme.error)
|
||||
Button(onClick = retry, colors = ButtonDefaults.buttonColors(containerColor = Primary600)) { Text("Erneut laden") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun HtmlContent(html: String) {
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
factory = { context ->
|
||||
TextView(context).apply {
|
||||
textSize = 17f
|
||||
setTextColor(android.graphics.Color.rgb(63, 63, 70))
|
||||
movementMethod = LinkMovementMethod.getInstance()
|
||||
setLineSpacing(0f, 1.2f)
|
||||
}
|
||||
},
|
||||
update = { textView ->
|
||||
textView.text = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
internal fun Context.openPublicUri(value: String) {
|
||||
val uri = if (value.startsWith("http://") || value.startsWith("https://") || value.startsWith("mailto:")) {
|
||||
value
|
||||
} else {
|
||||
BuildConfig.API_BASE_URL.trimEnd('/') + "/" + value.trimStart('/')
|
||||
}
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(uri)))
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package de.harheimertc.ui.screens.publicpages
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
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.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.layout.ContentScale
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.repositories.MeisterschaftResult
|
||||
import de.harheimertc.repositories.Spielsystem
|
||||
import de.harheimertc.ui.theme.Accent100
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent700
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary100
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
|
||||
@Composable
|
||||
fun SpielsystemeScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: SpielsystemeViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
var selectedCategory by rememberSaveable { mutableStateOf("Alle Kategorien") }
|
||||
val categories = state.systems.map(Spielsystem::category).filter(String::isNotBlank).distinct().sorted()
|
||||
val displayed = if (selectedCategory == "Alle Kategorien") state.systems else state.systems.filter { it.category == selectedCategory }
|
||||
PublicPage(
|
||||
navController,
|
||||
showBackNavigation,
|
||||
"Spielsysteme",
|
||||
"Übersicht der verschiedenen Mannschafts-Spielsysteme im Tischtennis",
|
||||
) {
|
||||
when {
|
||||
state.loading -> item { PublicLoading() }
|
||||
state.error != null -> item { PublicError(state.error.orEmpty(), viewModel::load) }
|
||||
else -> {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
(categories + "Alle Kategorien").forEach { category ->
|
||||
CategoryButton(category, category == selectedCategory) { selectedCategory = category }
|
||||
}
|
||||
}
|
||||
}
|
||||
displayed.forEach { system -> item { SpielsystemCard(system) } }
|
||||
item {
|
||||
Surface(color = Primary600, shape = RoundedCornerShape(8.dp)) {
|
||||
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Weitere Informationen", style = MaterialTheme.typography.titleLarge, color = Color.White)
|
||||
Text(
|
||||
"Die Spielsysteme werden je nach Liga und Verband unterschiedlich eingesetzt. Regionale Ligen verwenden meist das Bundessystem oder das Braunschweiger System.",
|
||||
color = Primary100,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CategoryButton(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = if (selected) Primary600 else Color.White, contentColor = if (selected) Color.White else Accent700),
|
||||
shape = RoundedCornerShape(7.dp),
|
||||
) { Text(label) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SpielsystemCard(system: Spielsystem) {
|
||||
PublicCard {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(system.name, style = MaterialTheme.typography.titleLarge, color = Accent900, modifier = Modifier.weight(1f))
|
||||
Surface(color = Primary100, shape = RoundedCornerShape(20.dp)) {
|
||||
Text(system.category, color = Primary600, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 9.dp, vertical = 5.dp))
|
||||
}
|
||||
}
|
||||
Text(system.teamSize, color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||
Text(system.description, color = Accent700)
|
||||
Text("Spielabfolge: ${system.sequence}", color = Accent500)
|
||||
Text("Anzahl Spiele: ${system.gameCount}", color = Accent500)
|
||||
Text("Besonderheiten: ${system.features}", color = Accent500)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VereinsmeisterschaftenScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: VereinsmeisterschaftenViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
var selectedYear by rememberSaveable { mutableStateOf("Alle Jahre") }
|
||||
var selectedPortrait by rememberSaveable { mutableStateOf<Pair<String, String>?>(null) }
|
||||
val years = state.results.map(MeisterschaftResult::year).filter(String::isNotBlank).distinct().sortedDescending()
|
||||
val displayed = if (selectedYear == "Alle Jahre") state.results else state.results.filter { it.year == selectedYear }
|
||||
PublicPage(
|
||||
navController,
|
||||
showBackNavigation,
|
||||
"Vereinsmeisterschaften",
|
||||
"Die Ergebnisse unserer Vereinsmeisterschaften der letzten Jahre",
|
||||
) {
|
||||
when {
|
||||
state.loading -> item { PublicLoading() }
|
||||
state.error != null -> item { PublicError(state.error.orEmpty(), viewModel::load) }
|
||||
else -> {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
(years + "Alle Jahre").forEach { year ->
|
||||
CategoryButton(year, selectedYear == year) { selectedYear = year }
|
||||
}
|
||||
}
|
||||
}
|
||||
displayed.groupBy(MeisterschaftResult::year).toSortedMap(compareByDescending { it }).forEach { (year, results) ->
|
||||
item { ChampionshipYearCard(year, results) { image, name -> selectedPortrait = image to name } }
|
||||
}
|
||||
item {
|
||||
val singleWinners = state.results.count { it.rank == "1" && it.category.contains("Einzel") }
|
||||
val doublesWinners = state.results.count { it.rank == "1" && it.category == "Doppel" }
|
||||
Surface(color = Primary600, shape = RoundedCornerShape(8.dp)) {
|
||||
Row(Modifier.fillMaxWidth().padding(18.dp), horizontalArrangement = Arrangement.SpaceAround) {
|
||||
Statistic("${years.size}", "Jahre")
|
||||
Statistic("$singleWinners", "Einzelgewinner")
|
||||
Statistic("$doublesWinners", "Doppelgewinner")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
selectedPortrait?.let { (image, name) ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { selectedPortrait = null },
|
||||
confirmButton = {
|
||||
Button(onClick = { selectedPortrait = null }) { Text("Schließen") }
|
||||
},
|
||||
title = { Text(name) },
|
||||
text = {
|
||||
AsyncImage(
|
||||
model = "${BuildConfig.API_BASE_URL.trimEnd('/')}/api/personen/$image",
|
||||
contentDescription = name,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChampionshipYearCard(year: String, results: List<MeisterschaftResult>, onImage: (String, String) -> Unit) {
|
||||
PublicCard(year) {
|
||||
results.map(MeisterschaftResult::note).firstOrNull(String::isNotBlank)?.let { note ->
|
||||
Surface(color = Color(0xFFFEF3C7), shape = RoundedCornerShape(6.dp)) {
|
||||
Text(note, color = Color(0xFF92400E), modifier = Modifier.fillMaxWidth().padding(10.dp))
|
||||
}
|
||||
}
|
||||
results.filter { it.category.isNotBlank() }.groupBy(MeisterschaftResult::category).forEach { (category, placements) ->
|
||||
Text(category, color = Accent900, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = 6.dp))
|
||||
placements.sortedBy { it.rank.toIntOrNull() ?: Int.MAX_VALUE }.forEach { result ->
|
||||
val names = listOf(result.playerOne, result.playerTwo).filter(String::isNotBlank).joinToString(" / ")
|
||||
Surface(color = if (result.rank == "1") Color(0xFFFEF3C7) else Accent100, shape = RoundedCornerShape(6.dp)) {
|
||||
Row(Modifier.fillMaxWidth().padding(10.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("${result.rank}.", color = Accent900, fontWeight = FontWeight.Bold)
|
||||
result.imageOne.takeIf(String::isNotBlank)?.let { image ->
|
||||
Portrait(image, result.playerOne) { onImage(image, result.playerOne) }
|
||||
}
|
||||
Text(names, color = Accent900, modifier = Modifier.weight(1f))
|
||||
result.imageTwo.takeIf(String::isNotBlank)?.let { image ->
|
||||
Portrait(image, result.playerTwo) { onImage(image, result.playerTwo) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Portrait(filename: String, name: String, onClick: () -> Unit) {
|
||||
AsyncImage(
|
||||
model = "${BuildConfig.API_BASE_URL.trimEnd('/')}/api/personen/$filename?width=48&height=48",
|
||||
contentDescription = name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(32.dp).clip(CircleShape).clickable(onClick = onClick),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Statistic(value: String, label: String) {
|
||||
Column {
|
||||
Text(value, color = Color.White, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
Text(label, color = Primary100, style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RegelnScreen(navController: NavController, showBackNavigation: Boolean) {
|
||||
val context = LocalContext.current
|
||||
val dttbUrl = "https://www.tischtennis.de/dttb/regeln-satzung/satzung-ordnungen.html"
|
||||
PublicPage(
|
||||
navController,
|
||||
showBackNavigation,
|
||||
"Tischtennis-Regeln",
|
||||
"Offizielle Regeln und Bestimmungen für den Tischtennissport",
|
||||
) {
|
||||
item {
|
||||
PublicCard("Offizielles ITTF-Reglement") {
|
||||
Text("Die offiziellen Regeln des Internationalen Tischtennis-Verbands gelten weltweit für Wettkämpfe und Turniere.", color = Accent700)
|
||||
Button(onClick = { context.openPublicUri(dttbUrl) }, colors = ButtonDefaults.buttonColors(containerColor = Primary600)) {
|
||||
Text("Offizielle Regeln öffnen")
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
PublicCard("Tischtennis-Regeln Light") {
|
||||
Text("Eine kompakte Übersicht der wichtigsten Regeln für Einsteiger und Hobbyspieler.", color = Accent700)
|
||||
Button(
|
||||
onClick = { context.openPublicUri("/documents/Tischtennisregeln%20light.pdf") },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
|
||||
) { Text("Regeln Light als PDF öffnen") }
|
||||
}
|
||||
}
|
||||
item { Text("Grundregeln im Überblick", style = MaterialTheme.typography.titleLarge, color = Accent900) }
|
||||
listOf(
|
||||
"Spielfeld" to "Tisch: 2,74 m x 1,525 m, Höhe: 76 cm. Netz: 15,25 cm hoch.",
|
||||
"Ball" to "Durchmesser: 40 mm. Gewicht: 2,7 g.",
|
||||
"Schläger" to "Belag: schwarz und farbig. Holz: mindestens 85 Prozent.",
|
||||
"Aufschlag" to "Ball muss sichtbar mindestens 16 cm hochgeworfen werden.",
|
||||
"Satz" to "Gewinn bei 11 Punkten mit mindestens 2 Punkten Vorsprung.",
|
||||
"Spiel" to "Best of 5 oder 7 Sätze; Aufschlagwechsel alle 2 Punkte.",
|
||||
).forEach { rule ->
|
||||
item { PublicCard(rule.first) { Text(rule.second, color = Accent700) } }
|
||||
}
|
||||
item {
|
||||
Surface(color = Primary600, shape = RoundedCornerShape(8.dp)) {
|
||||
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("Weitere Informationen", style = MaterialTheme.typography.titleLarge, color = Color.White)
|
||||
Text("Für regionale Turniere können ergänzende Bestimmungen gelten.", color = Primary100)
|
||||
Button(onClick = { context.openPublicUri(dttbUrl) }) { Text("DTTB-Regeln und Ordnungen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package de.harheimertc.ui.screens.publicpages
|
||||
|
||||
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.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.data.ConfigResponse
|
||||
import de.harheimertc.data.BoardMemberDto
|
||||
import de.harheimertc.data.LinkSectionDto
|
||||
import de.harheimertc.repositories.linkSections
|
||||
import de.harheimertc.ui.theme.Accent100
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent700
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary100
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
|
||||
@Composable
|
||||
fun AboutScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: PublicConfigViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
CmsHtmlScreen(navController, showBackNavigation, "Über uns", state.config?.seiten?.ueberUns, state.loading, state.error, viewModel::load)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GeschichteScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: PublicConfigViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
CmsHtmlScreen(navController, showBackNavigation, "Geschichte", state.config?.seiten?.geschichte, state.loading, state.error, viewModel::load)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CmsHtmlScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
title: String,
|
||||
html: String?,
|
||||
loading: Boolean,
|
||||
error: String?,
|
||||
retry: () -> Unit,
|
||||
) {
|
||||
PublicPage(navController, showBackNavigation, title) {
|
||||
when {
|
||||
loading -> item { PublicLoading() }
|
||||
error != null -> item { PublicError(error, retry) }
|
||||
html.isNullOrBlank() -> item { Text("Für diese Seite ist derzeit kein Inhalt hinterlegt.", color = Accent500) }
|
||||
else -> item { PublicCard { HtmlContent(html) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SatzungScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: PublicConfigViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val context = LocalContext.current
|
||||
PublicPage(navController, showBackNavigation, "Satzung") {
|
||||
when {
|
||||
state.loading -> item { PublicLoading() }
|
||||
state.error != null -> item { PublicError(state.error.orEmpty(), viewModel::load) }
|
||||
else -> state.config?.seiten?.satzung?.let { satzung ->
|
||||
item {
|
||||
PublicCard {
|
||||
if (satzung.content.isNotBlank()) HtmlContent(satzung.content)
|
||||
if (satzung.pdfUrl.isNotBlank()) {
|
||||
Button(
|
||||
onClick = { context.openPublicUri(satzung.pdfUrl) },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
|
||||
) { Text("Satzung als PDF öffnen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VorstandScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: PublicConfigViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val context = LocalContext.current
|
||||
PublicPage(
|
||||
navController,
|
||||
showBackNavigation,
|
||||
"Vorstand",
|
||||
"Unser Vorstandsteam leitet den Harheimer TC.",
|
||||
) {
|
||||
when {
|
||||
state.loading -> item { PublicLoading() }
|
||||
state.error != null -> item { PublicError(state.error.orEmpty(), viewModel::load) }
|
||||
else -> {
|
||||
val vorstand = state.config?.vorstand
|
||||
listOf(
|
||||
"Vorsitzender" to vorstand?.vorsitzender,
|
||||
"Stellvertreter" to vorstand?.stellvertreter,
|
||||
"Kassenwart" to vorstand?.kassenwart,
|
||||
"Schriftführer" to vorstand?.schriftfuehrer,
|
||||
"Sportwart" to vorstand?.sportwart,
|
||||
"Jugendwart" to vorstand?.jugendwart,
|
||||
).filter { !it.second?.vorname.isNullOrBlank() }.forEach { (role, member) ->
|
||||
item { BoardMemberCard(role, member!!, onMail = { context.openPublicUri("mailto:$it") }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoardMemberCard(role: String, member: BoardMemberDto, onMail: (String) -> Unit) {
|
||||
PublicCard {
|
||||
member.imageFilename?.takeIf(String::isNotBlank)?.let { filename ->
|
||||
AsyncImage(
|
||||
model = "${BuildConfig.API_BASE_URL.trimEnd('/')}/api/personen/$filename?width=120&height=120",
|
||||
contentDescription = "${member.vorname} ${member.nachname}",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(64.dp).clip(CircleShape),
|
||||
)
|
||||
}
|
||||
Surface(color = Primary100, shape = RoundedCornerShape(6.dp)) {
|
||||
Text(role, color = Primary600, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp))
|
||||
}
|
||||
Text("${member.vorname} ${member.nachname}", style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
member.strasse.takeIf(String::isNotBlank)?.let { Text(it, color = Accent700) }
|
||||
if (member.plz.isNotBlank() || member.ort.isNotBlank()) Text("${member.plz} ${member.ort}".trim(), color = Accent700)
|
||||
member.telefon.takeIf(String::isNotBlank)?.let { Text("Tel. $it", color = Accent700) }
|
||||
member.email.takeIf(String::isNotBlank)?.let { email ->
|
||||
Button(
|
||||
onClick = { onMail(email) },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
|
||||
) { Text(email) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LinksScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: PublicConfigViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val context = LocalContext.current
|
||||
PublicPage(
|
||||
navController,
|
||||
showBackNavigation,
|
||||
"Links",
|
||||
"Nützliche Verweise rund um Tischtennis, Verbände, Ergebnisse und Partner.",
|
||||
) {
|
||||
when {
|
||||
state.loading -> item { PublicLoading() }
|
||||
else -> (state.config ?: ConfigResponse()).linkSections().forEach { section ->
|
||||
item { LinkSectionCard(section) { context.openPublicUri(it) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LinkSectionCard(section: LinkSectionDto, onOpen: (String) -> Unit) {
|
||||
PublicCard(section.title) {
|
||||
section.items.forEach { item ->
|
||||
Surface(color = Accent100, shape = RoundedCornerShape(6.dp)) {
|
||||
Column(Modifier.fillMaxWidth().padding(11.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Row {
|
||||
Text(
|
||||
item.label,
|
||||
color = Primary600,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 8.dp),
|
||||
)
|
||||
Button(
|
||||
onClick = { onOpen(item.href) },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
|
||||
) { Text("Öffnen") }
|
||||
}
|
||||
item.description.takeIf(String::isNotBlank)?.let { Text(it, color = Accent500) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package de.harheimertc.ui.screens.spielplan
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
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.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.data.SeasonDto
|
||||
import de.harheimertc.data.SpielDto
|
||||
import de.harheimertc.ui.theme.Accent100
|
||||
import de.harheimertc.ui.theme.Accent200
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent700
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary100
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun SpielplanScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean = true,
|
||||
viewModel: SpielplanViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
item {
|
||||
if (showBackNavigation) {
|
||||
TextButton(onClick = { navController.popBackStack() }) {
|
||||
Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
Text("Spielpläne", style = MaterialTheme.typography.displayLarge, color = Accent900, modifier = Modifier.padding(top = 15.dp))
|
||||
Text("Alle Spielpläne der Mannschaften", color = Accent500, modifier = Modifier.padding(top = 5.dp, bottom = 15.dp))
|
||||
}
|
||||
item {
|
||||
FilterPanel(
|
||||
state = state,
|
||||
onSeason = viewModel::selectSeason,
|
||||
onCompetition = viewModel::selectWettbewerb,
|
||||
onTeam = viewModel::selectTeam,
|
||||
onReload = { viewModel.load() },
|
||||
)
|
||||
}
|
||||
if (state.loading) {
|
||||
item { LoadingPlan() }
|
||||
} else if (state.error != null) {
|
||||
item {
|
||||
StatusPanel("Fehler beim Laden", state.error.orEmpty())
|
||||
Button(onClick = { viewModel.load() }, modifier = Modifier.fillMaxWidth()) { Text("Erneut versuchen") }
|
||||
}
|
||||
} else if (state.spiele.isEmpty()) {
|
||||
item { StatusPanel("Keine Spielpläne verfügbar", "Es wurden noch keine Spielplandaten hochgeladen.") }
|
||||
} else {
|
||||
item {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(9.dp), shadowElevation = 2.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(16.dp)) {
|
||||
Text("Spielplan", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, color = Accent900)
|
||||
Text(
|
||||
"${state.selectedWettbewerb.label} - ${state.filtered.size} von ${state.spiele.size} Einträgen",
|
||||
color = Accent500,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.filtered.isEmpty()) {
|
||||
item { StatusPanel("Keine Einträge", "Für die gewählte Filterung sind keine Spiele vorhanden.") }
|
||||
} else {
|
||||
items(state.filtered) { game -> MatchRow(game) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilterPanel(
|
||||
state: SpielplanUiState,
|
||||
onSeason: (String) -> Unit,
|
||||
onCompetition: (Wettbewerb) -> Unit,
|
||||
onTeam: (String) -> Unit,
|
||||
onReload: () -> Unit,
|
||||
) {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(10.dp), shadowElevation = 3.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(15.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
if (state.seasons.isNotEmpty()) {
|
||||
SelectMenu(
|
||||
label = "Saison",
|
||||
selected = state.seasons.firstOrNull { it.slug == state.selectedSeason }?.label ?: state.selectedSeason.orEmpty(),
|
||||
options = state.seasons,
|
||||
text = { it.label },
|
||||
onSelected = { onSeason(it.slug) },
|
||||
)
|
||||
}
|
||||
SelectMenu(
|
||||
label = "Wettbewerb",
|
||||
selected = state.selectedWettbewerb.label,
|
||||
options = Wettbewerb.entries.toList(),
|
||||
text = { it.label },
|
||||
onSelected = onCompetition,
|
||||
)
|
||||
SelectMenu(
|
||||
label = "Mannschaft",
|
||||
selected = state.selectedTeam,
|
||||
options = state.teams,
|
||||
text = { it },
|
||||
onSelected = onTeam,
|
||||
)
|
||||
Button(
|
||||
onClick = onReload,
|
||||
enabled = !state.loading,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Spielplan laden") }
|
||||
Text(
|
||||
"${state.selectedWettbewerb.label} - ${state.selectedTeam} (${state.filtered.size} von ${state.spiele.size} Einträgen)",
|
||||
color = Accent500,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun <T> SelectMenu(
|
||||
label: String,
|
||||
selected: String,
|
||||
options: List<T>,
|
||||
text: (T) -> String,
|
||||
onSelected: (T) -> Unit,
|
||||
) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
Column {
|
||||
Text(label, style = MaterialTheme.typography.labelSmall, color = Accent700)
|
||||
Box {
|
||||
OutlinedButton(onClick = { open = true }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(selected.ifBlank { "-" }, modifier = Modifier.weight(1f), textAlign = TextAlign.Start)
|
||||
Text("v")
|
||||
}
|
||||
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||
options.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(text(option)) },
|
||||
onClick = {
|
||||
open = false
|
||||
onSelected(option)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MatchRow(game: SpielDto) {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(9.dp), shadowElevation = 1.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(formatTermin(game.termin), fontWeight = FontWeight.SemiBold, color = Accent900)
|
||||
if (game.spieleHeim.isNotBlank() || game.spieleGast.isNotBlank()) {
|
||||
Text("${game.spieleHeim}:${game.spieleGast}", color = Primary600, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(game.heimMannschaft.ifBlank { "-" }, modifier = Modifier.weight(1f), color = Accent900)
|
||||
Text(" - ", color = Accent500)
|
||||
Text(
|
||||
game.gastMannschaft.ifBlank { "-" },
|
||||
modifier = Modifier.weight(1f),
|
||||
color = Accent900,
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
}
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(game.runde.orEmpty(), style = MaterialTheme.typography.labelSmall, color = Accent500)
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(game.altersklasse.ifBlank { "-" }, style = MaterialTheme.typography.labelSmall, color = Accent700)
|
||||
Text(formatStaffel(game.staffel), style = MaterialTheme.typography.labelSmall, color = Accent500)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingPlan() {
|
||||
Column(Modifier.fillMaxWidth().padding(40.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(color = Primary600)
|
||||
Text("Spielpläne werden geladen...", color = Accent500, modifier = Modifier.padding(top = 12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusPanel(title: String, body: String) {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(10.dp), shadowElevation = 2.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 34.dp, horizontal = 16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, color = Accent900)
|
||||
Text(body, color = Accent500, textAlign = TextAlign.Center, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatTermin(value: String): String = runCatching {
|
||||
val source = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.GERMANY)
|
||||
SimpleDateFormat("EEE dd.MM.yyyy, HH:mm", Locale.GERMANY).format(source.parse(value)!!)
|
||||
}.getOrDefault(value)
|
||||
|
||||
private fun formatStaffel(value: String): String =
|
||||
value.trim().removePrefix("E").trimStart()
|
||||
@@ -0,0 +1,102 @@
|
||||
package de.harheimertc.ui.screens.spielplan
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.SeasonDto
|
||||
import de.harheimertc.data.SpielDto
|
||||
import de.harheimertc.repositories.SpielplanRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
enum class Wettbewerb(val label: String) {
|
||||
Punktrunde("Punktrunde"),
|
||||
Pokal("Pokal"),
|
||||
Alle("Alle"),
|
||||
}
|
||||
|
||||
data class SpielplanUiState(
|
||||
val loading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val spiele: List<SpielDto> = emptyList(),
|
||||
val seasons: List<SeasonDto> = emptyList(),
|
||||
val selectedSeason: String? = null,
|
||||
val selectedWettbewerb: Wettbewerb = Wettbewerb.Punktrunde,
|
||||
val selectedTeam: String = "Gesamt",
|
||||
) {
|
||||
val teams: List<String>
|
||||
get() = listOf("Gesamt", "Erwachsene", "Nachwuchs") +
|
||||
spiele.flatMap { listOf(it.heimMannschaft, it.gastMannschaft) }
|
||||
.filter { it.contains("Harheimer TC", ignoreCase = true) }
|
||||
.distinct()
|
||||
.sorted()
|
||||
|
||||
val filtered: List<SpielDto>
|
||||
get() = spiele.filter(::matchesCompetition).filter(::matchesTeam)
|
||||
|
||||
private fun matchesCompetition(game: SpielDto): Boolean {
|
||||
val text = "${game.runde.orEmpty()} ${game.staffel} ${game.liga}".lowercase()
|
||||
val pokal = "pokal" in text
|
||||
return when (selectedWettbewerb) {
|
||||
Wettbewerb.Punktrunde -> !pokal
|
||||
Wettbewerb.Pokal -> pokal
|
||||
Wettbewerb.Alle -> true
|
||||
}
|
||||
}
|
||||
|
||||
private fun matchesTeam(game: SpielDto): Boolean {
|
||||
val homeHtc = game.heimMannschaft.contains("Harheimer TC", ignoreCase = true)
|
||||
val awayHtc = game.gastMannschaft.contains("Harheimer TC", ignoreCase = true)
|
||||
return when (selectedTeam) {
|
||||
"Gesamt" -> true
|
||||
"Erwachsene" -> (homeHtc && game.heimAltersklasse.contains("Erwachsene", ignoreCase = true)) ||
|
||||
(awayHtc && game.gastAltersklasse.contains("Erwachsene", ignoreCase = true))
|
||||
"Nachwuchs" -> (homeHtc && isYouth(game.heimAltersklasse, game.heimMannschaft)) ||
|
||||
(awayHtc && isYouth(game.gastAltersklasse, game.gastMannschaft))
|
||||
else -> game.heimMannschaft == selectedTeam || game.gastMannschaft == selectedTeam
|
||||
}
|
||||
}
|
||||
|
||||
private fun isYouth(age: String, team: String): Boolean =
|
||||
age.contains("Jugend", ignoreCase = true) || team.contains("Jugend", ignoreCase = true)
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class SpielplanViewModel @Inject constructor(private val repository: SpielplanRepository) : ViewModel() {
|
||||
private val _state = MutableStateFlow(SpielplanUiState())
|
||||
val state: StateFlow<SpielplanUiState> = _state
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load(season: String? = _state.value.selectedSeason) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, error = null)
|
||||
repository.fetchSpielplan(season)
|
||||
.onSuccess { response ->
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
spiele = response.data,
|
||||
seasons = response.seasons.ifEmpty { _state.value.seasons },
|
||||
selectedSeason = response.season ?: season,
|
||||
)
|
||||
}
|
||||
.onFailure {
|
||||
_state.value = _state.value.copy(loading = false, error = "Spielplan konnte nicht geladen werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectSeason(slug: String) = load(slug)
|
||||
|
||||
fun selectWettbewerb(value: Wettbewerb) {
|
||||
_state.value = _state.value.copy(selectedWettbewerb = value)
|
||||
}
|
||||
|
||||
fun selectTeam(value: String) {
|
||||
_state.value = _state.value.copy(selectedTeam = value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package de.harheimertc.ui.screens.termine
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
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.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.data.TerminDto
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent700
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary100
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
import de.harheimertc.ui.theme.Primary900
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun TermineScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean = true,
|
||||
viewModel: TermineViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 18.dp, vertical = 22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
item {
|
||||
if (showBackNavigation) {
|
||||
PageHeader(onBack = { navController.popBackStack() })
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 30.dp, bottom = 20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text("Termine & Events", style = MaterialTheme.typography.displayLarge, color = Accent900)
|
||||
Spacer(Modifier.size(13.dp))
|
||||
Box(Modifier.width(74.dp).size(width = 74.dp, height = 4.dp).background(Primary600))
|
||||
Text(
|
||||
"Alle kommenden Termine und Veranstaltungen des Harheimer TC",
|
||||
color = Accent500,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 17.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.loading) {
|
||||
item { LoadingPanel() }
|
||||
} else if (state.error != null) {
|
||||
item {
|
||||
EmptyPanel(state.error.orEmpty())
|
||||
Button(onClick = viewModel::load, modifier = Modifier.fillMaxWidth()) { Text("Erneut versuchen") }
|
||||
}
|
||||
} else if (state.termine.isEmpty()) {
|
||||
item { EmptyPanel("Aktuell sind keine Termine geplant. Schauen Sie bald wieder vorbei!") }
|
||||
} else {
|
||||
items(state.termine) { termin -> TerminCard(termin) }
|
||||
}
|
||||
item { NoticePanel() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PageHeader(onBack: () -> Unit) {
|
||||
TextButton(onClick = onBack) {
|
||||
Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TerminCard(termin: TerminDto) {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 3.dp) {
|
||||
Row(Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.Top) {
|
||||
Surface(color = Primary600, shape = RoundedCornerShape(10.dp)) {
|
||||
Column(Modifier.width(64.dp).padding(vertical = 10.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(datePart(termin.datum, "dd"), color = Color.White, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleLarge)
|
||||
Text(datePart(termin.datum, "MMM"), color = Color.White, style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(horizontal = 13.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Text(termin.titel, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
termin.beschreibung?.takeIf(String::isNotBlank)?.let { Text(it, color = Accent700) }
|
||||
Text(fullDate(termin), style = MaterialTheme.typography.labelSmall, color = Accent500)
|
||||
}
|
||||
termin.kategorie?.takeIf(String::isNotBlank)?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Primary900,
|
||||
modifier = Modifier.background(Primary100, RoundedCornerShape(16.dp)).padding(horizontal = 8.dp, vertical = 5.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingPanel() {
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 38.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(color = Primary600)
|
||||
Text("Termine werden geladen...", color = Accent500, modifier = Modifier.padding(top = 12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyPanel(message: String) {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 3.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Keine kommenden Termine", style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
Text(message, color = Accent500, textAlign = TextAlign.Center, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NoticePanel() {
|
||||
Surface(color = Color(0xFFFEF2F2), shape = RoundedCornerShape(12.dp), modifier = Modifier.padding(top = 16.dp)) {
|
||||
Column(Modifier.fillMaxWidth().padding(18.dp)) {
|
||||
Text("Hinweis", fontWeight = FontWeight.SemiBold, color = Primary900)
|
||||
Text(
|
||||
"Alle Termine sind vorbehaltlich kurzfristiger Änderungen. Bei Fragen zu einzelnen Veranstaltungen kontaktieren Sie uns gerne.",
|
||||
color = Primary900,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun datePart(value: String, pattern: String): String = runCatching {
|
||||
val parsed = SimpleDateFormat("yyyy-MM-dd", Locale.GERMANY).parse(value)!!
|
||||
SimpleDateFormat(pattern, Locale.GERMANY).format(parsed)
|
||||
}.getOrDefault(value)
|
||||
|
||||
private fun fullDate(termin: TerminDto): String {
|
||||
val date = datePart(termin.datum, "EEEE, d. MMMM yyyy")
|
||||
return termin.uhrzeit?.takeIf(String::isNotBlank)?.let { "$date - $it Uhr" } ?: date
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package de.harheimertc.ui.screens.termine
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.TerminDto
|
||||
import de.harheimertc.repositories.TermineRepository
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class TermineUiState(
|
||||
val loading: Boolean = true,
|
||||
val termine: List<TerminDto> = emptyList(),
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class TermineViewModel @Inject constructor(private val repository: TermineRepository) : ViewModel() {
|
||||
private val _state = MutableStateFlow(TermineUiState())
|
||||
val state: StateFlow<TermineUiState> = _state
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_state.value = TermineUiState(loading = true)
|
||||
repository.fetchTermine()
|
||||
.onSuccess { termine ->
|
||||
_state.value = TermineUiState(
|
||||
loading = false,
|
||||
termine = termine
|
||||
.filter { it.eventDateTime()?.toLocalDate()?.isBefore(LocalDate.now()) != true }
|
||||
.sortedBy { it.eventDateTime() },
|
||||
)
|
||||
}
|
||||
.onFailure {
|
||||
_state.value = TermineUiState(loading = false, error = "Termine konnten nicht geladen werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun TerminDto.eventDateTime(): LocalDateTime? = runCatching {
|
||||
val time = uhrzeit?.takeIf { it.matches(Regex("\\d{2}:\\d{2}")) } ?: "00:00"
|
||||
LocalDateTime.parse("$datum $time", DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
|
||||
}.getOrNull()
|
||||
@@ -0,0 +1,207 @@
|
||||
package de.harheimertc.ui.screens.training
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
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.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.data.TrainingTimeDto
|
||||
import de.harheimertc.data.TrainerDto
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent700
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary100
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
|
||||
@Composable
|
||||
fun TrainingScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: TrainingViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
Page(
|
||||
navController = navController,
|
||||
showBackNavigation = showBackNavigation,
|
||||
title = "Trainingszeiten",
|
||||
) {
|
||||
when {
|
||||
state.loading -> item { Loading() }
|
||||
state.error != null -> item { ErrorPanel(state.error.orEmpty(), viewModel::load) }
|
||||
else -> state.config?.let { config ->
|
||||
item {
|
||||
ContentCard("Trainingsort") {
|
||||
Text(config.training.ort.name, color = Accent900, fontWeight = FontWeight.SemiBold)
|
||||
Text(config.training.ort.strasse, color = Accent700)
|
||||
Text("${config.training.ort.plz} ${config.training.ort.ort}", color = Accent700)
|
||||
}
|
||||
}
|
||||
item { Text("Trainingszeiten", style = MaterialTheme.typography.titleLarge, color = Accent900) }
|
||||
val groups = config.training.zeiten.groupBy { it.gruppe }
|
||||
items(groups.entries.toList()) { group ->
|
||||
ContentCard(group.key) {
|
||||
group.value.forEach { time ->
|
||||
Text("${time.tag}: ${time.von} - ${time.bis} Uhr", color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||
time.info?.takeIf(String::isNotBlank)?.let { Text(it, color = Accent500) }
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Surface(color = Primary100, shape = RoundedCornerShape(8.dp)) {
|
||||
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("Interessiert?", style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
Text("Komm einfach zum Schnuppertraining vorbei oder kontaktiere uns für weitere Informationen.", color = Accent700)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Button(onClick = { navController.navigate(Destinations.Anfaenger.route) }, colors = ButtonDefaults.buttonColors(containerColor = Primary600)) {
|
||||
Text("Infos für Anfänger")
|
||||
}
|
||||
OutlinedButton(onClick = { navController.navigate(Destinations.Contact.route) }) { Text("Kontakt") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TrainerScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: TrainingViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
Page(navController, showBackNavigation, "Unsere Trainer") {
|
||||
item { Text("Erfahrene und qualifizierte Trainer für alle Leistungsstufen", color = Accent500) }
|
||||
when {
|
||||
state.loading -> item { Loading() }
|
||||
state.error != null -> item { ErrorPanel(state.error.orEmpty(), viewModel::load) }
|
||||
else -> items(state.config?.trainer.orEmpty()) { trainer -> TrainerCard(trainer) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AnfaengerScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: TrainingViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
Page(navController, showBackNavigation, "Tischtennis für Anfänger") {
|
||||
item { Text("Du möchtest mit Tischtennis anfangen? Bei uns bist du richtig.", color = Accent500) }
|
||||
item {
|
||||
ContentCard("Was du wissen solltest") {
|
||||
listOf(
|
||||
"Keine Vorkenntnisse nötig",
|
||||
"Schläger und Material werden gestellt",
|
||||
"Sportkleidung und Hallenschuhe mitbringen",
|
||||
"3x kostenlos Probetraining",
|
||||
"Einstieg jederzeit möglich",
|
||||
).forEach { Text("+ $it", color = Accent700) }
|
||||
}
|
||||
}
|
||||
when {
|
||||
state.loading -> item { Loading() }
|
||||
state.error != null -> item { ErrorPanel(state.error.orEmpty(), viewModel::load) }
|
||||
else -> {
|
||||
val groups = state.config?.training?.zeiten.orEmpty().groupBy(TrainingTimeDto::gruppe)
|
||||
item { Text("Anfängergruppen", style = MaterialTheme.typography.titleLarge, color = Accent900) }
|
||||
items(groups.entries.toList()) { group ->
|
||||
ContentCard(group.key) {
|
||||
group.value.forEach { Text("${it.tag}, ${it.von} - ${it.bis} Uhr", color = Accent700) }
|
||||
}
|
||||
}
|
||||
item {
|
||||
Button(
|
||||
onClick = { navController.navigate(Destinations.Contact.route) },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Zum Probetraining anmelden") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Page(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
title: String,
|
||||
content: androidx.compose.foundation.lazy.LazyListScope.() -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||
contentPadding = PaddingValues(horizontal = 18.dp, vertical = 22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
item {
|
||||
if (showBackNavigation) {
|
||||
TextButton(onClick = { navController.popBackStack() }) { Text("< Startseite", color = Primary600) }
|
||||
}
|
||||
Text(title, style = MaterialTheme.typography.displayLarge, color = Accent900, modifier = Modifier.padding(top = 18.dp))
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContentCard(title: String, content: @Composable () -> Unit) {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(8.dp), shadowElevation = 2.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(17.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrainerCard(trainer: TrainerDto) {
|
||||
ContentCard(trainer.lizenz) {
|
||||
Text(trainer.name, color = Accent700, fontWeight = FontWeight.SemiBold)
|
||||
Text("Schwerpunkt: ${trainer.schwerpunkt}", color = Accent500)
|
||||
trainer.zusatz?.takeIf(String::isNotBlank)?.let { Text(it, color = Accent500) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Loading() {
|
||||
Column(Modifier.fillMaxWidth().padding(30.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(color = Primary600)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorPanel(message: String, retry: () -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(message, color = Accent700)
|
||||
Button(onClick = retry, colors = ButtonDefaults.buttonColors(containerColor = Primary600)) { Text("Erneut laden") }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package de.harheimertc.ui.screens.training
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.ConfigResponse
|
||||
import de.harheimertc.repositories.TrainingRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class TrainingUiState(
|
||||
val loading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val config: ConfigResponse? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class TrainingViewModel @Inject constructor(private val repository: TrainingRepository) : ViewModel() {
|
||||
private val _state = MutableStateFlow(TrainingUiState())
|
||||
val state: StateFlow<TrainingUiState> = _state
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_state.value = TrainingUiState(loading = true)
|
||||
repository.fetchConfig()
|
||||
.onSuccess { _state.value = TrainingUiState(loading = false, config = it) }
|
||||
.onFailure { _state.value = TrainingUiState(loading = false, error = "Trainingsinformationen konnten nicht geladen werden.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.harheimertc.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// Primary (Tailwind)
|
||||
val Primary50 = Color(0xFFFEF2F2)
|
||||
val Primary100 = Color(0xFFFEE2E2)
|
||||
val Primary200 = Color(0xFFFECACA)
|
||||
val Primary300 = Color(0xFFFCA5A5)
|
||||
val Primary400 = Color(0xFFF87171)
|
||||
val Primary500 = Color(0xFFEF4444)
|
||||
val Primary600 = Color(0xFFDC2626)
|
||||
val Primary700 = Color(0xFFB91C1C)
|
||||
val Primary800 = Color(0xFF991B1B)
|
||||
val Primary900 = Color(0xFF7F1D1D)
|
||||
|
||||
// Accent (Tailwind gray-ish palette)
|
||||
val Accent50 = Color(0xFFFAFAFA)
|
||||
val Accent100 = Color(0xFFF4F4F5)
|
||||
val Accent200 = Color(0xFFE4E4E7)
|
||||
val Accent300 = Color(0xFFD4D4D8)
|
||||
val Accent400 = Color(0xFFA1A1AA)
|
||||
val Accent500 = Color(0xFF71717A)
|
||||
val Accent600 = Color(0xFF52525B)
|
||||
val Accent700 = Color(0xFF3F3F46)
|
||||
val Accent800 = Color(0xFF27272A)
|
||||
val Accent900 = Color(0xFF18181B)
|
||||
|
||||
// Other semantic colors
|
||||
val AppBackground = Color(0xFFFFFFFF)
|
||||
val AppSurface = Accent50
|
||||
val AppOnPrimary = Color(0xFFFFFFFF)
|
||||
val AppError = Color(0xFFB00020)
|
||||
@@ -0,0 +1,41 @@
|
||||
package de.harheimertc.ui.theme
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
private val LightColors = lightColorScheme(
|
||||
primary = Primary500,
|
||||
onPrimary = AppOnPrimary,
|
||||
primaryContainer = Primary100,
|
||||
secondary = Accent500,
|
||||
background = AppBackground,
|
||||
surface = AppSurface,
|
||||
error = AppError,
|
||||
)
|
||||
|
||||
private val DarkColors = darkColorScheme(
|
||||
primary = Primary300,
|
||||
onPrimary = AppOnPrimary,
|
||||
primaryContainer = Primary700,
|
||||
secondary = Accent300,
|
||||
background = Accent900,
|
||||
surface = Accent800,
|
||||
error = AppError,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun HarheimerTheme(
|
||||
darkTheme: Boolean = false,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colors = if (darkTheme) DarkColors else LightColors
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colors,
|
||||
typography = AppTypography,
|
||||
shapes = MaterialTheme.shapes,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package de.harheimertc.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import de.harheimertc.R
|
||||
|
||||
// Bundled variable fonts in res/font:
|
||||
val InterFamily = FontFamily(Font(R.font.inter_variable))
|
||||
val MontserratFamily = FontFamily(Font(R.font.montserrat_variable, FontWeight.SemiBold))
|
||||
|
||||
val AppTypography = Typography(
|
||||
displayLarge = TextStyle(
|
||||
fontFamily = MontserratFamily,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 30.sp
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = MontserratFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 20.sp
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = InterFamily,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontFamily = InterFamily,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = InterFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
BIN
android-app/app/src/main/res/font/inter_variable.ttf
Normal file
BIN
android-app/app/src/main/res/font/inter_variable.ttf
Normal file
Binary file not shown.
BIN
android-app/app/src/main/res/font/montserrat_variable.ttf
Normal file
BIN
android-app/app/src/main/res/font/montserrat_variable.ttf
Normal file
Binary file not shown.
37
android-app/app/src/main/res/font_README.md
Normal file
37
android-app/app/src/main/res/font_README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
Fonts für die Android-App
|
||||
|
||||
Dieses Verzeichnis soll die gebündelten TTF-Dateien enthalten, damit die App die gleichen Schriften wie die Web-UI verwendet.
|
||||
|
||||
Automatischer Download (empfohlen):
|
||||
|
||||
1. Ausführbar machen:
|
||||
|
||||
```bash
|
||||
chmod +x android-app/scripts/download-fonts.sh
|
||||
```
|
||||
|
||||
2. Script ausführen:
|
||||
|
||||
```bash
|
||||
./android-app/scripts/download-fonts.sh
|
||||
```
|
||||
|
||||
Das Script lädt die variable Font-Dateien `Inter[opsz,wght].ttf` und `Montserrat[wght].ttf` in `app/src/main/res/font/` und benennt sie in `inter_variable.ttf` und `montserrat_variable.ttf`.
|
||||
|
||||
Manuelle Alternative:
|
||||
- Lade `Inter` und `Montserrat` vom Google Fonts Repo herunter und lege die TTFs in dieses Verzeichnis.
|
||||
- Benenne die Dateien wie oben.
|
||||
|
||||
Compose-Nutzung (Beispiel in Kotlin):
|
||||
|
||||
```kotlin
|
||||
val Inter = FontFamily(
|
||||
Font(R.font.inter_variable)
|
||||
)
|
||||
|
||||
val Montserrat = FontFamily(
|
||||
Font(R.font.montserrat_variable)
|
||||
)
|
||||
```
|
||||
|
||||
Hinweis: Das Herunterladen der Fonts erfolgt von GitHub (Raw URLs). Prüfe die Lizenzen (Google Fonts sind in der Regel OFL-lizenziert).
|
||||
6
android-app/app/src/main/res/values/colors.xml
Normal file
6
android-app/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="primary_500">#ef4444</color>
|
||||
<color name="primary_600">#dc2626</color>
|
||||
<color name="accent_500">#71717a</color>
|
||||
</resources>
|
||||
8
android-app/app/src/main/res/values/themes.xml
Normal file
8
android-app/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.HarheimerTC" parent="Theme.AppCompat.Light.NoActionBar">
|
||||
<item name="android:statusBarColor">#18181B</item>
|
||||
<item name="android:navigationBarColor">#18181B</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
4
android-app/app/src/main/res/xml/file_paths.xml
Normal file
4
android-app/app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<cache-path name="membership_documents" path="membership/" />
|
||||
</paths>
|
||||
16
android-app/build.gradle.kts
Normal file
16
android-app/build.gradle.kts
Normal file
@@ -0,0 +1,16 @@
|
||||
// Root build.gradle.kts (skeleton)
|
||||
plugins {
|
||||
id("com.android.application") version "9.2.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.3.21" apply false
|
||||
id("com.google.dagger.hilt.android") version "2.59.2" apply false
|
||||
id("com.google.devtools.ksp") version "2.3.7" apply false
|
||||
id("org.jetbrains.kotlin.kapt") version "2.3.21" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false
|
||||
}
|
||||
|
||||
buildscript {
|
||||
val agp_version by extra("9.2.1")
|
||||
}
|
||||
|
||||
allprojects {
|
||||
}
|
||||
666
android-app/build/reports/problems/problems-report.html
Normal file
666
android-app/build/reports/problems/problems-report.html
Normal file
File diff suppressed because one or more lines are too long
3
android-app/gradle.properties
Normal file
3
android-app/gradle.properties
Normal file
@@ -0,0 +1,3 @@
|
||||
# Using AGP 9.2.1 defaults
|
||||
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8
|
||||
org.gradle.workers.max=2
|
||||
BIN
android-app/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
android-app/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
android-app/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
android-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.4.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
251
android-app/gradlew
vendored
Executable file
251
android-app/gradlew
vendored
Executable file
@@ -0,0 +1,251 @@
|
||||
#!/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
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# 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" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
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" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-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" "$@"
|
||||
94
android-app/gradlew.bat
vendored
Normal file
94
android-app/gradlew.bat
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
@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
|
||||
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -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
|
||||
BIN
android-app/java_pid672503.hprof
Normal file
BIN
android-app/java_pid672503.hprof
Normal file
Binary file not shown.
22
android-app/scripts/download-fonts.sh
Executable file
22
android-app/scripts/download-fonts.sh
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Lädt Inter Regular und Montserrat SemiBold aus dem Google Fonts GitHub-Repo
|
||||
# Ziel: android-app/app/src/main/res/font/
|
||||
|
||||
OUT_DIR="$(dirname "$0")/../app/src/main/res/font"
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
echo "Download fonts into $OUT_DIR"
|
||||
|
||||
# URLs (raw GitHub content)
|
||||
INTER_URL="https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter-Regular.ttf"
|
||||
MONTSERRAT_URL="https://raw.githubusercontent.com/google/fonts/main/ofl/montserrat/Montserrat-SemiBold.ttf"
|
||||
|
||||
curl -fL "$INTER_URL" -o "$OUT_DIR/inter_regular.ttf" || { echo "Fehler: Inter konnte nicht heruntergeladen werden."; exit 1; }
|
||||
curl -fL "$MONTSERRAT_URL" -o "$OUT_DIR/montserrat_semibold.ttf" || { echo "Fehler: Montserrat konnte nicht heruntergeladen werden."; exit 1; }
|
||||
|
||||
echo "Fonts heruntergeladen:"
|
||||
ls -l "$OUT_DIR"/*.ttf
|
||||
|
||||
echo "Füge in Android Studio die Fonts als resource-Fonts hinzu oder benutze sie in Compose via R.font.inter_regular."
|
||||
18
android-app/settings.gradle.kts
Normal file
18
android-app/settings.gradle.kts
Normal file
@@ -0,0 +1,18 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
gradlePluginPortal()
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "harheimertc-android"
|
||||
include(":app")
|
||||
@@ -102,7 +102,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { Users } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -158,6 +158,7 @@ const loadMannschaften = async () => {
|
||||
const lines = csv.split('\n').filter(line => line.trim() !== '')
|
||||
|
||||
if (lines.length < 2) {
|
||||
mannschaften.value = []
|
||||
return
|
||||
}
|
||||
|
||||
@@ -234,4 +235,8 @@ const formatDate = (dateString) => {
|
||||
onMounted(() => {
|
||||
loadMannschaften()
|
||||
})
|
||||
|
||||
watch(selectedSeason, () => {
|
||||
loadMannschaften()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -403,6 +403,7 @@ const isEditing = ref(false)
|
||||
const editingIndex = ref(-1)
|
||||
const formData = ref({ mannschaft: '', liga: '', staffelleiter: '', telefon: '', heimspieltag: '', spielsystem: '', mannschaftsfuehrer: '', spielerListe: [], weitere_informationen_link: '', letzte_aktualisierung: '' })
|
||||
const moveTargetBySpielerId = ref({})
|
||||
const initialMoveTargetBySpielerId = ref({})
|
||||
const pendingSpielerNamesByTeamIndex = ref({})
|
||||
|
||||
function nowIsoDate() { return new Date().toISOString().split('T')[0] }
|
||||
@@ -449,7 +450,7 @@ async function loadSeasons() {
|
||||
if (!selectedSeason.value || !seasons.value.includes(selectedSeason.value)) {
|
||||
selectedSeason.value = result.defaultSeason || seasons.value[0]
|
||||
}
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
if (!seasons.value.length) seasons.value = ['']
|
||||
if (!selectedSeason.value) selectedSeason.value = seasons.value[0] || ''
|
||||
}
|
||||
@@ -461,7 +462,7 @@ const mannschaftenSelectOptions = computed(() => {
|
||||
return [...new Set([current, ...names])].filter(Boolean)
|
||||
})
|
||||
|
||||
function resetSpielerDraftState() { moveTargetBySpielerId.value = {}; pendingSpielerNamesByTeamIndex.value = {} }
|
||||
function resetSpielerDraftState() { moveTargetBySpielerId.value = {}; initialMoveTargetBySpielerId.value = {}; pendingSpielerNamesByTeamIndex.value = {} }
|
||||
function getPendingSpielerNamesForTeamIndex(teamIndex) {
|
||||
if (pendingSpielerNamesByTeamIndex.value[teamIndex]) return pendingSpielerNamesByTeamIndex.value[teamIndex]
|
||||
const existing = mannschaften.value[teamIndex]; const list = existing ? getSpielerListe(existing) : []
|
||||
@@ -497,29 +498,60 @@ const openEditModal = (mannschaft, index) => {
|
||||
formData.value = { mannschaft: mannschaft.mannschaft || '', liga: mannschaft.liga || '', staffelleiter: mannschaft.staffelleiter || '', telefon: mannschaft.telefon || '', heimspieltag: mannschaft.heimspieltag || '', spielsystem: mannschaft.spielsystem || '', mannschaftsfuehrer: mannschaft.mannschaftsfuehrer || '', spielerListe: parseSpielerString(mannschaft.spieler || ''), weitere_informationen_link: mannschaft.weitere_informationen_link || '', letzte_aktualisierung: mannschaft.letzte_aktualisierung || nowIsoDate() }
|
||||
isEditing.value = true; editingIndex.value = index; showModal.value = true; errorMessage.value = ''; resetSpielerDraftState()
|
||||
const currentTeam = (formData.value.mannschaft || '').trim()
|
||||
for (const s of formData.value.spielerListe) { moveTargetBySpielerId.value[s.id] = currentTeam }
|
||||
for (const s of formData.value.spielerListe) {
|
||||
moveTargetBySpielerId.value[s.id] = currentTeam
|
||||
initialMoveTargetBySpielerId.value[s.id] = currentTeam
|
||||
}
|
||||
}
|
||||
const addSpieler = () => {
|
||||
const item = newSpielerItem('')
|
||||
const currentTeam = (formData.value.mannschaft || '').trim()
|
||||
formData.value.spielerListe.push(item)
|
||||
moveTargetBySpielerId.value[item.id] = currentTeam
|
||||
initialMoveTargetBySpielerId.value[item.id] = currentTeam
|
||||
}
|
||||
const removeSpieler = (id) => {
|
||||
const idx = formData.value.spielerListe.findIndex(s => s.id === id)
|
||||
if (idx === -1) return
|
||||
formData.value.spielerListe.splice(idx, 1)
|
||||
delete moveTargetBySpielerId.value[id]
|
||||
delete initialMoveTargetBySpielerId.value[id]
|
||||
}
|
||||
const addSpieler = () => { const item = newSpielerItem(''); formData.value.spielerListe.push(item); moveTargetBySpielerId.value[item.id] = (formData.value.mannschaft || '').trim() }
|
||||
const removeSpieler = (id) => { const idx = formData.value.spielerListe.findIndex(s => s.id === id); if (idx === -1) return; formData.value.spielerListe.splice(idx, 1); if (moveTargetBySpielerId.value[id]) delete moveTargetBySpielerId.value[id] }
|
||||
const moveSpielerUp = (index) => { if (index <= 0) return; const arr = formData.value.spielerListe; const item = arr[index]; arr.splice(index, 1); arr.splice(index - 1, 0, item) }
|
||||
const moveSpielerDown = (index) => { const arr = formData.value.spielerListe; if (index < 0 || index >= arr.length - 1) return; const item = arr[index]; arr.splice(index, 1); arr.splice(index + 1, 0, item) }
|
||||
const canMoveSpieler = (id) => { const t = (moveTargetBySpielerId.value[id] || '').trim(); const c = (formData.value.mannschaft || '').trim(); return Boolean(t) && Boolean(c) && t !== c }
|
||||
const canMoveSpieler = (id) => {
|
||||
const target = (moveTargetBySpielerId.value[id] || '').trim()
|
||||
const initialTarget = (initialMoveTargetBySpielerId.value[id] || '').trim()
|
||||
return Boolean(target) && Boolean(initialTarget) && target !== initialTarget
|
||||
}
|
||||
|
||||
const moveSpielerToMannschaft = (spielerId) => {
|
||||
if (!isEditing.value || editingIndex.value < 0) return
|
||||
const targetName = (moveTargetBySpielerId.value[spielerId] || '').trim(); if (!targetName) return
|
||||
if (!isEditing.value || editingIndex.value < 0) return false
|
||||
const targetName = (moveTargetBySpielerId.value[spielerId] || '').trim(); if (!targetName) return false
|
||||
const targetIndex = mannschaften.value.findIndex((m, idx) => { if (idx === editingIndex.value) return false; return (m?.mannschaft || '').trim() === targetName })
|
||||
if (targetIndex === -1) { errorMessage.value = 'Ziel-Mannschaft nicht gefunden.'; return }
|
||||
const idx = formData.value.spielerListe.findIndex(s => s.id === spielerId); if (idx === -1) return
|
||||
const spielerName = (formData.value.spielerListe[idx]?.name || '').trim(); if (!spielerName) { errorMessage.value = 'Bitte zuerst einen Spielernamen eintragen.'; return }
|
||||
if (targetIndex === -1) { errorMessage.value = 'Ziel-Mannschaft nicht gefunden.'; return false }
|
||||
const idx = formData.value.spielerListe.findIndex(s => s.id === spielerId); if (idx === -1) return false
|
||||
const spielerName = (formData.value.spielerListe[idx]?.name || '').trim(); if (!spielerName) { errorMessage.value = 'Bitte zuerst einen Spielernamen eintragen.'; return false }
|
||||
formData.value.spielerListe.splice(idx, 1)
|
||||
const pendingList = getPendingSpielerNamesForTeamIndex(targetIndex); pendingList.push(spielerName)
|
||||
delete moveTargetBySpielerId.value[spielerId]
|
||||
delete initialMoveTargetBySpielerId.value[spielerId]
|
||||
return true
|
||||
}
|
||||
|
||||
const applySelectedSpielerTransfers = () => {
|
||||
if (!isEditing.value || editingIndex.value < 0) return true
|
||||
const pendingIds = formData.value.spielerListe
|
||||
.filter(spieler => canMoveSpieler(spieler.id))
|
||||
.map(spieler => spieler.id)
|
||||
|
||||
return pendingIds.every(spielerId => moveSpielerToMannschaft(spielerId))
|
||||
}
|
||||
|
||||
const saveMannschaft = async () => {
|
||||
isSaving.value = true; errorMessage.value = ''
|
||||
try {
|
||||
if (!applySelectedSpielerTransfers()) return
|
||||
const spielerString = serializeSpielerList(formData.value.spielerListe)
|
||||
const updated = { mannschaft: formData.value.mannschaft || '', liga: formData.value.liga || '', staffelleiter: formData.value.staffelleiter || '', telefon: formData.value.telefon || '', heimspieltag: formData.value.heimspieltag || '', spielsystem: formData.value.spielsystem || '', mannschaftsfuehrer: formData.value.mannschaftsfuehrer || '', spieler: spielerString, weitere_informationen_link: formData.value.weitere_informationen_link || '', letzte_aktualisierung: formData.value.letzte_aktualisierung || nowIsoDate() }
|
||||
if (isEditing.value && editingIndex.value >= 0) { mannschaften.value[editingIndex.value] = { ...updated } } else { mannschaften.value.push({ ...updated }) }
|
||||
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -12462,9 +12462,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
||||
"version": "6.15.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "harheimertc-website",
|
||||
"version": "1.5.2",
|
||||
"version": "1.6.0",
|
||||
"description": "Moderne Webseite für den Harheimer Tischtennis Club",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -6,8 +6,27 @@
|
||||
</h1>
|
||||
<div class="w-24 h-1 bg-primary-600 mb-8" />
|
||||
|
||||
<p class="text-xl text-gray-600 mb-12">
|
||||
Unsere aktiven Mannschaften in der Saison {{ selectedSeasonLabel }}
|
||||
<p class="text-xl text-gray-600 mb-12 flex flex-wrap items-center gap-2">
|
||||
<span>Unsere aktiven Mannschaften in der Saison</span>
|
||||
<label
|
||||
for="season-select"
|
||||
class="sr-only"
|
||||
>
|
||||
Saison auswählen
|
||||
</label>
|
||||
<select
|
||||
id="season-select"
|
||||
v-model="selectedSeason"
|
||||
class="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white text-base"
|
||||
>
|
||||
<option
|
||||
v-for="season in seasons"
|
||||
:key="season"
|
||||
:value="season"
|
||||
>
|
||||
{{ formatSeasonLabel(season) }}
|
||||
</option>
|
||||
</select>
|
||||
</p>
|
||||
|
||||
<MannschaftenUebersicht :season="selectedSeason" />
|
||||
@@ -37,6 +56,7 @@ import { computed } from 'vue'
|
||||
import MannschaftenUebersicht from '~/components/MannschaftenUebersicht.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const getCurrentSeasonSlug = () => {
|
||||
const now = new Date()
|
||||
@@ -46,18 +66,37 @@ const getCurrentSeasonSlug = () => {
|
||||
return `${String(startYear).slice(-2)}--${String(endYear).slice(-2)}`
|
||||
}
|
||||
|
||||
const selectedSeason = computed(() => {
|
||||
const value = String(route.query.season || '').trim()
|
||||
return /^\d{2}--\d{2}$/.test(value) ? value : getCurrentSeasonSlug()
|
||||
const currentSeason = getCurrentSeasonSlug()
|
||||
const { data: seasonsResult } = await useFetch('/api/mannschaften/seasons')
|
||||
|
||||
const seasons = computed(() => {
|
||||
const availableSeasons = Array.isArray(seasonsResult.value?.seasons)
|
||||
? seasonsResult.value.seasons
|
||||
: []
|
||||
|
||||
return [...new Set([currentSeason, ...availableSeasons])]
|
||||
.filter(season => /^\d{2}--\d{2}$/.test(season))
|
||||
.sort()
|
||||
.reverse()
|
||||
})
|
||||
|
||||
const selectedSeasonLabel = computed(() => {
|
||||
const match = String(selectedSeason.value || '').match(/^(\d{2})--(\d{2})$/)
|
||||
return match ? `20${match[1]}/${match[2]}` : selectedSeason.value
|
||||
const selectedSeason = computed({
|
||||
get() {
|
||||
const value = String(route.query.season || '').trim()
|
||||
return seasons.value.includes(value) ? value : currentSeason
|
||||
},
|
||||
set(value) {
|
||||
const season = seasons.value.includes(value) ? value : currentSeason
|
||||
router.replace({ query: { ...route.query, season } })
|
||||
}
|
||||
})
|
||||
|
||||
const formatSeasonLabel = (season) => {
|
||||
const match = String(season || '').match(/^(\d{2})--(\d{2})$/)
|
||||
return match ? `20${match[1]}/${match[2]}` : season
|
||||
}
|
||||
|
||||
useHead({
|
||||
title: 'Mannschaften - Harheimer TC',
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { deleteSession } from '../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
|
||||
if (token) {
|
||||
await deleteSession(token)
|
||||
@@ -23,4 +23,3 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { getUserFromToken } from '../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
@@ -46,4 +46,3 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user