diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml new file mode 100644 index 0000000..f6c1931 --- /dev/null +++ b/.github/workflows/android-ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 26ed931..8231f6a 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/ANDROID_ARCHITECTURE.md b/ANDROID_ARCHITECTURE.md new file mode 100644 index 0000000..1402921 --- /dev/null +++ b/ANDROID_ARCHITECTURE.md @@ -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) \ No newline at end of file diff --git a/ANDROID_KOTLIN_PLAN.md b/ANDROID_KOTLIN_PLAN.md new file mode 100644 index 0000000..e43bb8b --- /dev/null +++ b/ANDROID_KOTLIN_PLAN.md @@ -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://: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) diff --git a/ANDROID_PORT_TODO.md b/ANDROID_PORT_TODO.md new file mode 100644 index 0000000..db4f975 --- /dev/null +++ b/ANDROID_PORT_TODO.md @@ -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). \ No newline at end of file diff --git a/ANDROID_REPO_ENDPOINTS.md b/ANDROID_REPO_ENDPOINTS.md new file mode 100644 index 0000000..bb26dc7 --- /dev/null +++ b/ANDROID_REPO_ENDPOINTS.md @@ -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). \ No newline at end of file diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts new file mode 100644 index 0000000..4871544 --- /dev/null +++ b/android-app/app/build.gradle.kts @@ -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") +} diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..3833df9 --- /dev/null +++ b/android-app/app/src/main/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + diff --git a/android-app/app/src/main/java/de/harheimertc/HarheimerApplication.kt b/android-app/app/src/main/java/de/harheimertc/HarheimerApplication.kt new file mode 100644 index 0000000..395fd4c --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/HarheimerApplication.kt @@ -0,0 +1,7 @@ +package de.harheimertc + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class HarheimerApplication : Application() diff --git a/android-app/app/src/main/java/de/harheimertc/MainActivity.kt b/android-app/app/src/main/java/de/harheimertc/MainActivity.kt new file mode 100644 index 0000000..c75be2c --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/MainActivity.kt @@ -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() +} diff --git a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt new file mode 100644 index 0000000..f05c1f3 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt @@ -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 = 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 = emptyList(), + val headers: List = emptyList(), + val season: String? = null, + val seasons: List = 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 = 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 = 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 = 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 = 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 = 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 = emptyList(), +) +data class SeitenDto( + val ueberUns: String = "", + val geschichte: String = "", + val ttRegeln: String = "", + val satzung: SatzungDto = SatzungDto(), + val links: String = "", + val linksStructured: List = emptyList(), +) +data class ConfigResponse( + val training: TrainingDto = TrainingDto(), + val trainer: List = emptyList(), + val vorstand: VorstandDto = VorstandDto(), + val seiten: SeitenDto = SeitenDto(), +) + +interface ApiService { + @POST("/api/contact") + suspend fun postContact(@Body req: ContactRequest): Response + + @GET("/api/galerie/list") + suspend fun galerieList(): Response> + + @GET("/api/galerie") + suspend fun publicGalleryImages(): Response> + + @GET("/api/termine") + suspend fun termine(): Response + + @GET("/api/spielplan") + suspend fun spielplan(@Query("season") season: String? = null): Response + + @GET("/api/spielplan/table") + suspend fun spielplanTable( + @Query("team") team: String, + @Query("season") season: String? = null, + ): Response + + @GET("/api/news-public") + suspend fun publicNews(): Response + + @GET("/api/mannschaften") + suspend fun mannschaften(@Query("season") season: String? = null): Response + + @GET("/api/config") + suspend fun config(): Response + + @GET("/data/spielsysteme.csv") + suspend fun spielsysteme(): Response + + @GET("/api/vereinsmeisterschaften") + suspend fun vereinsmeisterschaften(): Response + + @POST("/api/membership/generate-pdf") + suspend fun generateMembershipPdf(@Body request: MembershipRequest): Response + + @Streaming + @GET + suspend fun downloadMembershipPdf(@Url downloadUrl: String): Response + + @POST("/api/auth/login") + suspend fun login(@Body request: LoginRequest): Response + + @POST("/api/auth/logout") + suspend fun logout(): Response + + @GET("/api/auth/status") + suspend fun authStatus(): Response + + @POST("/api/auth/reset-password") + suspend fun resetPassword(@Body request: ResetPasswordRequest): Response + + @POST("/api/auth/register") + suspend fun register(@Body request: RegistrationRequest): Response +} diff --git a/android-app/app/src/main/java/de/harheimertc/data/AuthInterceptor.kt b/android-app/app/src/main/java/de/harheimertc/data/AuthInterceptor.kt new file mode 100644 index 0000000..3cd058c --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/data/AuthInterceptor.kt @@ -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()) + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/data/NetworkModule.kt b/android-app/app/src/main/java/de/harheimertc/data/NetworkModule.kt new file mode 100644 index 0000000..6b19ccc --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/data/NetworkModule.kt @@ -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) +} diff --git a/android-app/app/src/main/java/de/harheimertc/di/RepositoryModule.kt b/android-app/app/src/main/java/de/harheimertc/di/RepositoryModule.kt new file mode 100644 index 0000000..dfef776 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/di/RepositoryModule.kt @@ -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 +} diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/AuthRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/AuthRepository.kt new file mode 100644 index 0000000..c16a08d --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/repositories/AuthRepository.kt @@ -0,0 +1,8 @@ +package de.harheimertc.repositories + +import kotlinx.coroutines.flow.StateFlow + +interface AuthRepository { + fun getToken(): String? + fun setToken(token: String?) +} diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/AuthRepositoryImpl.kt b/android-app/app/src/main/java/de/harheimertc/repositories/AuthRepositoryImpl.kt new file mode 100644 index 0000000..8380e5e --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/repositories/AuthRepositoryImpl.kt @@ -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() + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/ContactRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/ContactRepository.kt new file mode 100644 index 0000000..88d571a --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/repositories/ContactRepository.kt @@ -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 = api.postContact(req) +} diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/GalleryRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/GalleryRepository.kt new file mode 100644 index 0000000..dd33c8a --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/repositories/GalleryRepository.kt @@ -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 = runCatching { + val response = api.publicGalleryImages() + if (!response.isSuccessful) error("HTTP ${response.code()}") + response.body().orEmpty().isNotEmpty() + } + + suspend fun fetchImages(): Result> { + 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) + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/HomeRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/HomeRepository.kt new file mode 100644 index 0000000..fbddb08 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/repositories/HomeRepository.kt @@ -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, + val spiele: List, + val news: List, +) + +@Singleton +class HomeRepository @Inject constructor(private val api: ApiService) { + suspend fun fetchHomeData(): Result = 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) + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/LoginRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/LoginRepository.kt new file mode 100644 index 0000000..8c5ce8d --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/repositories/LoginRepository.kt @@ -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 = 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 = runCatching { + try { + api.logout() + } finally { + authRepository.setToken(null) + } + } + + suspend fun status(): Result = 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 = 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 = runCatching { + val response = api.register(request) + if (!response.isSuccessful) error("Registrierung fehlgeschlagen.") + response.body() ?: error("Leere Antwort") + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/MannschaftenRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/MannschaftenRepository.kt new file mode 100644 index 0000000..8dc1a01 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/repositories/MannschaftenRepository.kt @@ -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, + 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> = 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 = 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 { + val values = mutableListOf() + 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 + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/MembershipRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/MembershipRepository.kt new file mode 100644 index 0000000..686703d --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/repositories/MembershipRepository.kt @@ -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 = 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(), + ) + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/PublicPagesRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/PublicPagesRepository.kt new file mode 100644 index 0000000..0b7c9e7 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/repositories/PublicPagesRepository.kt @@ -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 = runCatching { + val response = api.config() + if (!response.isSuccessful) error("Inhalte konnten nicht geladen werden.") + response.body() ?: error("Leere Antwort") + } + + suspend fun fetchSpielsysteme(): Result> = 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> = 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> = + csv.lineSequence().filter(String::isNotBlank).map(::parseCsvLine).toList() + +private fun parseCsvLine(line: String): List { + val values = mutableListOf() + 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 = + seiten.linksStructured.filter { it.title.isNotBlank() && it.items.isNotEmpty() } + .ifEmpty { + parseLinkSections(seiten.links).ifEmpty { defaultLinkSections } + } + +private fun parseLinkSections(html: String): List { + if (html.isBlank()) return emptyList() + val sectionRegex = Regex("""]*>(.*?)(.*?)(?=]*>|$)""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)) + val itemRegex = Regex("""]*>(.*?)""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)) + val anchorRegex = Regex("""]*href=["']([^"']+)["'][^>]*>(.*?)""", 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/"), + )), +) diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/SpielplanRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/SpielplanRepository.kt new file mode 100644 index 0000000..851ef35 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/repositories/SpielplanRepository.kt @@ -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 = 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 = 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 + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/TermineRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/TermineRepository.kt new file mode 100644 index 0000000..d2926cd --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/repositories/TermineRepository.kt @@ -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> = runCatching { + val response = api.termine() + if (!response.isSuccessful) error("HTTP ${response.code()}") + response.body()?.termine.orEmpty() + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/TrainingRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/TrainingRepository.kt new file mode 100644 index 0000000..711b73b --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/repositories/TrainingRepository.kt @@ -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 = runCatching { + val response = api.config() + if (!response.isSuccessful) error("Trainingsinformationen konnten nicht geladen werden.") + response.body() ?: error("Leere Antwort") + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt b/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt new file mode 100644 index 0000000..d7351ee --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt @@ -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 = 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() +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/components/HeroComponent.kt b/android-app/app/src/main/java/de/harheimertc/ui/components/HeroComponent.kt new file mode 100644 index 0000000..a7d37de --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/components/HeroComponent.kt @@ -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)) + } + } + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/components/ImageGrid.kt b/android-app/app/src/main/java/de/harheimertc/ui/components/ImageGrid.kt new file mode 100644 index 0000000..5b08b80 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/components/ImageGrid.kt @@ -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, modifier: Modifier = Modifier) { + val selected = remember { mutableStateOf(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") + } + } + } + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/components/PendingPage.kt b/android-app/app/src/main/java/de/harheimertc/ui/components/PendingPage.kt new file mode 100644 index 0000000..385cc86 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/components/PendingPage.kt @@ -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), + ) + } + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/navigation/Destinations.kt b/android-app/app/src/main/java/de/harheimertc/ui/navigation/Destinations.kt new file mode 100644 index 0000000..3ce736e --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/navigation/Destinations.kt @@ -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") +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt b/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt new file mode 100644 index 0000000..07421f6 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt @@ -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 + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavigationViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavigationViewModel.kt new file mode 100644 index 0000000..4e7741c --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavigationViewModel.kt @@ -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 = emptyList(), + val hasGalleryImages: Boolean = false, + val loggedIn: Boolean = false, + val roles: Set = 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 = _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(), + ) + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/contact/ContactScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/contact/ContactScreen.kt new file mode 100644 index 0000000..eedafb7 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/contact/ContactScreen.kt @@ -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)) + } + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/contact/ContactViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/contact/ContactViewModel.kt new file mode 100644 index 0000000..4973777 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/contact/ContactViewModel.kt @@ -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 = _name + + private val _email = MutableStateFlow("") + val email: StateFlow = _email + + private val _message = MutableStateFlow("") + val message: StateFlow = _message + + private val _sending = MutableStateFlow(false) + val sending: StateFlow = _sending + + private val _result = MutableStateFlow(null) + val result: StateFlow = _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 + } + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/gallery/GalleryScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/gallery/GalleryScreen.kt new file mode 100644 index 0000000..89152f6 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/gallery/GalleryScreen.kt @@ -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() } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/gallery/GalleryViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/gallery/GalleryViewModel.kt new file mode 100644 index 0000000..6dbd025 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/gallery/GalleryViewModel.kt @@ -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>(emptyList()) + val images: StateFlow> = _images + + private val _loading = MutableStateFlow(false) + val loading: StateFlow = _loading + + private val _error = MutableStateFlow(null) + val error: StateFlow = _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 + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeScreen.kt new file mode 100644 index 0000000..4424807 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeScreen.kt @@ -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(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, 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, 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, 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)) +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeViewModel.kt new file mode 100644 index 0000000..8b0c6e8 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeViewModel.kt @@ -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 = emptyList(), + val spiele: List = emptyList(), + val news: List = emptyList(), + val error: Boolean = false, +) + +@HiltViewModel +class HomeViewModel @Inject constructor(private val repository: HomeRepository) : ViewModel() { + private val _state = MutableStateFlow(HomeUiState()) + val state: StateFlow = _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() diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/login/LoginScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/login/LoginScreen.kt new file mode 100644 index 0000000..b0c4439 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/login/LoginScreen.kt @@ -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), + ) + } + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/login/LoginViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/login/LoginViewModel.kt new file mode 100644 index 0000000..a69d2c6 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/login/LoginViewModel.kt @@ -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 = 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 = _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.") + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationScreens.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationScreens.kt new file mode 100644 index 0000000..c5bcf91 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationScreens.kt @@ -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)) + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationViewModels.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationViewModels.kt new file mode 100644 index 0000000..895c4eb --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationViewModels.kt @@ -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 = _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 = _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.") + } + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/mannschaften/MannschaftenScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/mannschaften/MannschaftenScreen.kt new file mode 100644 index 0000000..a22b6dc --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/mannschaften/MannschaftenScreen.kt @@ -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}" diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/mannschaften/MannschaftenViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/mannschaften/MannschaftenViewModel.kt new file mode 100644 index 0000000..c7ebb88 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/mannschaften/MannschaftenViewModel.kt @@ -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 = emptyList(), +) + +@HiltViewModel +class MannschaftenViewModel @Inject constructor( + private val repository: MannschaftenRepository, +) : ViewModel() { + private val _state = MutableStateFlow(MannschaftenUiState()) + val state: StateFlow = _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 = emptyList(), + val season: String? = null, + val tableLoading: Boolean = false, + val tableError: String? = null, + val tableRows: List = emptyList(), +) + +@HiltViewModel +class MannschaftDetailViewModel @Inject constructor( + private val mannschaftenRepository: MannschaftenRepository, + private val spielplanRepository: SpielplanRepository, +) : ViewModel() { + private val _state = MutableStateFlow(MannschaftDetailUiState()) + val state: StateFlow = _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") + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/membership/MembershipScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/membership/MembershipScreen.kt new file mode 100644 index 0000000..081d9d3 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/membership/MembershipScreen.kt @@ -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)) + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/membership/MembershipViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/membership/MembershipViewModel.kt new file mode 100644 index 0000000..dee042a --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/membership/MembershipViewModel.kt @@ -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 = _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 + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/publicpages/PublicPagesViewModels.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/publicpages/PublicPagesViewModels.kt new file mode 100644 index 0000000..b2974b6 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/publicpages/PublicPagesViewModels.kt @@ -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 = _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 = emptyList(), +) + +@HiltViewModel +class SpielsystemeViewModel @Inject constructor(private val repository: PublicPagesRepository) : ViewModel() { + private val _state = MutableStateFlow(SpielsystemeUiState()) + val state: StateFlow = _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 = emptyList(), +) + +@HiltViewModel +class VereinsmeisterschaftenViewModel @Inject constructor(private val repository: PublicPagesRepository) : ViewModel() { + private val _state = MutableStateFlow(VereinsmeisterschaftenUiState()) + val state: StateFlow = _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.") } + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/publicpages/PublicScreenComponents.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/publicpages/PublicScreenComponents.kt new file mode 100644 index 0000000..aa7a82c --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/publicpages/PublicScreenComponents.kt @@ -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))) +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/publicpages/SportsInfoScreens.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/publicpages/SportsInfoScreens.kt new file mode 100644 index 0000000..adfe8cf --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/publicpages/SportsInfoScreens.kt @@ -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?>(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, 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") } + } + } + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/publicpages/VereinScreens.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/publicpages/VereinScreens.kt new file mode 100644 index 0000000..b7759a3 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/publicpages/VereinScreens.kt @@ -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) } + } + } + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/spielplan/SpielplanScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/spielplan/SpielplanScreen.kt new file mode 100644 index 0000000..b7c69ac --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/spielplan/SpielplanScreen.kt @@ -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 SelectMenu( + label: String, + selected: String, + options: List, + 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() diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/spielplan/SpielplanViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/spielplan/SpielplanViewModel.kt new file mode 100644 index 0000000..92c82e0 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/spielplan/SpielplanViewModel.kt @@ -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 = emptyList(), + val seasons: List = emptyList(), + val selectedSeason: String? = null, + val selectedWettbewerb: Wettbewerb = Wettbewerb.Punktrunde, + val selectedTeam: String = "Gesamt", +) { + val teams: List + get() = listOf("Gesamt", "Erwachsene", "Nachwuchs") + + spiele.flatMap { listOf(it.heimMannschaft, it.gastMannschaft) } + .filter { it.contains("Harheimer TC", ignoreCase = true) } + .distinct() + .sorted() + + val filtered: List + 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 = _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) + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/termine/TermineScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/termine/TermineScreen.kt new file mode 100644 index 0000000..f241ed7 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/termine/TermineScreen.kt @@ -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 +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/termine/TermineViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/termine/TermineViewModel.kt new file mode 100644 index 0000000..5fae667 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/termine/TermineViewModel.kt @@ -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 = emptyList(), + val error: String? = null, +) + +@HiltViewModel +class TermineViewModel @Inject constructor(private val repository: TermineRepository) : ViewModel() { + private val _state = MutableStateFlow(TermineUiState()) + val state: StateFlow = _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() diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/training/TrainingScreens.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/training/TrainingScreens.kt new file mode 100644 index 0000000..dc2e429 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/training/TrainingScreens.kt @@ -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") } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/training/TrainingViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/training/TrainingViewModel.kt new file mode 100644 index 0000000..cf9d501 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/training/TrainingViewModel.kt @@ -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 = _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.") } + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/theme/Color.kt b/android-app/app/src/main/java/de/harheimertc/ui/theme/Color.kt new file mode 100644 index 0000000..f8b1da1 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/theme/Color.kt @@ -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) diff --git a/android-app/app/src/main/java/de/harheimertc/ui/theme/Theme.kt b/android-app/app/src/main/java/de/harheimertc/ui/theme/Theme.kt new file mode 100644 index 0000000..33aa5bc --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/theme/Theme.kt @@ -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 + ) +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/theme/Typography.kt b/android-app/app/src/main/java/de/harheimertc/ui/theme/Typography.kt new file mode 100644 index 0000000..001af30 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/theme/Typography.kt @@ -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 + ) +) diff --git a/android-app/app/src/main/res/drawable-xxxhdpi/harheimer_tc_logo.png b/android-app/app/src/main/res/drawable-xxxhdpi/harheimer_tc_logo.png new file mode 100644 index 0000000..d4879c5 Binary files /dev/null and b/android-app/app/src/main/res/drawable-xxxhdpi/harheimer_tc_logo.png differ diff --git a/android-app/app/src/main/res/font/inter_variable.ttf b/android-app/app/src/main/res/font/inter_variable.ttf new file mode 100644 index 0000000..047c92f Binary files /dev/null and b/android-app/app/src/main/res/font/inter_variable.ttf differ diff --git a/android-app/app/src/main/res/font/montserrat_variable.ttf b/android-app/app/src/main/res/font/montserrat_variable.ttf new file mode 100644 index 0000000..c97aca1 Binary files /dev/null and b/android-app/app/src/main/res/font/montserrat_variable.ttf differ diff --git a/android-app/app/src/main/res/font_README.md b/android-app/app/src/main/res/font_README.md new file mode 100644 index 0000000..2671cf1 --- /dev/null +++ b/android-app/app/src/main/res/font_README.md @@ -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). \ No newline at end of file diff --git a/android-app/app/src/main/res/values/colors.xml b/android-app/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..ab1782c --- /dev/null +++ b/android-app/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + #ef4444 + #dc2626 + #71717a + \ No newline at end of file diff --git a/android-app/app/src/main/res/values/themes.xml b/android-app/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..879eacb --- /dev/null +++ b/android-app/app/src/main/res/values/themes.xml @@ -0,0 +1,8 @@ + + + + diff --git a/android-app/app/src/main/res/xml/file_paths.xml b/android-app/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..971fc91 --- /dev/null +++ b/android-app/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android-app/build.gradle.kts b/android-app/build.gradle.kts new file mode 100644 index 0000000..4672809 --- /dev/null +++ b/android-app/build.gradle.kts @@ -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 { +} diff --git a/android-app/build/reports/problems/problems-report.html b/android-app/build/reports/problems/problems-report.html new file mode 100644 index 0000000..9a11add --- /dev/null +++ b/android-app/build/reports/problems/problems-report.html @@ -0,0 +1,666 @@ + + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/android-app/gradle.properties b/android-app/gradle.properties new file mode 100644 index 0000000..2433678 --- /dev/null +++ b/android-app/gradle.properties @@ -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 diff --git a/android-app/gradle/wrapper/gradle-wrapper.jar b/android-app/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..8bdaf60 Binary files /dev/null and b/android-app/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android-app/gradle/wrapper/gradle-wrapper.properties b/android-app/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..c61a118 --- /dev/null +++ b/android-app/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/android-app/gradlew b/android-app/gradlew new file mode 100755 index 0000000..ef07e01 --- /dev/null +++ b/android-app/gradlew @@ -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" "$@" diff --git a/android-app/gradlew.bat b/android-app/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/android-app/gradlew.bat @@ -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 diff --git a/android-app/java_pid672503.hprof b/android-app/java_pid672503.hprof new file mode 100644 index 0000000..9ac724b Binary files /dev/null and b/android-app/java_pid672503.hprof differ diff --git a/android-app/scripts/download-fonts.sh b/android-app/scripts/download-fonts.sh new file mode 100755 index 0000000..1567ae5 --- /dev/null +++ b/android-app/scripts/download-fonts.sh @@ -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." \ No newline at end of file diff --git a/android-app/settings.gradle.kts b/android-app/settings.gradle.kts new file mode 100644 index 0000000..4baa9c0 --- /dev/null +++ b/android-app/settings.gradle.kts @@ -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") diff --git a/components/MannschaftenUebersicht.vue b/components/MannschaftenUebersicht.vue index 23d8f55..2bcbf7c 100644 --- a/components/MannschaftenUebersicht.vue +++ b/components/MannschaftenUebersicht.vue @@ -102,7 +102,7 @@ diff --git a/components/cms/CmsMannschaften.vue b/components/cms/CmsMannschaften.vue index 4aefdbd..4009705 100644 --- a/components/cms/CmsMannschaften.vue +++ b/components/cms/CmsMannschaften.vue @@ -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 }) } diff --git a/package-lock.json b/package-lock.json index 7dbbd7f..a2ce108 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index f70ff36..fabe241 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pages/mannschaften/index.vue b/pages/mannschaften/index.vue index 676323c..027be06 100644 --- a/pages/mannschaften/index.vue +++ b/pages/mannschaften/index.vue @@ -6,8 +6,27 @@
-

- Unsere aktiven Mannschaften in der Saison {{ selectedSeasonLabel }} +

+ Unsere aktiven Mannschaften in der Saison + +

@@ -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', }) - diff --git a/server/api/auth/logout.post.js b/server/api/auth/logout.post.js index e945f3e..bba2234 100644 --- a/server/api/auth/logout.post.js +++ b/server/api/auth/logout.post.js @@ -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) => { }) } }) - diff --git a/server/api/auth/status.get.js b/server/api/auth/status.get.js index 83c6a92..4aac15f 100644 --- a/server/api/auth/status.get.js +++ b/server/api/auth/status.get.js @@ -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) => { } } }) -