@@ -10,6 +10,22 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
clean: true
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Ensure clean workspace
|
||||
run: |
|
||||
git reset --hard HEAD
|
||||
git clean -fdx
|
||||
|
||||
- name: Debug dependency files
|
||||
run: |
|
||||
echo "commit: $(git rev-parse HEAD)"
|
||||
echo "branch ref: ${GITHUB_REF:-unknown}"
|
||||
echo "package.json checksum:" && sha256sum package.json
|
||||
echo "package-lock.json checksum:" && sha256sum package-lock.json
|
||||
echo "eslint entries in package.json:" && rg '"eslint"' package.json || true
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
@@ -72,7 +88,11 @@ jobs:
|
||||
rm -f gitleaks.tar.gz
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: |
|
||||
if ! npm ci; then
|
||||
echo "WARNING: npm ci fehlgeschlagen (Lockfile-Drift?). Fallback auf npm install."
|
||||
npm install
|
||||
fi
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
@@ -90,13 +90,13 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
|
||||
- [x] `/cms`, `/cms/startseite`, `/cms/inhalte`, `/cms/vereinsmeisterschaften`
|
||||
- [x] `/cms/sportbetrieb`, `/cms/mitgliederverwaltung`, `/cms/kontaktanfragen`
|
||||
- [x] `/cms/newsletter`, `/cms/einstellungen`, `/cms/benutzer`
|
||||
[ ] 11. API-Client: Retrofit/Ktor-Client implementieren, Auth-Interceptor (Token Refresh)
|
||||
[x] 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 (`/api/profile`, `/api/birthdays`, `/api/members`, `/api/news`, CMS-Leseendpunkte erledigt)
|
||||
- [x] Bearer-Unterstützung aller später portierten geschützten Bereiche (`/api/profile`, `/api/birthdays`, `/api/members`, `/api/news`, CMS-/Newsletter-/Galerie-Endpunkte erledigt); Web bleibt bewusst Cookie-basiert
|
||||
- [x] `POST /api/auth/refresh` anbinden und Access-Token bei Ablauf automatisch erneuern
|
||||
- [x] OkHttp-`Authenticator` mit genau einem synchronisierten Refresh-Versuch pro fehlgeschlagenem Request ergänzen
|
||||
[x] 12. Auth: Login/Register/Logout + sichere Token-Speicherung (EncryptedSharedPreferences)
|
||||
@@ -113,22 +113,41 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
|
||||
- [x] Passkey-Erstellung und -Entfernung im nativen Profil ergänzen
|
||||
- [x] Backend-Passkey-Endpunkte für Android-Bearer-Token und Android-Refresh-Sitzungen erweitern
|
||||
- [ ] Für Produktion `/.well-known/assetlinks.json` mit Package `de.harheimertc` und Release-Zertifikat-Fingerprint veröffentlichen
|
||||
[ ] 14. Image-Upload: Multipart-Upload + Coil für Anzeige + Bildkompression (u. a. Sharp-Äquivalent evtl. serverseitig)
|
||||
[x] 14. Image-Upload: Multipart-Upload + Coil für Anzeige + Bildkompression (u. a. Sharp-Äquivalent evtl. serverseitig)
|
||||
[x] 15. Rich-Text: Anzeige von HTML (Compose + WebView) und ggf. Editor via WebView-bridge
|
||||
- [x] Gemeinsame native HTML/Rich-Text-Komponente für CMS- und News-Inhalte ergänzt
|
||||
- [x] Öffentliche CMS-Seiten, Startseiten-News und interne News rendern HTML-Inhalte nativ
|
||||
- [ ] Rich-Text-Editor für CMS-Bearbeitung als WebView-Bridge prüfen, falls CMS-Editieren nativ ausgebaut wird
|
||||
- [x] Nativer Rich-Text-Editor für CMS-Inhalte ergänzt; Toolbar schreibt Quill-kompatible HTML-Fragmente und speichert denselben HTML-String wie die Web-UI
|
||||
[x] 16. Formulare: Validierung (clientseitig) und Fehlerdarstellung
|
||||
- [x] Gemeinsame validierte Textfelder mit Inline-Fehlern ergänzt
|
||||
- [x] Kontakt, Login, Passwort-Reset, Registrierung, Newsletter, Profil und Mitgliedschaft zeigen Feldfehler direkt am Eingabefeld
|
||||
[ ] 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
|
||||
[x] 17. Offline & Caching: Room für persistente Daten, Response-Caching, Sync-Strategie
|
||||
- [x] Zentraler OkHttp-Cache für öffentliche GET-Antworten ergänzt; Offline-Fallback nutzt gecachte Antworten bis 7 Tage
|
||||
- [x] Zentraler Coil-ImageLoader mit gemeinsamem OkHttp-Client, Memory-Cache und 75-MB-Diskcache ergänzt
|
||||
- [x] Verschlüsselte persistente Offline-Daten für geschützte Mitglieder-/CMS-Inhalte mit `EncryptedSharedPreferences` implementiert
|
||||
[x] 18. Lokalisierung: `strings.xml` (DE + EN) und i18n-Check
|
||||
- [x] App-Name und neue Galerie-Upload-Texte in deutsche und englische Ressourcen ausgelagert
|
||||
- [x] i18n-Check durchgeführt; ältere Compose-Harttexte bleiben als separate, risikoarme Nachmigration offen
|
||||
[x] 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)
|
||||
- [x] Erste JVM-Unit-Tests für gemeinsame Formularvalidierung ergänzt
|
||||
- [x] ViewModel-Tests für Auth-/CMS-/Galerie-Flows ergänzen
|
||||
- [ ] Compose-UI-Tests für kritische Screens ergänzen
|
||||
- [x] Hilt androidTest dependencies und `kspAndroidTest` konfiguriert
|
||||
- [x] `HiltTestApplication` in `androidTest`-Manifest gesetzt
|
||||
- [x] `LoginScreenTest` zu `@HiltAndroidTest` migriert und `HiltAndroidRule` hinzugefügt
|
||||
- [x] `TestHiltModules.kt` für androidTest hinzugefügt (Test‑Bindings bereitgestellt)
|
||||
[x] 21. Performance: Bildoptimierung, LazyLists, Paging (falls große Daten)
|
||||
[ ] 22. Analytics: Firebase / Matomo Integration (je nach Datenschutz)
|
||||
[ ] 23. Crash-Reporting: Sentry / Crashlytics integrieren
|
||||
[x] 23. Crash-Reporting: Sentry / Crashlytics integrieren
|
||||
[ ] 24. Build/Release: App signing, Release-Notes, Play-Store-Metadaten vorbereiten
|
||||
- [x] Technische Release-Basis vorbereitet: `ANDROID_VERSION_CODE` und `ANDROID_VERSION_NAME` als Gradle-Properties eingeführt (`android-app/gradle.properties`) und im App-Gradle verdrahtet.
|
||||
- [x] Production-Release-Flavor auf Produktiv-Backend parametrisierbar gemacht (`PRODUCTION_API_BASE_URL`, Default `https://harheimertc.de/`).
|
||||
- [x] Release-Signing per sicheren Gradle-Properties vorbereitet (`RELEASE_STORE_FILE`, `RELEASE_STORE_PASSWORD`, `RELEASE_KEY_ALIAS`, `RELEASE_KEY_PASSWORD`) statt Hardcoding.
|
||||
- [x] `:app:assembleProductionRelease` erfolgreich gebaut (Stand 2026-05-29).
|
||||
- [x] Play-Store-Listing-Basis ergänzt: Datenschutzseite unter `/datenschutz` sowie Skripte für Icon/Feature-Graphic-Export und Screenshot-Anonymisierung inklusive Anleitung (`android-app/PLAYSTORE_ASSETS.md`).
|
||||
- [x] Konto-Lösch-URL für Play Store ergänzt: öffentliche Seite unter `/konto-loeschen` inklusive Prozessbeschreibung.
|
||||
- [ ] Offen: Finales Upload-Keystore + Credentials in CI/Build-Host hinterlegen, Play-Store-Release-Notes und Store-Metadaten pflegen.
|
||||
[ ] 25. Dokumentation: `README-android.md` mit Setup, Architektur und Release-Anleitung
|
||||
|
||||
5) Kurzzeit-MVP (Priorität für erste Version)
|
||||
@@ -138,7 +157,7 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
|
||||
- [x] 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] D. Bildanzeige + Caching
|
||||
- [x] E. Theme & Fonts
|
||||
|
||||
6) Nächste Aktionen (sofort)
|
||||
@@ -146,7 +165,7 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
|
||||
- Release-Zertifikat erzeugen und Digital Asset Links für native Android-Passkeys veröffentlichen.
|
||||
- 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.
|
||||
- Weitere Mitgliederbereich/CMS-Screens portieren; dabei rollenabhängige `Intern`-Navigation und Bearer-Unterstützung der jeweils verwendeten Backend-Endpunkte ergänzen.
|
||||
- Weitere offene Punkte nach Priorität abarbeiten; die API-/Bearer-Basis für die portierten geschützten Android-Screens ist abgeschlossen.
|
||||
|
||||
7) Umsetzungsprotokoll
|
||||
- 2026-05-27: Webnahe Startseite mit öffentlichen Live-Daten umgesetzt; Hilt/Moshi-App-Verdrahtung ergänzt.
|
||||
@@ -173,6 +192,14 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
|
||||
- 2026-05-28: 10a und 10b abgeschlossen: Impressum, Newsletter-An-/Abmeldung und Newsletter-Statusseiten sind nativ umgesetzt; Legacy-/Doppelrouten im Android-NavGraph auf native Screens abgebildet. Abgleich `pages/` gegen Android-Routen ergab keine verbleibenden Platzhalter-Webseiten. `/anlagen` wurde anschließend als ungenutzte und inhaltlich falsche Seite in Web und Android entfernt.
|
||||
- 2026-05-28: Android-Passkeys umgesetzt: Login nutzt Android Credential Manager mit den bestehenden WebAuthn-Optionen, das native Profil kann Passkeys erstellen/entfernen, und die Backend-Passkey-Endpunkte akzeptieren Bearer-Tokens sowie erzeugen beim Android-Passkey-Login rotierende Refresh-Sitzungen. Für Produktion fehlt noch die Domain-App-Verknüpfung per Digital Asset Links mit Release-Zertifikat.
|
||||
- 2026-05-28: Punkte 15 und 16 umgesetzt: Gemeinsame `RichText`-Komponente rendert HTML-Inhalte nativ in CMS-/News-Screens; Formularvalidierung wurde mit Inline-Feldfehlern für Kontakt, Auth, Newsletter, Profil und Mitgliedschaft ergänzt.
|
||||
- 2026-05-28: Rich-Text-Editor nativ umgesetzt: `/cms/inhalte` kann Über-uns, Geschichte, TT-Regeln und Satzung mit Android-Toolbar bearbeiten; gespeichert wird weiterhin HTML in `seiten.*` über `PUT /api/config`, kompatibel zur Web-/Quill-Ausgabe.
|
||||
- 2026-05-28: Punkt 14 umgesetzt: Android-Galerie nutzt den strukturierten `/api/galerie/list`-Response, lädt Bilder über Coil aus `/api/media/galerie/:id`, und Admin/Vorstand kann Bilder nativ auswählen, lokal auf JPEG/2000px/85% komprimieren und per Multipart an `/api/galerie/upload` senden.
|
||||
- 2026-05-28: Punkt 18 umgesetzt: `strings.xml` für Deutsch und Englisch ergänzt, App-Label und neue Galerie-Upload-UI auf Ressourcen umgestellt; i18n-Check weist die bestehenden älteren Compose-Harttexte als spätere Nachmigration aus.
|
||||
- 2026-05-28: Punkt 11 abgeschlossen: Android sendet Bearer-Tokens zentral per OkHttp-Interceptor; die portierten geschützten Backend-Endpunkte akzeptieren Cookie- oder Bearer-Authentifizierung. Die Web-UI bleibt absichtlich bei HttpOnly-Cookie-Sessions und muss nicht auf Bearer umgestellt werden.
|
||||
- 2026-05-28: Caching-Teil von Punkt 17 und MVP-D umgesetzt: OkHttp cached öffentliche GET-Antworten und nutzt gecachte Antworten offline, Coil nutzt denselben authentifizierten Client plus Memory-/Diskcache. Geschützte Daten werden bewusst nicht unverschlüsselt im HTTP-Diskcache persistiert.
|
||||
- 2026-05-28: Testbasis für Punkt 20 begonnen: JVM-Unit-Tests für E-Mail- und ISO-Datum-Validierung ergänzt; `:app:testLocalDebugUnitTest` läuft mit `compileSdk 35` grün.
|
||||
- 2026-05-28: Punkte 17, 19, 21 und 23 weiter umgesetzt: geschützte Mitglieder-/CMS-Daten werden verschlüsselt in Keystore-gestützten Preferences gecacht und bei Ladefehlern genutzt; Galerie-Accessibility und Thumbnail-Decoding verbessert; Sentry-Android 8.42.0 über optionalen `SENTRY_DSN`-Gradle-Parameter integriert.
|
||||
- 2026-05-29: Play-Store-Listing-Vorbereitung ergänzt: eigenständige Web-Datenschutzseite (`/datenschutz`) sowie Asset-/Anonymisierungs-Skripte und Anleitung in `android-app/PLAYSTORE_ASSETS.md` hinzugefügt.
|
||||
|
||||
8) Android-Testumgebungen
|
||||
- Lokal im Emulator: `./gradlew :app:installLocalDebug` verwendet `http://10.0.2.2:3100/` und die App-ID `de.harheimertc.local`.
|
||||
@@ -181,6 +208,27 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
|
||||
- Produktion: `./gradlew :app:installProductionDebug` verwendet `https://harheimertc.de/` und die App-ID `de.harheimertc`.
|
||||
- Nur APKs erzeugen: `./gradlew :app:assembleLocalDebug :app:assembleInstantTestDebug :app:assembleProductionDebug`.
|
||||
|
||||
8a) Aktueller Teststatus & Troubleshooting (Stand: 2026-05-28)
|
||||
|
||||
- **Status:** `:app:assembleAndroidTest` läuft durch; `:app:connectedAndroidTest` ist derzeit instabil und schlägt bei Instrumentation-Läufen fehl.
|
||||
- **Beobachtete Probleme:**
|
||||
- Kompilationsfehler in `LoginScreenTest.kt` wegen `HiltTestActivity` (Unresolved reference). Workaround: `createAndroidComposeRule<ComponentActivity>()` + `setContent{}` verwenden, damit `assembleAndroidTest` durchläuft.
|
||||
- Laufzeit-/Device-Probleme bei `connectedAndroidTest`: `com.android.ddmlib.SyncException: Remote object doesn't exist!` und `DELETE_FAILED_INTERNAL_ERROR` beim Deinstallieren von Test-APKs.
|
||||
- `AndroidTestLogcatPlugin` wirft `FileNotFoundException` für erwartete Log-/Crash-Dateien, weil Gradle/UTP manche Device-Artefakte nicht zuverlässig pulled.
|
||||
- Einzelne Instrumentation-Tests (z. B. `CmsActivateResendTest`, `GalleryScreenTest`) zeigen Assertion-Fehlschläge — diese sollten isoliert reproduziert werden.
|
||||
- **Kurzfristige Empfehlungen (nicht ausführen):**
|
||||
- Emulator neu starten und sicherstellen, dass keine veralteten Test-APKs installiert sind.
|
||||
- Manuell: `adb uninstall` der Test-Pakete, dann frisches `adb install -r` des Test-APKs und gezielter Einzeltest via:
|
||||
|
||||
`adb shell am instrument -w -e class <test-class>#<testMethod> de.harheimertc.test/androidx.test.runner.AndroidJUnitRunner`
|
||||
|
||||
parallel `adb logcat -v time > /tmp/harheimertc_live_logcat.txt` laufen lassen, um vollständige Logs zu speichern.
|
||||
- Falls UTP/ddmlib `SyncException` weiter auftritt: Gradle-Parallelität reduzieren, Test-Plugins (z. B. `AndroidTestLogcatPlugin`) temporär deaktivieren oder Tests in kleinere Gruppen splitten.
|
||||
- **Offene Test‑To‑Dos:**
|
||||
- Reproduzierbaren Einzeltest-Run mit vollständigem `logcat` erfassen (derzeit vom Nutzer pausiert).
|
||||
- Flaky Tests isolieren und Hilt/KSP-Setup prüfen, damit `HiltTestActivity`-Importe nicht mehr fehlschlagen.
|
||||
- Langfristig: Tests aufteilen, flaky tests markieren und CI-Job für androidTests gegen UTP-Transient-Fehler härten.
|
||||
|
||||
9) Dauerhaftes Android-Login: Architektur und Umsetzung
|
||||
- Stand der Umsetzung: Android-Logins erhalten ein ca. 15 Minuten gültiges JWT und eine serverseitig prüfbare Refresh-Sitzung; Access-Tokens mit Sitzungs-ID werden bei widerrufener Gerätesitzung abgelehnt. Web-Logins verwenden weiterhin das bisherige Cookie-JWT, bis ein browserseitiger Refresh-Flow ergänzt ist.
|
||||
- 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.
|
||||
@@ -209,3 +257,116 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
|
||||
|
||||
---
|
||||
Datei: [ANDROID_KOTLIN_PLAN.md](ANDROID_KOTLIN_PLAN.md)
|
||||
|
||||
**CMS-Verbesserungsplan (Analyse → Umsetzung)**
|
||||
|
||||
Ziel: Alle `cms/*`-Screens von rudimentärem Status zu vollständigen, getesteten Admin-Tools weiterentwickeln. Fokus: Datenintegrität, Berechtigungen, bessere UI/UX, Offline-Verhalten und Tests.
|
||||
|
||||
Kurzüberblick (3 Phasen):
|
||||
- Phase A — Analyse (1-2 Tage): Inventar aller CMS-Endpunkte, fehlende CRUD-Workflows identifizieren, Prioritäten setzen (News, Benutzer, Kontaktanfragen, Newsletter, Config). Ergebnis: Aufgabenliste mit Aufwandsschätzung.
|
||||
- Phase B — Implementierung MVP (1-2 Wochen): Kernfunktionen pro Bereich implementieren (News CRUD mit RichText-Vorschau, Benutzerliste + Rollen-Edit, Kontaktanfragen Detail & Antwort-Workflow, Newsletter-Gruppen-Management, Config-Editor inklusive Satzung-PDF-Feld). Unit- / Integrationstests für ViewModels.
|
||||
- Phase C — Harden, UX & Tests (1 Woche): Validierung, Fehlermeldungen, Offline-Caching (verschlüsselt für geschützte Daten), Compose-UI-Tests, Accessibility-, Performance-Feinschliff.
|
||||
|
||||
Detaillierte Aufgaben (priorisiert):
|
||||
- A1: Audit `CmsViewModel`-State vs. Backend-Responses — fehlen Felder/Fehlerfälle? (bereits teilweise umgesetzt)
|
||||
- A2: Prüfen, ob API-Fehler (4xx/5xx) sauber an `FormMessages`/UI gemeldet werden — Standardisiere Fehlermeldungen.
|
||||
- A3: Prüfen, ob `NativeRichTextEditor` HTML speichert, das Web-Editor-kompatibel bleibt (Quill/HTML). Schreibe Roundtrip-Tests.
|
||||
- B1: News-Management
|
||||
- B1.1: News-CRUD: Create/Update/Delete mit Vorschau (RichText-Preview) und Validierung (Titel Pflicht, Inhalt Mindestlänge)
|
||||
- B1.2: Bulk-Aktionen: Sichtbar/Unsichtbar/ExpiresAt setzen
|
||||
- B1.3: Unit-Tests für `NewsViewModel` + `CmsViewModel`-Integrationspfad
|
||||
- B2: Benutzer-Management
|
||||
- B2.1: Rollen-Edit (admin/vorstand/trainer/newsletter) in `CmsBenutzerScreen` (Inline-Action oder Detail-Dialog)
|
||||
- B2.2: Aktiv/Inaktiv Toggle + Resend-Invite (falls API unterstützt)
|
||||
- B2.3: Tests: `CmsViewModel.users()` Verhalten bei Pagination/Leeren Listen
|
||||
- B3: Kontaktanfragen
|
||||
- B3.1: Detailansicht mit Antwort-Option (falls Backend Mail-Sende-Endpunkt vorhanden)
|
||||
- B3.2: Status-Filter (offen/beantwortet) und Bulk-Archiv
|
||||
- B4: Newsletter
|
||||
- B4.1: Entwurf -> Senden Flow mit Preview (falls Backend zulässt)
|
||||
- B4.2: Gruppenverwaltung (CRUD) + Subscribe/Unsubscribe-Preview
|
||||
- B5: Config / Seiten (Inhalte)
|
||||
- B5.1: Sichern/Zurücksetzen von Seiteninhalten mit Undo-Hinweis
|
||||
- B5.2: Satzung: PDF-Upload-Feld und native PDF-Viewer-Integration (falls serverseitig gespeichert)
|
||||
- B5.6: Android-Startseite weiter ausbauen: Nutzer sollen Elemente und Reihenfolge der Startseite selbst zusammenstellen koennen; Detailkonzept und Feinschliff folgen spaeter
|
||||
- B6: Diagnostics / Passwort-Reset-Diagnose
|
||||
- B6.1: Detail-View mit exportierbaren Logs (bei Bedarf)
|
||||
- C1: Offline-/Caching-Strategie
|
||||
- C1.1: Verschlüsseltes lokales Caching für CMS-Daten (EncryptedSharedPreferences/Room)
|
||||
- C1.2: Sync-Strategie: lokale Änderungen buffernd senden, Konflikt-UI
|
||||
- C2: Tests & CI
|
||||
- C2.1: ViewModel-Unit-Tests für alle CMS-Flows
|
||||
- C2.2: Compose-UI-Tests für kritische Pfade (News erstellen, Benutzerrolle ändern, Config speichern)
|
||||
- C2.3: androidTest Hilt-Stubs erweitern (falls nötig)
|
||||
|
||||
Minor UX-Verbesserungen (parallel möglich):
|
||||
- konsistente Buttons/Labels (`Speichern` vs `Inhalt speichern`), Ladezustand-UI, einzeilige Success-/Error-Banner, Inline-Validierungen.
|
||||
|
||||
Deliverables & Milestones:
|
||||
- M1 (nach Analyse): Priorisierte Aufgabenliste + Schätzung (mehrere PRs)
|
||||
- M2 (nach MVP-Implementierung): News + Benutzer + ContactRequests + Config Editor + Tests (smoke)
|
||||
- M3 (Final): Offline, UI-Tests, Accessibility, Performance
|
||||
|
||||
Zeitplanung (empfohlen):
|
||||
- Analyse: 2 Arbeitstage
|
||||
- MVP-Implementierung: 7–10 Arbeitstage
|
||||
- Hardening + Tests: 3–5 Arbeitstage
|
||||
|
||||
Wenn du willst, trage ich die einzelnen Subtickets in unserem lokalen Issue-Tracker (oder als separate TODOs) ein und beginne mit A1/A2.
|
||||
|
||||
**TODO (zum Abhaken) — CMS-Implementierung**
|
||||
|
||||
- [x] A1: Audit `CmsViewModel` vs Backend-Responses (Fehleraggregation implementiert)
|
||||
- [x] A2: Standardisiere API-Fehlerdarstellung in UI (`FormMessages` / globale Errors)
|
||||
- [x] A3: Roundtrip-Tests `NativeRichTextEditor` ↔ Backend-HTML (Kompatibilität / Quill)
|
||||
- [x] B1: News-Management
|
||||
- [x] B1.1: News-CRUD (Create/Update/Delete) mit RichText-Vorschau
|
||||
- [x] B1.2: Bulk-Aktionen (sichtbar/unsichtbar, expiresAt)
|
||||
- [x] B1.3: Unit-Tests für `NewsViewModel`
|
||||
|
||||
- [x] B2: Benutzer-Management
|
||||
- [x] B2.1: Rollen-Edit (Inline oder Detail-Dialog)
|
||||
- [x] B2.2: Aktiv/Inaktiv Toggle, Resend-Invite
|
||||
- [x] B2.3: Tests für Pagination/Leere Listen
|
||||
|
||||
- [x] B3: Kontaktanfragen
|
||||
- [x] B3.1: Detailansicht + Antwort-Option
|
||||
- [x] B3.2: Status-Filter + Archiv
|
||||
|
||||
- [ ] B4: Newsletter
|
||||
- [x] B4.1: Entwurf → Senden Flow mit Preview
|
||||
- [x] B4.2: Gruppenverwaltung (CRUD)
|
||||
|
||||
- [x] B5: Config / Seiten
|
||||
- Web‑Status: Die Web‑UI bietet bereits umfassende CMS‑UIs für `cms/startseite`, `cms/vereinsmeisterschaften`, `cms/sportbetrieb` und `cms/einstellungen` (Drag&Drop, CSV‑Import/Export, Tabbed‑UIs, ImageUpload, native‑like Modals). `cms/startseite` speichert `homepage.sections` via `PUT /api/config`, `vereinsmeisterschaften` arbeitet mit CSV‑Export/Import, `sportbetrieb` kapselt Termine/Mannschaften/Spielpläne in Tabs, `einstellungen` ist ein umfangreicher Config‑Editor.
|
||||
- Android‑Status: Implementiert — die Android‑App enthält native CMS‑Screens (`CmsStartseiteScreen`, `CmsVereinsmeisterschaftenScreen`, `CmsSportbetriebScreen`, `CmsEinstellungenScreen`) mit Save/Load‑Flows via `CmsViewModel`.
|
||||
- Umsetzung (B5.x):
|
||||
- [x] B5.1: `cms/startseite` (Startseiten‑Layout) — Reorderable/Visibility + Save → `PUT /api/config` (via `CmsViewModel`).
|
||||
- [x] B5.2: `cms/vereinsmeisterschaften` — CSV‑Parser/CSV‑Save integration and modal CRUD (native UI present).
|
||||
- [x] B5.3: `cms/sportbetrieb` — Tabbed UI reusing `Termine`, `Mannschaften`, `Spielplan` components.
|
||||
- [x] B5.4: `cms/einstellungen` — Tabbed config editor with Vereinsdaten/Training/Trainer/Mitgliedschaft and save.
|
||||
- [x] B5.5: Roundtrip & Tests — basic ViewModel unit tests and roundtrip checks exist; Compose UI smoke tests remain for hardening.
|
||||
- [x] B5.6: Startseite weiter ausgebaut — zusaetzliche Elemente (`training`, `links`, `vereinsmeisterschaften`) sind konfigurierbar; Android kann Reihenfolge/Sichtbarkeit lokal speichern und Web nutzt Marker (`cookie`, `eingeloggt`) mit marker-spezifischer Persistenz: `eingeloggt` wird als individuelles User-Setting serverseitig gespeichert, `cookie` wird ausschliesslich im Browser-Cookie gehalten. Neu umgesetzt: konfigurierbare Startseiten-Widgets vom Typ `spielplan_team` (Saison + Mannschaft beim Hinzufuegen waehlbar, spaeter jederzeit aenderbar, mehrfach pro Startseite moeglich, persistiert ueber `key` + `config`).
|
||||
|
||||
- [x] B6: Diagnostics / Passwort-Reset-Diagnose (Export/Detail)
|
||||
- Web‑Status: `cms/passwort-reset-diagnose` zeigt vollständige Diagnose‑UI mit Suche, Maskierung, Filter (nur Auffälligkeiten) und listbaren Reset‑Versuchen; Backend: `/api/cms/password-reset-diagnostics` liefert `matchingUsers`, `attempts`, `retentionHours`.
|
||||
- Android‑Status: umgesetzt — native Diagnose‑UI mit Suche (`email`/Name), Filter `Nur Auffälligkeiten`, `matchingUsers`‑Liste mit Schnellfilter, detaillierter Schrittansicht je Versuch (Zeit/Schritt/Status/Grund), Refresh und Share‑Export der maskierten Logs.
|
||||
- Konkrete Android‑ToDos (B6.x):
|
||||
- [x] B6.1: Implementieren Suche + Filter UI, Rendering der `attempts` mit Zeitstempeln, Status‑Badges und Details.
|
||||
- [x] B6.2: Logs exportieren / share (falls API Export unterstützt) und Datenschutz: E‑Mail Maskierung beibehalten.
|
||||
|
||||
- [x] C1: Offline-/Caching-Strategie (verschlüsselt für geschützte CMS-Daten)
|
||||
- Umgesetzt: EncryptedSharedPreferences-basierter Offline-Cache mit Zeitstempel/TTL pro Cache-Key (CMS standard 24h, Reset-Diagnose 6h).
|
||||
- Umgesetzt: Fallback auf verschlüsselte Cache-Daten bei Ladefehlern nur innerhalb der TTL, um veraltete geschützte CMS-Daten zu begrenzen.
|
||||
- Umgesetzt: Gezielte Cache-Invalidierung bei schreibenden CMS-Operationen (Konfiguration, Benutzerverwaltung, Kontaktanfragen, Newsletter, interne News), damit Offline-Daten nach Änderungen konsistent bleiben.
|
||||
- Umgesetzt: Passwort-Reset-Diagnose-Cache wird nur für den Standardfilter (ohne Suchbegriff) verwendet, um falsche Treffer bei gefilterten Diagnosen zu vermeiden.
|
||||
- [x] C2: Tests & CI
|
||||
- [x] C2.1: ViewModel-Unit-Tests für CMS-Flows (`CmsViewModel.load()` / `saveConfig()`)
|
||||
- Status: `:app:testLocalDebugUnitTest` läuft grün; `CmsViewModelTest` wurde auf aktuelle Repository-Signaturen und vollständige `load()`-Abhängigkeiten (inkl. `vereinsmeisterschaften`) aktualisiert.
|
||||
- [x] C2.2: Compose-UI-Tests für kritische Flows
|
||||
- Status: neuer Instrumentation-Test für `CmsPasswordResetDiagnosticsScreen` ergänzt (`diagnosticsScreen_showsFilterAndAttemptDetails`) und gezielt per `connectedLocalDebugAndroidTest` erfolgreich ausgeführt.
|
||||
- [x] C2.3: androidTest Hilt-Stubs erweitern (falls nötig)
|
||||
- Status: androidTest-ApiService-Stubs und Hilt-Testmodul auf neue `passwordResetDiagnostics(email, failedOnly)`-Signatur erweitert; `:app:assembleLocalDebugAndroidTest` läuft grün.
|
||||
|
||||
Markiere die Items, wenn erledigt — ich kann die einzelnen Punkte jetzt in Branches/PRs umsetzen.
|
||||
|
||||
|
||||
70
android-app/PLAYSTORE_ASSETS.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Play Store Assets - Harheimer TC Android
|
||||
|
||||
## 1) Datenschutzerklaerung (Web-URL)
|
||||
|
||||
Empfohlene URL fuer Play Console:
|
||||
- https://harheimertc.de/datenschutz
|
||||
|
||||
Die Seite ist in der Web-App als eigene Route vorhanden.
|
||||
|
||||
## 1b) Konto-Loeschung (Web-URL)
|
||||
|
||||
Empfohlene URL fuer Play Console:
|
||||
- https://harheimertc.de/konto-loeschen
|
||||
|
||||
Die Seite beschreibt den Loeschprozess und Kontaktweg fuer App- und Webkonto.
|
||||
|
||||
## 2) Logo / Grafiken
|
||||
|
||||
### Pflicht
|
||||
- App-Icon (Play): 512 x 512 PNG
|
||||
|
||||
### Optional, aber empfohlen
|
||||
- Feature Graphic: 1024 x 500 PNG
|
||||
|
||||
### Generierung
|
||||
|
||||
Im Repo ist ein Script vorhanden, das aus dem Vereinslogo fertige Dateien erzeugt:
|
||||
|
||||
```bash
|
||||
./scripts/playstore-assets.sh
|
||||
```
|
||||
|
||||
Ausgabe in:
|
||||
- android-app/playstore-assets/generated/playstore-icon-512.png
|
||||
- android-app/playstore-assets/generated/playstore-feature-graphic-1024x500.png
|
||||
|
||||
## 3) Screenshots (anonymisiert)
|
||||
|
||||
### Grobe Anforderungen (Telefon)
|
||||
- Mindestens 2 Screenshots
|
||||
- PNG oder JPEG
|
||||
- Seitenlaenge je Seite zwischen 320 px und 3840 px
|
||||
|
||||
Empfehlung fuer Android-Phone:
|
||||
- 1080 x 1920 (Portrait)
|
||||
|
||||
### Anonymisierung
|
||||
|
||||
Script fuer schwarze halbtransparente Balken ueber sensible Bereiche:
|
||||
|
||||
```bash
|
||||
./scripts/anonymize-playstore-screenshot.sh <input.png> <output.png> 'x,y,w,h;x,y,w,h'
|
||||
```
|
||||
|
||||
Beispiel:
|
||||
|
||||
```bash
|
||||
./scripts/anonymize-playstore-screenshot.sh \
|
||||
android-app/playstore-assets/raw/screen1.png \
|
||||
android-app/playstore-assets/anon/screen1-anon.png \
|
||||
'68,118,520,72;70,706,560,98'
|
||||
```
|
||||
|
||||
## 4) Upload in Play Console
|
||||
|
||||
- Datenschutzerklaerung: URL eintragen
|
||||
- Konto-Loeschung: URL eintragen
|
||||
- App-Icon: playstore-icon-512.png
|
||||
- Feature Graphic: playstore-feature-graphic-1024x500.png
|
||||
- Screenshots: anonymisierte PNG/JPEG hochladen
|
||||
@@ -6,19 +6,73 @@ plugins {
|
||||
}
|
||||
|
||||
val localApiBaseUrl = providers.gradleProperty("LOCAL_API_BASE_URL")
|
||||
.orElse("http://10.0.2.2:3100/")
|
||||
.orElse("https://harheimertc.tsschulz.de/")
|
||||
.get()
|
||||
val productionApiBaseUrl = providers.gradleProperty("PRODUCTION_API_BASE_URL")
|
||||
.orElse("https://harheimertc.de/")
|
||||
.get()
|
||||
val sentryDsn = providers.gradleProperty("SENTRY_DSN")
|
||||
.orElse("")
|
||||
.get()
|
||||
val androidVersionCode = providers.gradleProperty("ANDROID_VERSION_CODE")
|
||||
.orElse("2")
|
||||
.get()
|
||||
.toInt()
|
||||
val androidVersionName = providers.gradleProperty("ANDROID_VERSION_NAME")
|
||||
.orElse("1.0.0")
|
||||
.get()
|
||||
val releaseMinifyEnabled = providers.gradleProperty("RELEASE_MINIFY_ENABLED")
|
||||
.orElse("true")
|
||||
.get()
|
||||
.toBoolean()
|
||||
val releaseStoreFile = providers.gradleProperty("RELEASE_STORE_FILE").orNull
|
||||
val releaseStorePassword = providers.gradleProperty("RELEASE_STORE_PASSWORD").orNull
|
||||
val releaseKeyAlias = providers.gradleProperty("RELEASE_KEY_ALIAS").orNull
|
||||
val releaseKeyPassword = providers.gradleProperty("RELEASE_KEY_PASSWORD").orNull
|
||||
val hasReleaseSigning = !releaseStoreFile.isNullOrBlank() &&
|
||||
!releaseStorePassword.isNullOrBlank() &&
|
||||
!releaseKeyAlias.isNullOrBlank() &&
|
||||
!releaseKeyPassword.isNullOrBlank()
|
||||
|
||||
android {
|
||||
namespace = "de.harheimertc"
|
||||
compileSdk = 34
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "de.harheimertc"
|
||||
minSdk = 24
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
targetSdk = 35
|
||||
versionCode = androidVersionCode
|
||||
versionName = androidVersionName
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
if (hasReleaseSigning) {
|
||||
storeFile = file(releaseStoreFile!!)
|
||||
storePassword = releaseStorePassword
|
||||
keyAlias = releaseKeyAlias
|
||||
keyPassword = releaseKeyPassword
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
getByName("release") {
|
||||
isMinifyEnabled = releaseMinifyEnabled
|
||||
isShrinkResources = false
|
||||
ndk {
|
||||
// Generate a native debug symbols archive for Play Console uploads.
|
||||
debugSymbolLevel = "SYMBOL_TABLE"
|
||||
}
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
if (hasReleaseSigning) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions += "environment"
|
||||
@@ -28,6 +82,7 @@ android {
|
||||
applicationIdSuffix = ".local"
|
||||
versionNameSuffix = "-local"
|
||||
buildConfigField("String", "API_BASE_URL", "\"$localApiBaseUrl\"")
|
||||
buildConfigField("String", "SENTRY_DSN", "\"\"")
|
||||
buildConfigField("String", "ENVIRONMENT_NAME", "\"LOCAL\"")
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
||||
}
|
||||
@@ -36,12 +91,14 @@ android {
|
||||
applicationIdSuffix = ".test"
|
||||
versionNameSuffix = "-test"
|
||||
buildConfigField("String", "API_BASE_URL", "\"https://harheimertc.tsschulz.de/\"")
|
||||
buildConfigField("String", "SENTRY_DSN", "\"$sentryDsn\"")
|
||||
buildConfigField("String", "ENVIRONMENT_NAME", "\"TEST\"")
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||
}
|
||||
create("production") {
|
||||
dimension = "environment"
|
||||
buildConfigField("String", "API_BASE_URL", "\"https://harheimertc.de/\"")
|
||||
buildConfigField("String", "API_BASE_URL", "\"$productionApiBaseUrl\"")
|
||||
buildConfigField("String", "SENTRY_DSN", "\"$sentryDsn\"")
|
||||
buildConfigField("String", "ENVIRONMENT_NAME", "\"\"")
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||
}
|
||||
@@ -57,6 +114,51 @@ android {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.all {
|
||||
// allow Byte Buddy experimental features for newer JVMs
|
||||
it.jvmArgs = listOf("-Dnet.bytebuddy.experimental=true")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val packageNativeDebugSymbolsForProductionRelease = tasks.register<Zip>("packageNativeDebugSymbolsForProductionRelease") {
|
||||
group = "distribution"
|
||||
description = "Packages production release native libraries into a Play Console debug symbols zip."
|
||||
dependsOn(":app:mergeProductionReleaseNativeLibs")
|
||||
from(layout.buildDirectory.dir("intermediates/merged_native_libs/productionRelease/mergeProductionReleaseNativeLibs/out/lib"))
|
||||
destinationDirectory.set(layout.buildDirectory.dir("outputs/native-debug-symbols/productionRelease"))
|
||||
archiveFileName.set("native-debug-symbols.zip")
|
||||
}
|
||||
|
||||
val collectPlayStoreArtifacts = tasks.register("collectPlayStoreArtifacts") {
|
||||
group = "distribution"
|
||||
description = "Builds production release artifacts and collects AAB, mapping, and native symbols for Play Console upload."
|
||||
dependsOn(":app:bundleProductionRelease")
|
||||
dependsOn(packageNativeDebugSymbolsForProductionRelease)
|
||||
|
||||
doLast {
|
||||
val outputDir = layout.buildDirectory.dir("outputs/playstore/productionRelease").get().asFile
|
||||
outputDir.mkdirs()
|
||||
|
||||
val bundleFile = layout.buildDirectory.file("outputs/bundle/productionRelease/app-production-release.aab").get().asFile
|
||||
if (bundleFile.exists()) {
|
||||
bundleFile.copyTo(outputDir.resolve(bundleFile.name), overwrite = true)
|
||||
}
|
||||
|
||||
val mappingFile = layout.buildDirectory.file("outputs/mapping/productionRelease/mapping.txt").get().asFile
|
||||
if (mappingFile.exists()) {
|
||||
mappingFile.copyTo(outputDir.resolve("mapping.txt"), overwrite = true)
|
||||
}
|
||||
|
||||
val nativeSymbolsZip = layout.buildDirectory.file("outputs/native-debug-symbols/productionRelease/native-debug-symbols.zip").get().asFile
|
||||
if (nativeSymbolsZip.exists()) {
|
||||
nativeSymbolsZip.copyTo(outputDir.resolve("native-debug-symbols.zip"), overwrite = true)
|
||||
}
|
||||
|
||||
println("Play Store artifacts prepared in: ${outputDir.absolutePath}")
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
@@ -101,6 +203,9 @@ dependencies {
|
||||
// Coil
|
||||
implementation("io.coil-kt:coil-compose:2.4.0")
|
||||
|
||||
// Crash reporting
|
||||
implementation("io.sentry:sentry-android:8.42.0")
|
||||
|
||||
// Room
|
||||
implementation("androidx.room:room-runtime:2.6.1")
|
||||
ksp("androidx.room:room-compiler:2.6.1")
|
||||
@@ -113,4 +218,17 @@ dependencies {
|
||||
|
||||
// Testing (skeleton)
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
|
||||
testImplementation("io.mockk:mockk:1.13.7")
|
||||
// Compose UI testing
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.5.0")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")
|
||||
// Hilt testing
|
||||
androidTestImplementation("com.google.dagger:hilt-android-testing:2.59.2")
|
||||
// Ensure Hilt runtime is available in the test APK so HiltTestApplication can be instantiated
|
||||
androidTestImplementation("com.google.dagger:hilt-android:2.59.2")
|
||||
kspAndroidTest("com.google.dagger:hilt-compiler:2.59.2")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest:1.5.0")
|
||||
}
|
||||
|
||||
BIN
android-app/app/instantTest/release/app-instantTest-release.aab
Normal file
BIN
android-app/app/local/release/app-local-release.aab
Normal file
BIN
android-app/app/production/release/app-production-release.aab
Normal file
2
android-app/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Project-specific R8/ProGuard rules for release builds.
|
||||
# Keep this file intentionally minimal and add rules only when needed.
|
||||
7
android-app/app/src/androidTest/AndroidManifest.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application android:allowBackup="false">
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,6 @@
|
||||
// Disabled TestBindingsModule — replaced by TestHiltModules.kt
|
||||
// Kept as an empty placeholder to avoid accidental compilation of the previous
|
||||
// broken test module. Refer to TestHiltModules.kt for test bindings.
|
||||
package de.harheimertc.test
|
||||
|
||||
// Intentionally empty
|
||||
@@ -0,0 +1,90 @@
|
||||
package de.harheimertc.test
|
||||
|
||||
import com.squareup.moshi.Moshi
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.hilt.testing.TestInstallIn
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.AuthStatusResponse
|
||||
import de.harheimertc.data.LoginRequest
|
||||
import de.harheimertc.data.LoginResponse
|
||||
import de.harheimertc.data.AuthUserDto
|
||||
import de.harheimertc.repositories.AuthRepository
|
||||
import de.harheimertc.data.SessionRefresher
|
||||
import dagger.hilt.InstallIn
|
||||
import retrofit2.Response
|
||||
import javax.inject.Singleton
|
||||
import java.lang.reflect.InvocationHandler
|
||||
import java.lang.reflect.Method
|
||||
import java.lang.reflect.Proxy
|
||||
import de.harheimertc.repositories.LoginRepository
|
||||
import de.harheimertc.repositories.PasskeyRepository
|
||||
import de.harheimertc.repositories.AuthRepository as RepoAuthRepository
|
||||
|
||||
@Module
|
||||
@TestInstallIn(
|
||||
components = [SingletonComponent::class],
|
||||
replaces = [de.harheimertc.data.NetworkModule::class, de.harheimertc.di.RepositoryModule::class]
|
||||
)
|
||||
object TestHiltModules {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideMoshi(): Moshi = Moshi.Builder().build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideApiService(): ApiService {
|
||||
val handler = InvocationHandler { _, method: Method, args: Array<Any>? ->
|
||||
when (method.name) {
|
||||
"login" -> Response.success(LoginResponse(success = true, accessToken = "test-token", refreshToken = "r", sessionId = "s", user = AuthUserDto(id = "1", email = "test@example.com", name = "Test")))
|
||||
"authStatus" -> Response.success(AuthStatusResponse(isLoggedIn = false))
|
||||
"publicNews" -> Response.success(de.harheimertc.data.NewsPublicResponse(news = listOf()))
|
||||
"memberNews" -> Response.success(de.harheimertc.data.NewsResponse(success = true, news = listOf()))
|
||||
"passwordResetDiagnostics" -> Response.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
|
||||
else -> throw UnsupportedOperationException("ApiService method not implemented in test double: ${method.name}")
|
||||
}
|
||||
}
|
||||
|
||||
return Proxy.newProxyInstance(
|
||||
ApiService::class.java.classLoader,
|
||||
arrayOf(ApiService::class.java),
|
||||
handler,
|
||||
) as ApiService
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthRepository(): AuthRepository = object : AuthRepository {
|
||||
private var token: String? = "test-token"
|
||||
private var refresh: String? = "r"
|
||||
override fun getToken(): String? = token
|
||||
override fun getRefreshToken(): String? = refresh
|
||||
override fun getSessionId(): String? = "s"
|
||||
override fun setSession(accessToken: String?, refreshToken: String?, sessionId: String?) {
|
||||
token = accessToken
|
||||
refresh = refreshToken
|
||||
}
|
||||
|
||||
override fun clearSession() { token = null; refresh = null }
|
||||
override fun ensureDeviceKey(): String? = null
|
||||
override fun getDevicePublicKey(): String? = null
|
||||
override fun signWithDeviceKey(data: ByteArray): ByteArray? = null
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSessionRefresher(auth: AuthRepository, moshi: Moshi): SessionRefresher = SessionRefresher(auth, moshi)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideLoginRepository(api: ApiService, auth: AuthRepository, sessionRefresher: SessionRefresher): LoginRepository {
|
||||
return LoginRepository(api, auth, sessionRefresher)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePasskeyRepository(api: ApiService, auth: AuthRepository): PasskeyRepository {
|
||||
return PasskeyRepository(api, auth)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package de.harheimertc.ui
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
|
||||
class TestActivity : ComponentActivity()
|
||||
@@ -0,0 +1,113 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CmsActivateResendTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun activateAndResend_buttonsAreClickable() {
|
||||
composeTestRule.setContent {
|
||||
androidx.compose.material3.TextButton(onClick = { /* no-op */ }) { androidx.compose.material3.Text("Deaktivieren") }
|
||||
androidx.compose.material3.TextButton(onClick = { /* no-op */ }) { androidx.compose.material3.Text("Invite erneut") }
|
||||
}
|
||||
|
||||
// wait until nodes appear to avoid race conditions on slower devices
|
||||
fun waitForText(text: String, timeoutMs: Long = 15000L) {
|
||||
try {
|
||||
composeTestRule.waitUntil(timeoutMs) {
|
||||
try {
|
||||
composeTestRule.onAllNodes(hasText(text)).fetchSemanticsNodes().isNotEmpty()
|
||||
} catch (_: AssertionError) {
|
||||
false
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
// dump semantics tree for debugging before failing
|
||||
try {
|
||||
composeTestRule.onRoot().printToLog("CmsActivateResendTest-SEMTREE")
|
||||
} catch (_: Throwable) { /* best-effort logging */ }
|
||||
throw AssertionError("Timed out waiting for text: '$text'")
|
||||
}
|
||||
}
|
||||
|
||||
// helper: find the nearest parent node that has a click action
|
||||
fun findClickableParent(text: String): SemanticsNodeInteraction {
|
||||
val all = composeTestRule.onAllNodes(hasText(text))
|
||||
if (all.fetchSemanticsNodes().isEmpty()) {
|
||||
try {
|
||||
composeTestRule.onRoot().printToLog("CmsActivateResendTest-SEMTREE-NOT-FOUND-$text")
|
||||
} catch (_: Throwable) { }
|
||||
throw AssertionError("No node found with text '$text'")
|
||||
}
|
||||
|
||||
// Log matches for debugging
|
||||
try {
|
||||
val matches = all.fetchSemanticsNodes()
|
||||
Log.d("CmsActivateResendTest", "Found ${matches.size} node(s) for text '$text'")
|
||||
matches.forEachIndexed { i, n -> Log.d("CmsActivateResendTest", "Match[$i]: ${n}") }
|
||||
} catch (_: Throwable) { /* ignore logging failures */ }
|
||||
|
||||
var node = try {
|
||||
// prefer the single-node API, but fall back to the first match if ambiguous
|
||||
composeTestRule.onNode(hasText(text))
|
||||
} catch (_: AssertionError) {
|
||||
all[0]
|
||||
}
|
||||
|
||||
// climb a few parents to find the clickable wrapper
|
||||
repeat(8) {
|
||||
try {
|
||||
node.assert(hasClickAction())
|
||||
try { Log.d("CmsActivateResendTest", "Clickable node found for '$text': ${node.fetchSemanticsNode()}") } catch (_: Throwable) {}
|
||||
return node
|
||||
} catch (_: AssertionError) {
|
||||
try { Log.d("CmsActivateResendTest", "Node not clickable yet, current node: ${node.fetchSemanticsNode()}") } catch (_: Throwable) {}
|
||||
node = node.onParent()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
composeTestRule.onRoot().printToLog("CmsActivateResendTest-SEMTREE-NO-CLICK-$text")
|
||||
} catch (_: Throwable) { }
|
||||
throw AssertionError("No clickable parent found for text '$text'")
|
||||
}
|
||||
|
||||
waitForText("Deaktivieren")
|
||||
val deactivateNode = findClickableParent("Deaktivieren")
|
||||
deactivateNode.assertExists()
|
||||
deactivateNode.assertIsDisplayed()
|
||||
deactivateNode.assert(hasClickAction())
|
||||
composeTestRule.waitForIdle()
|
||||
try {
|
||||
deactivateNode.performClick()
|
||||
} catch (e: Throwable) {
|
||||
composeTestRule.onRoot().printToLog("CmsActivateResendTest-CLICK-FAIL-DEACTIVATE")
|
||||
throw e
|
||||
}
|
||||
|
||||
waitForText("Invite erneut")
|
||||
val inviteNode = findClickableParent("Invite erneut")
|
||||
inviteNode.assertExists()
|
||||
inviteNode.assertIsDisplayed()
|
||||
inviteNode.assert(hasClickAction())
|
||||
composeTestRule.waitForIdle()
|
||||
try {
|
||||
inviteNode.performClick()
|
||||
} catch (e: Throwable) {
|
||||
composeTestRule.onRoot().printToLog("CmsActivateResendTest-CLICK-FAIL-INVITE")
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
import android.content.Context
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.squareup.moshi.Moshi
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.AuthMessageResponse
|
||||
import de.harheimertc.data.AuthStatusResponse
|
||||
import de.harheimertc.data.BirthdaysResponse
|
||||
import de.harheimertc.data.CmsUserDto
|
||||
import de.harheimertc.data.CmsUsersResponse
|
||||
import de.harheimertc.data.ConfigResponse
|
||||
import de.harheimertc.data.ContactRequest
|
||||
import de.harheimertc.data.ContactRequestDto
|
||||
import de.harheimertc.data.ContactResponse
|
||||
import de.harheimertc.data.LoginRequest
|
||||
import de.harheimertc.data.LoginResponse
|
||||
import de.harheimertc.data.LogoutRequest
|
||||
import de.harheimertc.data.MembersResponse
|
||||
import de.harheimertc.data.MembershipRequest
|
||||
import de.harheimertc.data.MembershipResponse
|
||||
import de.harheimertc.data.NewsletterCreateRequest
|
||||
import de.harheimertc.data.NewsletterCreateResponse
|
||||
import de.harheimertc.data.NewsletterDto
|
||||
import de.harheimertc.data.NewsletterGroupDto
|
||||
import de.harheimertc.data.NewsletterGroupsResponse
|
||||
import de.harheimertc.data.NewsletterListResponse
|
||||
import de.harheimertc.data.NewsletterSendResponse
|
||||
import de.harheimertc.data.NewsletterSubscriptionRequest
|
||||
import de.harheimertc.data.NewsPublicResponse
|
||||
import de.harheimertc.data.NewsResponse
|
||||
import de.harheimertc.data.NewsSaveRequest
|
||||
import de.harheimertc.data.PasskeyAuthenticationOptionsRequest
|
||||
import de.harheimertc.data.PasskeyRegistrationOptionsRequest
|
||||
import de.harheimertc.data.PasskeysResponse
|
||||
import de.harheimertc.data.PasswordResetAttemptDto
|
||||
import de.harheimertc.data.PasswordResetDiagnosticsResponse
|
||||
import de.harheimertc.data.ProfileResponse
|
||||
import de.harheimertc.data.ProfileUpdateRequest
|
||||
import de.harheimertc.data.PublicGalleryImageDto
|
||||
import de.harheimertc.data.GalleryListResponse
|
||||
import de.harheimertc.data.GalleryUploadResponse
|
||||
import de.harheimertc.data.RefreshRequest
|
||||
import de.harheimertc.data.RegistrationRequest
|
||||
import de.harheimertc.data.RemovePasskeyRequest
|
||||
import de.harheimertc.data.ResetPasswordRequest
|
||||
import de.harheimertc.data.SaveCsvRequest
|
||||
import de.harheimertc.data.SaveCsvResponse
|
||||
import de.harheimertc.data.SecureOfflineCache
|
||||
import de.harheimertc.data.SpielplanResponse
|
||||
import de.harheimertc.data.TeamTableResponse
|
||||
import de.harheimertc.data.TermineResponse
|
||||
import de.harheimertc.repositories.CmsRepository
|
||||
import de.harheimertc.repositories.MeisterschaftResult
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.ResponseBody
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import retrofit2.Response
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CmsExistingScreensSmokeTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun cmsDashboard_renders() {
|
||||
val api = RecordingApiService()
|
||||
val viewModel = createViewModel(api)
|
||||
renderWithState(viewModel)
|
||||
composeTestRule.setContent {
|
||||
val nav = rememberNavController()
|
||||
CmsDashboardScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Vereinsmeisterschaften", substring = true).assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cmsStartseite_saveWorks() {
|
||||
val api = RecordingApiService()
|
||||
val viewModel = createViewModel(api)
|
||||
renderWithState(viewModel)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val nav = rememberNavController()
|
||||
CmsStartseiteScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Speichern").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
assertTrue(api.updateConfigCalls >= 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cmsInhalte_saveWorks() {
|
||||
val api = RecordingApiService()
|
||||
val viewModel = createViewModel(api)
|
||||
renderWithState(viewModel)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val nav = rememberNavController()
|
||||
CmsInhalteScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Inhalte speichern").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
assertTrue(api.updateConfigCalls >= 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cmsVereinsmeisterschaften_saveWorks() {
|
||||
val api = RecordingApiService()
|
||||
val viewModel = createViewModel(api)
|
||||
renderWithState(viewModel)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val nav = rememberNavController()
|
||||
CmsVereinsmeisterschaftenScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Speichern").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
assertEquals(1, api.saveCsvCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cmsSportbetrieb_saveWorks() {
|
||||
val api = RecordingApiService()
|
||||
val viewModel = createViewModel(api)
|
||||
renderWithState(viewModel)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val nav = rememberNavController()
|
||||
CmsSportbetriebScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Speichern").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
assertTrue(api.updateConfigCalls >= 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cmsEinstellungen_saveWorks() {
|
||||
val api = RecordingApiService()
|
||||
val viewModel = createViewModel(api)
|
||||
renderWithState(viewModel)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val nav = rememberNavController()
|
||||
CmsEinstellungenScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Speichern").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
assertTrue(api.updateConfigCalls >= 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cmsMitgliederverwaltung_clickFreischalten() {
|
||||
val api = RecordingApiService()
|
||||
val viewModel = createViewModel(api)
|
||||
renderWithState(viewModel)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val nav = rememberNavController()
|
||||
CmsMitgliederverwaltungScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Freischalten").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
assertEquals(1, api.updateUserActiveCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cmsBenutzer_clickRollenSpeichern() {
|
||||
val api = RecordingApiService()
|
||||
val viewModel = createViewModel(api)
|
||||
renderWithState(viewModel)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val nav = rememberNavController()
|
||||
CmsBenutzerScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Rollen").performClick()
|
||||
composeTestRule.onNodeWithText("Speichern").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
assertEquals(1, api.updateUserRolesCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cmsContactRequests_replySenden() {
|
||||
val api = RecordingApiService()
|
||||
val viewModel = createViewModel(api)
|
||||
renderWithState(viewModel)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val nav = rememberNavController()
|
||||
CmsContactRequestsScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Antworten").performClick()
|
||||
composeTestRule.onNode(hasSetTextAction()).performTextInput("Kurze Testantwort")
|
||||
composeTestRule.onNodeWithText("Senden").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
assertEquals(1, api.replyContactCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cmsNewsletter_createAndSave() {
|
||||
val api = RecordingApiService()
|
||||
val viewModel = createViewModel(api)
|
||||
renderWithState(viewModel)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val nav = rememberNavController()
|
||||
CmsNewsletterScreen(nav, showBackNavigation = false, viewModel = viewModel, canWriteOverride = true)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Newsletter erstellen").performClick()
|
||||
composeTestRule.onNode(hasSetTextAction()).performTextInput("Testnewsletter")
|
||||
composeTestRule.onAllNodes(hasText("Speichern")).onFirst().performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
assertEquals(1, api.createNewsletterCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cmsPasswordResetDiagnostics_renders() {
|
||||
val api = RecordingApiService()
|
||||
val viewModel = createViewModel(api)
|
||||
renderWithState(viewModel)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val nav = rememberNavController()
|
||||
CmsPasswordResetDiagnosticsScreen(nav, showBackNavigation = false, viewModel = viewModel)
|
||||
}
|
||||
composeTestRule.onNodeWithText("Passwort-Reset-Diagnose", substring = true).assertExists()
|
||||
}
|
||||
|
||||
private fun createViewModel(api: RecordingApiService): CmsViewModel {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val cache = SecureOfflineCache(context, Moshi.Builder().build())
|
||||
val repository = CmsRepository(api, cache)
|
||||
return CmsViewModel(repository)
|
||||
}
|
||||
|
||||
private fun renderWithState(viewModel: CmsViewModel) {
|
||||
val readyState = CmsUiState(
|
||||
loading = false,
|
||||
saving = false,
|
||||
error = null,
|
||||
message = null,
|
||||
config = ConfigResponse(),
|
||||
users = listOf(
|
||||
CmsUserDto(id = "pending-1", name = "Pending User", email = "pending@example.com", active = false, roles = emptyList()),
|
||||
CmsUserDto(id = "active-1", name = "Active User", email = "active@example.com", active = true, roles = listOf("vorstand")),
|
||||
),
|
||||
contactRequests = listOf(
|
||||
ContactRequestDto(id = "request-1", name = "Kontakt Test", email = "kontakt@example.com", message = "Bitte melden", status = "offen"),
|
||||
),
|
||||
newsletters = listOf(
|
||||
NewsletterDto(id = "newsletter-1", title = "Sommerinfo", subject = "Sommerinfo", status = "draft"),
|
||||
),
|
||||
newsletterGroups = listOf(
|
||||
NewsletterGroupDto(id = "group-1", name = "Mitglieder", description = "Alle Mitglieder"),
|
||||
),
|
||||
passwordResetAttempts = listOf(
|
||||
PasswordResetAttemptDto(requestId = "diag-1", emailMasked = "m***@example.com", failed = true),
|
||||
),
|
||||
news = emptyList(),
|
||||
meisterschaften = listOf(
|
||||
MeisterschaftResult(year = "2025", category = "Herren", rank = "1", playerOne = "Erika Muster", playerTwo = "", note = "Titel verteidigt", imageOne = "", imageTwo = ""),
|
||||
),
|
||||
)
|
||||
val field = CmsViewModel::class.java.getDeclaredField("_state")
|
||||
field.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(field.get(viewModel) as MutableStateFlow<CmsUiState>).value = readyState
|
||||
}
|
||||
}
|
||||
|
||||
private class RecordingApiService : ApiService {
|
||||
var updateConfigCalls = 0
|
||||
var saveCsvCalls = 0
|
||||
var updateUserActiveCalls = 0
|
||||
var updateUserRolesCalls = 0
|
||||
var replyContactCalls = 0
|
||||
var createNewsletterCalls = 0
|
||||
|
||||
private val config = ConfigResponse()
|
||||
private val users = listOf(
|
||||
CmsUserDto(id = "pending-1", name = "Pending User", email = "pending@example.com", active = false, roles = emptyList()),
|
||||
CmsUserDto(id = "active-1", name = "Active User", email = "active@example.com", active = true, roles = listOf("vorstand")),
|
||||
)
|
||||
private val contactRequests = listOf(
|
||||
ContactRequestDto(id = "request-1", name = "Kontakt Test", email = "kontakt@example.com", message = "Bitte melden", status = "offen"),
|
||||
)
|
||||
private val newsletters = listOf(
|
||||
NewsletterDto(id = "newsletter-1", title = "Sommerinfo", subject = "Sommerinfo", status = "draft"),
|
||||
)
|
||||
private val groups = listOf(
|
||||
NewsletterGroupDto(id = "group-1", name = "Mitglieder", description = "Alle Mitglieder"),
|
||||
)
|
||||
private val diagnostics = listOf(
|
||||
PasswordResetAttemptDto(requestId = "diag-1", emailMasked = "m***@example.com", failed = true),
|
||||
)
|
||||
|
||||
override suspend fun publicGalleryImages(): Response<List<PublicGalleryImageDto>> = Response.success(emptyList())
|
||||
override suspend fun postContact(req: ContactRequest): Response<ContactResponse> = Response.success(ContactResponse(ok = true))
|
||||
override suspend fun galerieList(page: Int, perPage: Int): Response<GalleryListResponse> = Response.success(GalleryListResponse())
|
||||
override suspend fun uploadGalleryImage(image: MultipartBody.Part, title: RequestBody, description: RequestBody, isPublic: RequestBody): Response<GalleryUploadResponse> = Response.success(GalleryUploadResponse())
|
||||
override suspend fun termine(): Response<TermineResponse> = Response.success(TermineResponse())
|
||||
override suspend fun spielplan(season: String?): Response<SpielplanResponse> = Response.success(SpielplanResponse())
|
||||
override suspend fun spielplanTable(team: String, season: String?): Response<TeamTableResponse> = Response.success(TeamTableResponse())
|
||||
override suspend fun publicNews(): Response<NewsPublicResponse> = Response.success(NewsPublicResponse())
|
||||
override suspend fun memberNews(): Response<NewsResponse> = Response.success(NewsResponse(success = true, news = emptyList()))
|
||||
override suspend fun saveNews(request: NewsSaveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true, message = "ok"))
|
||||
override suspend fun deleteNews(id: Int): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun mannschaften(season: String?): Response<ResponseBody> = Response.success(null)
|
||||
override suspend fun config(): Response<ConfigResponse> = Response.success(config)
|
||||
override suspend fun updateConfig(request: ConfigResponse): Response<ConfigResponse> {
|
||||
updateConfigCalls++
|
||||
return Response.success(request)
|
||||
}
|
||||
override suspend fun spielsysteme(): Response<ResponseBody> = Response.success(null)
|
||||
override suspend fun vereinsmeisterschaften(): Response<ResponseBody> =
|
||||
Response.success("Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung,imageFilename1,imageFilename2\n\"2025\",\"Herren\",\"1\",\"Erika Muster\",\"\",\"Titel verteidigt\",\"\",\"\"".toResponseBody(null))
|
||||
override suspend fun saveCsv(request: SaveCsvRequest): Response<SaveCsvResponse> {
|
||||
saveCsvCalls++
|
||||
return Response.success(SaveCsvResponse(success = true, message = "CSV gespeichert"))
|
||||
}
|
||||
override suspend fun generateMembershipPdf(request: MembershipRequest): Response<MembershipResponse> = Response.success(MembershipResponse())
|
||||
override suspend fun downloadMembershipPdf(downloadUrl: String): Response<ResponseBody> = Response.success(null)
|
||||
override suspend fun login(request: LoginRequest): Response<LoginResponse> = Response.success(LoginResponse())
|
||||
override suspend fun logout(request: LogoutRequest): Response<Unit> = Response.success(Unit)
|
||||
override suspend fun refresh(request: RefreshRequest): Response<LoginResponse> = Response.success(LoginResponse())
|
||||
override suspend fun authStatus(): Response<AuthStatusResponse> = Response.success(AuthStatusResponse())
|
||||
override suspend fun resetPassword(request: ResetPasswordRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun register(request: RegistrationRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun passkeyAuthenticationOptions(request: PasskeyAuthenticationOptionsRequest): Response<ResponseBody> = Response.success(null)
|
||||
override suspend fun passkeyLogin(request: RequestBody): Response<LoginResponse> = Response.success(LoginResponse())
|
||||
override suspend fun passkeys(): Response<PasskeysResponse> = Response.success(PasskeysResponse())
|
||||
override suspend fun passkeyRegistrationOptions(request: PasskeyRegistrationOptionsRequest): Response<ResponseBody> = Response.success(null)
|
||||
override suspend fun registerPasskey(request: RequestBody): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun removePasskey(request: RemovePasskeyRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun profile(): Response<ProfileResponse> = Response.success(ProfileResponse())
|
||||
override suspend fun updateProfile(request: ProfileUpdateRequest): Response<ProfileResponse> = Response.success(ProfileResponse())
|
||||
override suspend fun birthdays(): Response<BirthdaysResponse> = Response.success(BirthdaysResponse())
|
||||
override suspend fun members(): Response<MembersResponse> = Response.success(MembersResponse())
|
||||
override suspend fun saveMember(request: ApiService.MemberSaveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun deleteMember(body: Map<String, String>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun bulkImportMembers(request: ApiService.BulkImportRequest): Response<ApiService.BulkImportResponse> = Response.success(ApiService.BulkImportResponse())
|
||||
override suspend fun toggleMannschaftsspieler(body: Map<String, String>): Response<Map<String, Any>> = Response.success(emptyMap())
|
||||
override suspend fun cmsUsers(): Response<CmsUsersResponse> = Response.success(CmsUsersResponse(users = users))
|
||||
override suspend fun updateUserRoles(request: ApiService.UpdateUserRolesRequest): Response<AuthMessageResponse> {
|
||||
updateUserRolesCalls++
|
||||
return Response.success(AuthMessageResponse(success = true, message = "Rollen aktualisiert"))
|
||||
}
|
||||
override suspend fun updateUserActive(request: ApiService.UpdateUserActiveRequest): Response<AuthMessageResponse> {
|
||||
updateUserActiveCalls++
|
||||
return Response.success(AuthMessageResponse(success = true, message = "Status aktualisiert"))
|
||||
}
|
||||
override suspend fun resendInvite(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun contactRequests(): Response<List<ContactRequestDto>> = Response.success(contactRequests)
|
||||
override suspend fun replyToContactRequest(id: String, request: ApiService.ContactReplyRequest): Response<ContactResponse> {
|
||||
replyContactCalls++
|
||||
return Response.success(ContactResponse(ok = true, message = "Antwort versendet"))
|
||||
}
|
||||
override suspend fun toggleContactRequestStatus(id: String): Response<ContactResponse> = Response.success(ContactResponse(ok = true, message = "Status aktualisiert"))
|
||||
override suspend fun newsletters(): Response<NewsletterListResponse> = Response.success(NewsletterListResponse(success = true, newsletters = newsletters))
|
||||
override suspend fun newsletterGroups(): Response<NewsletterGroupsResponse> = Response.success(NewsletterGroupsResponse(success = true, groups = groups))
|
||||
override suspend fun createNewsletterGroup(request: Map<String, Any?>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun updateNewsletterGroup(id: String, request: Map<String, Any?>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun deleteNewsletterGroup(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun createNewsletter(request: NewsletterCreateRequest): Response<NewsletterCreateResponse> {
|
||||
createNewsletterCalls++
|
||||
return Response.success(NewsletterCreateResponse(success = true, message = "Newsletter gespeichert"))
|
||||
}
|
||||
override suspend fun updateNewsletter(id: String, request: Map<String, Any?>): Response<NewsletterCreateResponse> = Response.success(NewsletterCreateResponse(success = true))
|
||||
override suspend fun sendNewsletter(id: String): Response<NewsletterSendResponse> = Response.success(NewsletterSendResponse(success = true))
|
||||
override suspend fun deleteNewsletter(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun publicNewsletterGroups(): Response<NewsletterGroupsResponse> = Response.success(NewsletterGroupsResponse(success = true, groups = groups))
|
||||
override suspend fun subscribeNewsletter(request: NewsletterSubscriptionRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun unsubscribeNewsletter(request: NewsletterSubscriptionRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun confirmNewsletter(token: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun passwordResetDiagnostics(
|
||||
email: String?,
|
||||
failedOnly: Boolean,
|
||||
): Response<PasswordResetDiagnosticsResponse> = Response.success(PasswordResetDiagnosticsResponse(attempts = diagnostics))
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
import android.content.Context
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.squareup.moshi.Moshi
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.CmsUsersResponse
|
||||
import de.harheimertc.data.ConfigResponse
|
||||
import de.harheimertc.data.ContactRequestDto
|
||||
import de.harheimertc.data.NewsDto
|
||||
import de.harheimertc.data.NewsResponse
|
||||
import de.harheimertc.data.NewsletterGroupsResponse
|
||||
import de.harheimertc.data.NewsletterListResponse
|
||||
import de.harheimertc.data.PasswordResetAttemptDto
|
||||
import de.harheimertc.data.PasswordResetDiagnosticsResponse
|
||||
import de.harheimertc.data.PasswordResetStepDto
|
||||
import de.harheimertc.data.SecureOfflineCache
|
||||
import de.harheimertc.repositories.CmsRepository
|
||||
import okhttp3.ResponseBody
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import retrofit2.Response
|
||||
import java.lang.reflect.InvocationHandler
|
||||
import java.lang.reflect.Method
|
||||
import java.lang.reflect.Proxy
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CmsPasswordResetDiagnosticsScreenTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun diagnosticsScreen_showsFilterAndAttemptDetails() {
|
||||
val api = createDiagnosticsApiService()
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val cache = SecureOfflineCache(context, Moshi.Builder().build())
|
||||
val repo = CmsRepository(api, cache)
|
||||
val viewModel = CmsViewModel(repo)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val navController = rememberNavController()
|
||||
CmsPasswordResetDiagnosticsScreen(navController, showBackNavigation = false, viewModel = viewModel)
|
||||
}
|
||||
|
||||
composeTestRule.waitUntil(15_000) {
|
||||
try {
|
||||
composeTestRule.onNodeWithText("Nur Auffälligkeiten", useUnmergedTree = true).assertExists()
|
||||
true
|
||||
} catch (_: Throwable) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Nur Auffälligkeiten", useUnmergedTree = true).assertExists()
|
||||
composeTestRule.onNodeWithText("Prüfen", useUnmergedTree = true).assertExists()
|
||||
composeTestRule.onNodeWithText("Reset-Vorgänge", useUnmergedTree = true).assertExists()
|
||||
composeTestRule.onNodeWithText("Aktualisieren", useUnmergedTree = true).assertExists()
|
||||
|
||||
// Trigger a manual refresh to validate the main interaction path.
|
||||
composeTestRule.onNodeWithText("Prüfen", useUnmergedTree = true).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
}
|
||||
|
||||
private fun createDiagnosticsApiService(): ApiService {
|
||||
val attempt = PasswordResetAttemptDto(
|
||||
requestId = "req-1",
|
||||
startedAt = "2026-05-29T10:15:00Z",
|
||||
emailMasked = "m***@example.com",
|
||||
ip = "127.0.0.1",
|
||||
failed = true,
|
||||
steps = listOf(
|
||||
PasswordResetStepDto(
|
||||
ts = "2026-05-29T10:15:01Z",
|
||||
step = "mail_configuration",
|
||||
status = "failed",
|
||||
reason = "smtp_credentials_missing",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val handler = InvocationHandler { _, method: Method, _ ->
|
||||
when (method.name) {
|
||||
"config" -> Response.success(ConfigResponse())
|
||||
"users" -> Response.success(CmsUsersResponse())
|
||||
"cmsUsers" -> Response.success(CmsUsersResponse())
|
||||
"contactRequests" -> Response.success(listOf<ContactRequestDto>())
|
||||
"newsletters" -> Response.success(NewsletterListResponse(success = true, newsletters = emptyList()))
|
||||
"newsletterGroups" -> Response.success(NewsletterGroupsResponse(success = true, groups = emptyList()))
|
||||
"memberNews" -> Response.success(NewsResponse(success = true, news = listOf(NewsDto(id = 1, title = "N", content = "C"))))
|
||||
"passwordResetDiagnostics" -> Response.success(
|
||||
PasswordResetDiagnosticsResponse(
|
||||
retentionHours = 72,
|
||||
searchedEmail = "",
|
||||
matchingUsers = listOf(
|
||||
de.harheimertc.data.PasswordResetMatchingUserDto(
|
||||
id = "u1",
|
||||
name = "Max Muster",
|
||||
email = "max@example.com",
|
||||
active = true,
|
||||
),
|
||||
),
|
||||
attempts = listOf(attempt),
|
||||
),
|
||||
)
|
||||
"vereinsmeisterschaften" -> Response.success("Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung\n".toResponseBody(null))
|
||||
else -> throw UnsupportedOperationException("Unhandled ApiService method in test: ${method.name}")
|
||||
}
|
||||
}
|
||||
|
||||
return Proxy.newProxyInstance(
|
||||
ApiService::class.java.classLoader,
|
||||
arrayOf(ApiService::class.java),
|
||||
handler,
|
||||
) as ApiService
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.junit4.ComposeTestRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CmsRolesDialogTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
class FakeVm {
|
||||
var calledId: String? = null
|
||||
var calledRoles: List<String>? = null
|
||||
fun updateUserRoles(id: String, roles: List<String>) {
|
||||
calledId = id
|
||||
calledRoles = roles
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rolesDialog_callsUpdateUserRoles() {
|
||||
val fake = FakeVm()
|
||||
val initialRoles = listOf("admin")
|
||||
|
||||
composeTestRule.setContent {
|
||||
val show = remember { mutableStateOf(false) }
|
||||
val selected = remember { mutableStateListOf<String>().apply { addAll(initialRoles) } }
|
||||
Column {
|
||||
Button(onClick = { show.value = true }) { Text("Rollen") }
|
||||
if (show.value) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { show.value = false },
|
||||
title = { Text("Rollen bearbeiten") },
|
||||
text = {
|
||||
Column(modifier = Modifier.padding(4.dp)) {
|
||||
// simple checkbox row for admin only (representative)
|
||||
Row {
|
||||
Checkbox(checked = selected.contains("admin"), onCheckedChange = { checked ->
|
||||
if (checked) selected.add("admin") else selected.remove("admin")
|
||||
})
|
||||
Text("admin", modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
fake.updateUserRoles("42", selected.toList())
|
||||
show.value = false
|
||||
}) { Text("Speichern") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { show.value = false }) { Text("Abbrechen") } }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Open dialog
|
||||
composeTestRule.onNodeWithText("Rollen").performClick()
|
||||
// Save immediately (we keep admin preselected)
|
||||
composeTestRule.onNodeWithText("Speichern").performClick()
|
||||
|
||||
assert(fake.calledId == "42")
|
||||
assert(fake.calledRoles?.contains("admin") == true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CmsScreenTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun cmsScreen_placeholder() {
|
||||
composeTestRule.setContent {
|
||||
Text("CMS Placeholder")
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("CMS Placeholder").assertExists()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.junit4.ComposeTestRule
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.squareup.moshi.Moshi
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.runner.RunWith
|
||||
import retrofit2.Response
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
import de.harheimertc.data.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import de.harheimertc.repositories.CmsRepository
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CmsStartseiteSmokeTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun cmsStartseite_rendersWithDefaultState() {
|
||||
// prepare a minimal fake ApiService that returns empty/neutral responses
|
||||
val fakeApi = object : ApiService {
|
||||
override suspend fun publicGalleryImages(): Response<List<PublicGalleryImageDto>> = Response.success(emptyList())
|
||||
override suspend fun postContact(req: ContactRequest): Response<ContactResponse> = Response.success(ContactResponse(ok = true))
|
||||
override suspend fun galerieList(page: Int, perPage: Int): Response<GalleryListResponse> = Response.success(GalleryListResponse())
|
||||
override suspend fun uploadGalleryImage(image: MultipartBody.Part, title: RequestBody, description: RequestBody, isPublic: RequestBody): Response<GalleryUploadResponse> = Response.success(GalleryUploadResponse())
|
||||
override suspend fun termine(): Response<TermineResponse> = Response.success(TermineResponse())
|
||||
override suspend fun spielplan(season: String?): Response<SpielplanResponse> = Response.success(SpielplanResponse())
|
||||
override suspend fun spielplanTable(team: String, season: String?): Response<TeamTableResponse> = Response.success(TeamTableResponse())
|
||||
override suspend fun publicNews(): Response<NewsPublicResponse> = Response.success(NewsPublicResponse())
|
||||
override suspend fun memberNews(): Response<NewsResponse> = Response.success(NewsResponse(success = true, news = emptyList()))
|
||||
override suspend fun saveNews(request: NewsSaveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true, message = "ok"))
|
||||
override suspend fun deleteNews(id: Int): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun mannschaften(season: String?): Response<okhttp3.ResponseBody> = Response.success(null)
|
||||
override suspend fun config(): Response<ConfigResponse> = Response.success(ConfigResponse())
|
||||
override suspend fun updateConfig(request: ConfigResponse): Response<ConfigResponse> = Response.success(request)
|
||||
override suspend fun spielsysteme(): Response<okhttp3.ResponseBody> = Response.success(null)
|
||||
override suspend fun vereinsmeisterschaften(): Response<okhttp3.ResponseBody> = Response.success(null)
|
||||
override suspend fun saveCsv(request: SaveCsvRequest): Response<SaveCsvResponse> = Response.success(SaveCsvResponse(success = true))
|
||||
override suspend fun generateMembershipPdf(request: MembershipRequest): Response<MembershipResponse> = Response.success(MembershipResponse())
|
||||
override suspend fun downloadMembershipPdf(downloadUrl: String): Response<okhttp3.ResponseBody> = Response.success(null)
|
||||
override suspend fun login(request: LoginRequest): Response<LoginResponse> = Response.success(LoginResponse())
|
||||
override suspend fun logout(request: LogoutRequest): Response<Unit> = Response.success(Unit)
|
||||
override suspend fun refresh(request: RefreshRequest): Response<LoginResponse> = Response.success(LoginResponse())
|
||||
override suspend fun authStatus(): Response<AuthStatusResponse> = Response.success(AuthStatusResponse())
|
||||
override suspend fun resetPassword(request: ResetPasswordRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun register(request: RegistrationRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun passkeyAuthenticationOptions(request: PasskeyAuthenticationOptionsRequest): Response<okhttp3.ResponseBody> = Response.success(null)
|
||||
override suspend fun passkeyLogin(request: okhttp3.RequestBody): Response<LoginResponse> = Response.success(LoginResponse())
|
||||
override suspend fun passkeys(): Response<PasskeysResponse> = Response.success(PasskeysResponse())
|
||||
override suspend fun passkeyRegistrationOptions(request: PasskeyRegistrationOptionsRequest): Response<okhttp3.ResponseBody> = Response.success(null)
|
||||
override suspend fun registerPasskey(request: okhttp3.RequestBody): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun removePasskey(request: RemovePasskeyRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun profile(): Response<ProfileResponse> = Response.success(ProfileResponse())
|
||||
override suspend fun updateProfile(request: ProfileUpdateRequest): Response<ProfileResponse> = Response.success(ProfileResponse())
|
||||
override suspend fun birthdays(): Response<BirthdaysResponse> = Response.success(BirthdaysResponse())
|
||||
override suspend fun members(): Response<MembersResponse> = Response.success(MembersResponse())
|
||||
override suspend fun saveMember(request: ApiService.MemberSaveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun deleteMember(body: Map<String, String>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun bulkImportMembers(request: ApiService.BulkImportRequest): Response<ApiService.BulkImportResponse> = Response.success(ApiService.BulkImportResponse())
|
||||
override suspend fun toggleMannschaftsspieler(body: Map<String, String>): Response<Map<String, Any>> = Response.success(emptyMap())
|
||||
override suspend fun cmsUsers(): Response<CmsUsersResponse> = Response.success(CmsUsersResponse())
|
||||
override suspend fun updateUserRoles(request: ApiService.UpdateUserRolesRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun updateUserActive(request: ApiService.UpdateUserActiveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun resendInvite(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
|
||||
override suspend fun contactRequests(): Response<List<ContactRequestDto>> = Response.success(emptyList())
|
||||
override suspend fun replyToContactRequest(id: String, request: ApiService.ContactReplyRequest): Response<de.harheimertc.data.ContactResponse> = Response.success(de.harheimertc.data.ContactResponse(ok = true))
|
||||
override suspend fun toggleContactRequestStatus(id: String): Response<de.harheimertc.data.ContactResponse> = Response.success(de.harheimertc.data.ContactResponse(ok = true))
|
||||
override suspend fun newsletters(): Response<NewsletterListResponse> = Response.success(NewsletterListResponse(success = true, newsletters = emptyList()))
|
||||
override suspend fun newsletterGroups(): Response<NewsletterGroupsResponse> = Response.success(NewsletterGroupsResponse(success = true, groups = emptyList()))
|
||||
override suspend fun createNewsletterGroup(request: Map<String, Any?>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun updateNewsletterGroup(id: String, request: Map<String, Any?>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun deleteNewsletterGroup(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun createNewsletter(request: NewsletterCreateRequest): Response<NewsletterCreateResponse> = Response.success(NewsletterCreateResponse(success = true))
|
||||
override suspend fun updateNewsletter(id: String, request: Map<String, Any?>): Response<NewsletterCreateResponse> = Response.success(NewsletterCreateResponse(success = true))
|
||||
override suspend fun sendNewsletter(id: String): Response<NewsletterSendResponse> = Response.success(NewsletterSendResponse(success = true))
|
||||
override suspend fun deleteNewsletter(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun publicNewsletterGroups(): Response<NewsletterGroupsResponse> = Response.success(NewsletterGroupsResponse(success = true))
|
||||
override suspend fun subscribeNewsletter(request: NewsletterSubscriptionRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun unsubscribeNewsletter(request: NewsletterSubscriptionRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun confirmNewsletter(token: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
|
||||
override suspend fun passwordResetDiagnostics(
|
||||
email: String?,
|
||||
failedOnly: Boolean,
|
||||
): Response<PasswordResetDiagnosticsResponse> = Response.success(PasswordResetDiagnosticsResponse())
|
||||
}
|
||||
|
||||
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
|
||||
val moshi = Moshi.Builder().build()
|
||||
val cache = SecureOfflineCache(context, moshi)
|
||||
val repo = CmsRepository(fakeApi, cache)
|
||||
val vm = de.harheimertc.ui.screens.cms.CmsViewModel(repo)
|
||||
|
||||
// set a ready state to avoid waiting for async network loads in Vm.init
|
||||
val readyState = de.harheimertc.ui.screens.cms.CmsUiState(
|
||||
loading = false,
|
||||
saving = false,
|
||||
error = null,
|
||||
message = null,
|
||||
config = ConfigResponse(),
|
||||
users = emptyList(),
|
||||
contactRequests = emptyList(),
|
||||
newsletters = emptyList(),
|
||||
newsletterGroups = emptyList(),
|
||||
passwordResetAttempts = emptyList(),
|
||||
news = emptyList(),
|
||||
)
|
||||
try {
|
||||
val field = de.harheimertc.ui.screens.cms.CmsViewModel::class.java.getDeclaredField("_state")
|
||||
field.isAccessible = true
|
||||
val current = field.get(vm) as? MutableStateFlow<*>
|
||||
if (current is MutableStateFlow<*>) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(current as MutableStateFlow<de.harheimertc.ui.screens.cms.CmsUiState>).value = readyState
|
||||
}
|
||||
} catch (_: Throwable) { /* best-effort, continue */ }
|
||||
|
||||
composeTestRule.setContent {
|
||||
val nav = rememberNavController()
|
||||
CmsStartseiteScreen(nav, showBackNavigation = false, viewModel = vm)
|
||||
}
|
||||
|
||||
// dump semantics tree for debugging
|
||||
try {
|
||||
composeTestRule.onRoot().printToLog("CmsStartseiteSmokeTest-SEMTREE")
|
||||
} catch (_: Throwable) { }
|
||||
|
||||
// wait for the main title and info rows to appear
|
||||
fun waitForText(text: String, timeoutMs: Long = 20000L) {
|
||||
composeTestRule.waitUntil(timeoutMs) {
|
||||
try {
|
||||
composeTestRule.onAllNodes(hasText(text, substring = true)).fetchSemanticsNodes().isNotEmpty()
|
||||
} catch (_: AssertionError) { false }
|
||||
}
|
||||
}
|
||||
|
||||
waitForText("Startseite")
|
||||
waitForText("Öffentliche")
|
||||
|
||||
// basic assertions (use substring matching)
|
||||
assertTrue(composeTestRule.onAllNodes(hasText("Startseite", substring = true)).fetchSemanticsNodes().isNotEmpty())
|
||||
assertTrue(composeTestRule.onAllNodes(hasText("Öffentliche", substring = true)).fetchSemanticsNodes().isNotEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.uiautomator.By
|
||||
import androidx.test.uiautomator.UiDevice
|
||||
import androidx.test.uiautomator.UiObject2
|
||||
import androidx.test.uiautomator.Until
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CmsUiAutomatorClickTest {
|
||||
|
||||
@Test
|
||||
fun clickThroughExistingCmsPages_andTrySave() {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val context = instrumentation.targetContext
|
||||
val device = UiDevice.getInstance(instrumentation)
|
||||
val packageName = "de.harheimertc.local"
|
||||
|
||||
val launchIntent = context.packageManager.getLaunchIntentForPackage(packageName)
|
||||
?.apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
assertNotNull("Launch-Intent fuer de.harheimertc.local nicht gefunden", launchIntent)
|
||||
context.startActivity(launchIntent)
|
||||
|
||||
device.wait(Until.hasObject(By.pkg(packageName).depth(0)), 15000)
|
||||
|
||||
clickText(device, "Intern")
|
||||
clickText(device, "CMS")
|
||||
|
||||
openCmsCard(device, "Startseite")
|
||||
clickIfPresent(device, "Speichern")
|
||||
backToCmsDashboard(device)
|
||||
|
||||
openCmsCard(device, "Inhalte")
|
||||
clickIfPresent(device, "Inhalte speichern")
|
||||
backToCmsDashboard(device)
|
||||
|
||||
openCmsCard(device, "Vereinsmeisterschaften")
|
||||
clickIfPresent(device, "Speichern")
|
||||
backToCmsDashboard(device)
|
||||
|
||||
openCmsCard(device, "Sportbetrieb")
|
||||
clickIfPresent(device, "Speichern")
|
||||
backToCmsDashboard(device)
|
||||
|
||||
openCmsCard(device, "Einstellungen")
|
||||
clickIfPresent(device, "Speichern")
|
||||
backToCmsDashboard(device)
|
||||
|
||||
openCmsCard(device, "Mitgliederverwaltung")
|
||||
clickIfPresent(device, "Freischalten")
|
||||
backToCmsDashboard(device)
|
||||
|
||||
openCmsCard(device, "Kontaktanfragen")
|
||||
clickIfPresent(device, "Antworten")
|
||||
clickIfPresent(device, "Abbrechen")
|
||||
backToCmsDashboard(device)
|
||||
|
||||
openCmsCard(device, "Newsletter")
|
||||
clickIfPresent(device, "Newsletter erstellen")
|
||||
clickIfPresent(device, "Abbrechen")
|
||||
backToCmsDashboard(device)
|
||||
|
||||
if (openCmsCardIfAvailable(device, "Benutzer")) {
|
||||
clickIfPresent(device, "Rollen")
|
||||
clickIfPresent(device, "Abbrechen")
|
||||
backToCmsDashboard(device)
|
||||
}
|
||||
|
||||
openCmsCardIfAvailable(device, "Passwort-Reset-Diagnose")
|
||||
|
||||
// Wenn wir am Ende noch im App-Paket sind, ist der Flow nicht gecrasht.
|
||||
device.wait(Until.hasObject(By.pkg(packageName).depth(0)), 5000)
|
||||
}
|
||||
|
||||
private fun openCmsCard(device: UiDevice, label: String) {
|
||||
if (!clickIfPresent(device, label, 2500) && !clickTextWithScroll(device, label)) {
|
||||
clickText(device, label)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openCmsCardIfAvailable(device: UiDevice, label: String): Boolean {
|
||||
if (clickIfPresent(device, label, 1500)) return true
|
||||
return clickTextWithScroll(device, label)
|
||||
}
|
||||
|
||||
private fun backToCmsDashboard(device: UiDevice) {
|
||||
if (!clickIfPresent(device, "CMS", 3000)) {
|
||||
device.pressBack()
|
||||
device.waitForIdle()
|
||||
}
|
||||
}
|
||||
|
||||
private fun clickText(device: UiDevice, text: String, timeoutMs: Long = 10000): UiObject2 {
|
||||
val obj = device.wait(Until.findObject(By.textContains(text)), timeoutMs)
|
||||
requireNotNull(obj) { "Text nicht gefunden: $text" }
|
||||
obj.click()
|
||||
device.waitForIdle()
|
||||
return obj
|
||||
}
|
||||
|
||||
private fun clickIfPresent(device: UiDevice, text: String, timeoutMs: Long = 1500): Boolean {
|
||||
val obj = device.wait(Until.findObject(By.textContains(text)), timeoutMs)
|
||||
if (obj != null) {
|
||||
obj.click()
|
||||
device.waitForIdle()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun clickTextWithScroll(device: UiDevice, text: String, maxSwipes: Int = 5): Boolean {
|
||||
if (clickIfPresent(device, text, 1500)) return true
|
||||
repeat(maxSwipes) {
|
||||
device.swipe(
|
||||
device.displayWidth / 2,
|
||||
(device.displayHeight * 0.8).toInt(),
|
||||
device.displayWidth / 2,
|
||||
(device.displayHeight * 0.25).toInt(),
|
||||
24,
|
||||
)
|
||||
device.waitForIdle()
|
||||
if (clickIfPresent(device, text, 1200)) return true
|
||||
}
|
||||
repeat(maxSwipes) {
|
||||
device.swipe(
|
||||
device.displayWidth / 2,
|
||||
(device.displayHeight * 0.25).toInt(),
|
||||
device.displayWidth / 2,
|
||||
(device.displayHeight * 0.8).toInt(),
|
||||
24,
|
||||
)
|
||||
device.waitForIdle()
|
||||
if (clickIfPresent(device, text, 1200)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package de.harheimertc.ui.screens.gallery
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class GalleryScreenTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun galleryScreen_rendersPlaceholder() {
|
||||
composeTestRule.setContent {
|
||||
GalleryScreen()
|
||||
}
|
||||
|
||||
composeTestRule.onRoot().assertExists()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package de.harheimertc.ui.screens.home
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class HomeScreenTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun homeScreen_renders() {
|
||||
composeTestRule.setContent {
|
||||
val navController = rememberNavController()
|
||||
HomeScreen(navController = navController, showNavigationHeader = false)
|
||||
}
|
||||
|
||||
composeTestRule.onRoot().assertExists()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.harheimertc.ui.screens.login
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class LoginScreenTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun loginScreen_showsFields() {
|
||||
composeTestRule.setContent {
|
||||
val navController = rememberNavController()
|
||||
LoginScreen(navController = navController, showBackNavigation = false)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("E-Mail-Adresse", useUnmergedTree = true).assertExists()
|
||||
composeTestRule.onNodeWithText("Passwort", useUnmergedTree = true).assertExists()
|
||||
composeTestRule.onNodeWithText("Anmelden", useUnmergedTree = true).assertExists()
|
||||
}
|
||||
}
|
||||
8
android-app/app/src/debug/AndroidManifest.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<!-- Disable Sentry automatic initialization in debug/test builds -->
|
||||
<meta-data android:name="io.sentry.auto-init" android:value="false" />
|
||||
<meta-data android:name="io.sentry.dsn" android:value="" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -5,7 +5,8 @@
|
||||
|
||||
<application
|
||||
android:name=".HarheimerApplication"
|
||||
android:label="HarheimerTC"
|
||||
android:label="@string/app_name"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:theme="@style/Theme.HarheimerTC"
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||
<activity android:name="de.harheimertc.MainActivity"
|
||||
|
||||
@@ -1,7 +1,49 @@
|
||||
package de.harheimertc
|
||||
|
||||
import android.app.Application
|
||||
import coil.ImageLoader
|
||||
import coil.ImageLoaderFactory
|
||||
import coil.disk.DiskCache
|
||||
import coil.memory.MemoryCache
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import io.sentry.Sentry
|
||||
import okhttp3.OkHttpClient
|
||||
import javax.inject.Inject
|
||||
import android.util.Log
|
||||
|
||||
@HiltAndroidApp
|
||||
class HarheimerApplication : Application()
|
||||
class HarheimerApplication : Application(), ImageLoaderFactory {
|
||||
@Inject
|
||||
lateinit var okHttpClient: OkHttpClient
|
||||
|
||||
override fun onCreate() {
|
||||
Log.d("HILT", "HarheimerApplication.onCreate called")
|
||||
super.onCreate()
|
||||
if (BuildConfig.SENTRY_DSN.isNotBlank()) {
|
||||
Sentry.init { options ->
|
||||
options.dsn = BuildConfig.SENTRY_DSN
|
||||
options.environment = BuildConfig.ENVIRONMENT_NAME.ifBlank { "production" }
|
||||
options.release = "${BuildConfig.APPLICATION_ID}@${BuildConfig.VERSION_NAME}+${BuildConfig.VERSION_CODE}"
|
||||
options.isEnableAutoSessionTracking = true
|
||||
options.tracesSampleRate = 0.05
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun newImageLoader(): ImageLoader =
|
||||
ImageLoader.Builder(this)
|
||||
.okHttpClient(okHttpClient)
|
||||
.memoryCache {
|
||||
MemoryCache.Builder(this)
|
||||
.maxSizePercent(0.20)
|
||||
.build()
|
||||
}
|
||||
.diskCache {
|
||||
DiskCache.Builder()
|
||||
.directory(cacheDir.resolve("image_cache"))
|
||||
.maxSizeBytes(75L * 1024L * 1024L)
|
||||
.build()
|
||||
}
|
||||
.crossfade(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -6,9 +6,14 @@ 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.navigation.Destinations
|
||||
import de.harheimertc.ui.theme.HarheimerTheme
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import de.harheimertc.ui.navigation.NavigationViewModel
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import android.util.Log
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
@@ -24,7 +29,11 @@ class MainActivity : ComponentActivity() {
|
||||
fun App() {
|
||||
HarheimerTheme {
|
||||
val navController = rememberNavController()
|
||||
NavGraph(navController = navController)
|
||||
val ctx = LocalContext.current
|
||||
val activity = ctx as? ComponentActivity
|
||||
Log.i("HILT_FACTORY", "defaultViewModelProviderFactory=${activity?.defaultViewModelProviderFactory?.javaClass?.name}")
|
||||
val navigationViewModel: NavigationViewModel = hiltViewModel()
|
||||
NavGraph(navController = navController, navigationViewModelParam = navigationViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,15 +5,21 @@ import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.PATCH
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Part
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Query
|
||||
import retrofit2.http.Url
|
||||
import retrofit2.http.Streaming
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.ResponseBody
|
||||
import okhttp3.RequestBody
|
||||
|
||||
data class ContactRequest(val name: String, val email: String, val message: String)
|
||||
data class ContactResponse(val ok: Boolean, val id: String? = null)
|
||||
data class ContactResponse(val ok: Boolean, val id: String? = null, val message: String? = null)
|
||||
data class TermineResponse(val success: Boolean = true, val termine: List<TerminDto> = emptyList())
|
||||
data class TerminDto(
|
||||
val datum: String = "",
|
||||
@@ -101,6 +107,35 @@ data class PublicGalleryImageDto(
|
||||
val filename: String = "",
|
||||
val title: String = "",
|
||||
)
|
||||
data class GalleryImageDto(
|
||||
val id: String = "",
|
||||
val title: String = "",
|
||||
val description: String = "",
|
||||
val isPublic: Boolean = false,
|
||||
val uploadedAt: String? = null,
|
||||
val previewFilename: String? = null,
|
||||
)
|
||||
data class GalleryPaginationDto(
|
||||
val page: Int = 1,
|
||||
val perPage: Int = 10,
|
||||
val total: Int = 0,
|
||||
val totalPages: Int = 0,
|
||||
)
|
||||
data class GalleryListResponse(
|
||||
val success: Boolean = false,
|
||||
val images: List<GalleryImageDto> = emptyList(),
|
||||
val pagination: GalleryPaginationDto = GalleryPaginationDto(),
|
||||
)
|
||||
data class GalleryUploadImageDto(
|
||||
val id: String = "",
|
||||
val title: String = "",
|
||||
val isPublic: Boolean = false,
|
||||
)
|
||||
data class GalleryUploadResponse(
|
||||
val success: Boolean = false,
|
||||
val message: String? = null,
|
||||
val image: GalleryUploadImageDto? = null,
|
||||
)
|
||||
data class MembershipRequest(
|
||||
val vorname: String,
|
||||
val nachname: String,
|
||||
@@ -156,6 +191,16 @@ data class AuthStatusResponse(
|
||||
)
|
||||
data class ResetPasswordRequest(val email: String)
|
||||
data class AuthMessageResponse(val success: Boolean = false, val message: String? = null)
|
||||
data class SaveCsvRequest(
|
||||
val filename: String,
|
||||
val content: String,
|
||||
)
|
||||
data class SaveCsvResponse(
|
||||
val success: Boolean = false,
|
||||
val message: String? = null,
|
||||
val writtenTo: List<String> = emptyList(),
|
||||
val jsonWrittenTo: List<String> = emptyList(),
|
||||
)
|
||||
data class PasskeyAuthenticationOptionsRequest(
|
||||
val email: String? = null,
|
||||
val client: String = "android",
|
||||
@@ -309,14 +354,38 @@ data class SatzungDto(
|
||||
val pdfUrl: String = "",
|
||||
val content: String = "",
|
||||
)
|
||||
data class MembershipTierDto(
|
||||
val id: String = "",
|
||||
val typ: String = "",
|
||||
val beschreibung: String? = null,
|
||||
val preis: Int = 0,
|
||||
val features: List<String> = emptyList(),
|
||||
)
|
||||
data class LinkItemDto(
|
||||
val label: String = "",
|
||||
val href: String = "",
|
||||
val description: String = "",
|
||||
val id: String = "",
|
||||
)
|
||||
data class LinkSectionDto(
|
||||
val title: String = "",
|
||||
val items: List<LinkItemDto> = emptyList(),
|
||||
val id: String = "",
|
||||
)
|
||||
data class HomepageSectionDto(
|
||||
val id: String = "",
|
||||
val enabled: Boolean = true,
|
||||
val key: String? = null,
|
||||
val marker: String? = null,
|
||||
val config: HomepageSectionConfigDto? = null,
|
||||
)
|
||||
data class HomepageSectionConfigDto(
|
||||
val season: String? = null,
|
||||
val teamName: String? = null,
|
||||
val teamAgeGroup: String? = null,
|
||||
)
|
||||
data class HomepageDto(
|
||||
val sections: List<HomepageSectionDto> = emptyList(),
|
||||
)
|
||||
data class SeitenDto(
|
||||
val ueberUns: String = "",
|
||||
@@ -329,10 +398,12 @@ data class SeitenDto(
|
||||
data class ConfigResponse(
|
||||
val training: TrainingDto = TrainingDto(),
|
||||
val trainer: List<TrainerDto> = emptyList(),
|
||||
val mitgliedschaft: List<MembershipTierDto> = emptyList(),
|
||||
val verein: VereinDto = VereinDto(),
|
||||
val vorstand: VorstandDto = VorstandDto(),
|
||||
val website: WebsiteDto = WebsiteDto(),
|
||||
val seiten: SeitenDto = SeitenDto(),
|
||||
val homepage: HomepageDto = HomepageDto(),
|
||||
)
|
||||
data class CmsUserDto(
|
||||
val id: String = "",
|
||||
@@ -368,6 +439,25 @@ data class NewsletterListResponse(
|
||||
val success: Boolean = false,
|
||||
val newsletters: List<NewsletterDto> = emptyList(),
|
||||
)
|
||||
data class NewsletterCreateRequest(
|
||||
val title: String,
|
||||
val content: String,
|
||||
val type: String,
|
||||
val targetGroup: String? = null,
|
||||
val sendToExternal: Boolean? = null,
|
||||
)
|
||||
|
||||
data class NewsletterCreateResponse(
|
||||
val success: Boolean = false,
|
||||
val message: String? = null,
|
||||
val newsletter: NewsletterDto? = null,
|
||||
)
|
||||
|
||||
data class NewsletterSendResponse(
|
||||
val success: Boolean = false,
|
||||
val message: String? = null,
|
||||
val stats: Map<String, Any>? = null,
|
||||
)
|
||||
data class NewsletterGroupDto(
|
||||
val id: String = "",
|
||||
val name: String = "",
|
||||
@@ -400,8 +490,17 @@ data class PasswordResetAttemptDto(
|
||||
val failed: Boolean = false,
|
||||
val steps: List<PasswordResetStepDto> = emptyList(),
|
||||
)
|
||||
data class PasswordResetMatchingUserDto(
|
||||
val id: String = "",
|
||||
val name: String = "",
|
||||
val email: String = "",
|
||||
val active: Boolean = true,
|
||||
val lastLogin: String? = null,
|
||||
)
|
||||
data class PasswordResetDiagnosticsResponse(
|
||||
val retentionHours: Int = 0,
|
||||
val searchedEmail: String? = null,
|
||||
val matchingUsers: List<PasswordResetMatchingUserDto> = emptyList(),
|
||||
val attempts: List<PasswordResetAttemptDto> = emptyList(),
|
||||
)
|
||||
|
||||
@@ -410,7 +509,19 @@ interface ApiService {
|
||||
suspend fun postContact(@Body req: ContactRequest): Response<ContactResponse>
|
||||
|
||||
@GET("/api/galerie/list")
|
||||
suspend fun galerieList(): Response<List<String>>
|
||||
suspend fun galerieList(
|
||||
@Query("page") page: Int = 1,
|
||||
@Query("perPage") perPage: Int = 60,
|
||||
): Response<GalleryListResponse>
|
||||
|
||||
@Multipart
|
||||
@POST("/api/galerie/upload")
|
||||
suspend fun uploadGalleryImage(
|
||||
@Part image: MultipartBody.Part,
|
||||
@Part("title") title: RequestBody,
|
||||
@Part("description") description: RequestBody,
|
||||
@Part("isPublic") isPublic: RequestBody,
|
||||
): Response<GalleryUploadResponse>
|
||||
|
||||
@GET("/api/galerie")
|
||||
suspend fun publicGalleryImages(): Response<List<PublicGalleryImageDto>>
|
||||
@@ -445,12 +556,18 @@ interface ApiService {
|
||||
@GET("/api/config")
|
||||
suspend fun config(): Response<ConfigResponse>
|
||||
|
||||
@PUT("/api/config")
|
||||
suspend fun updateConfig(@Body request: ConfigResponse): Response<ConfigResponse>
|
||||
|
||||
@GET("/data/spielsysteme.csv")
|
||||
suspend fun spielsysteme(): Response<ResponseBody>
|
||||
|
||||
@GET("/api/vereinsmeisterschaften")
|
||||
suspend fun vereinsmeisterschaften(): Response<ResponseBody>
|
||||
|
||||
@POST("/api/cms/save-csv")
|
||||
suspend fun saveCsv(@Body request: SaveCsvRequest): Response<SaveCsvResponse>
|
||||
|
||||
@POST("/api/membership/generate-pdf")
|
||||
suspend fun generateMembershipPdf(@Body request: MembershipRequest): Response<MembershipResponse>
|
||||
|
||||
@@ -506,18 +623,87 @@ interface ApiService {
|
||||
@GET("/api/members")
|
||||
suspend fun members(): Response<MembersResponse>
|
||||
|
||||
data class MemberSaveRequest(
|
||||
val id: String? = null,
|
||||
val firstName: String,
|
||||
val lastName: String,
|
||||
val geburtsdatum: String,
|
||||
val email: String? = null,
|
||||
val phone: String? = null,
|
||||
val address: String? = null,
|
||||
val notes: String? = null,
|
||||
val isMannschaftsspieler: Boolean = false,
|
||||
val hasHallKey: Boolean = false,
|
||||
)
|
||||
|
||||
data class BulkImportRequest(val members: List<Map<String, String>>)
|
||||
data class BulkImportResponse(val success: Boolean = false, val summary: Map<String, Int>? = null)
|
||||
|
||||
@POST("/api/members")
|
||||
suspend fun saveMember(@Body request: MemberSaveRequest): Response<AuthMessageResponse>
|
||||
|
||||
@DELETE("/api/members")
|
||||
suspend fun deleteMember(@Body body: Map<String, String>): Response<AuthMessageResponse>
|
||||
|
||||
@POST("/api/members/bulk")
|
||||
suspend fun bulkImportMembers(@Body request: BulkImportRequest): Response<BulkImportResponse>
|
||||
|
||||
@POST("/api/members/toggle-mannschaftsspieler")
|
||||
suspend fun toggleMannschaftsspieler(@Body body: Map<String, String>): Response<Map<String, Any>>
|
||||
|
||||
@GET("/api/cms/users/list")
|
||||
suspend fun cmsUsers(): Response<CmsUsersResponse>
|
||||
|
||||
data class UpdateUserRolesRequest(val id: String, val roles: List<String>)
|
||||
data class UpdateUserActiveRequest(val id: String, val active: Boolean)
|
||||
|
||||
@PUT("/api/cms/users/update-roles")
|
||||
suspend fun updateUserRoles(@Body request: UpdateUserRolesRequest): Response<AuthMessageResponse>
|
||||
|
||||
@PUT("/api/cms/users/update-active")
|
||||
suspend fun updateUserActive(@Body request: UpdateUserActiveRequest): Response<AuthMessageResponse>
|
||||
|
||||
@POST("/api/cms/users/resend-invite")
|
||||
suspend fun resendInvite(@Query("id") id: String): Response<AuthMessageResponse>
|
||||
|
||||
@GET("/api/cms/contact-requests")
|
||||
suspend fun contactRequests(): Response<List<ContactRequestDto>>
|
||||
|
||||
data class ContactReplyRequest(val message: String)
|
||||
|
||||
@POST("/api/cms/contact-requests/{id}/reply")
|
||||
suspend fun replyToContactRequest(@Path("id") id: String, @Body request: ContactReplyRequest): Response<de.harheimertc.data.ContactResponse>
|
||||
|
||||
@PATCH("/api/cms/contact-requests/{id}/toggle-status")
|
||||
suspend fun toggleContactRequestStatus(@Path("id") id: String): Response<de.harheimertc.data.ContactResponse>
|
||||
|
||||
@GET("/api/newsletter/list")
|
||||
suspend fun newsletters(): Response<NewsletterListResponse>
|
||||
|
||||
@GET("/api/newsletter/groups/list")
|
||||
suspend fun newsletterGroups(): Response<NewsletterGroupsResponse>
|
||||
|
||||
@POST("/api/newsletter/groups/create")
|
||||
suspend fun createNewsletterGroup(@Body request: Map<String, @JvmSuppressWildcards Any?>): Response<AuthMessageResponse>
|
||||
|
||||
@PUT("/api/newsletter/groups/{id}")
|
||||
suspend fun updateNewsletterGroup(@Path("id") id: String, @Body request: Map<String, @JvmSuppressWildcards Any?>): Response<AuthMessageResponse>
|
||||
|
||||
@DELETE("/api/newsletter/groups/{id}")
|
||||
suspend fun deleteNewsletterGroup(@Path("id") id: String): Response<AuthMessageResponse>
|
||||
|
||||
@POST("/api/newsletter/create")
|
||||
suspend fun createNewsletter(@Body request: NewsletterCreateRequest): Response<NewsletterCreateResponse>
|
||||
|
||||
@PUT("/api/newsletter/{id}")
|
||||
suspend fun updateNewsletter(@Path("id") id: String, @Body request: Map<String, @JvmSuppressWildcards Any?>): Response<NewsletterCreateResponse>
|
||||
|
||||
@POST("/api/newsletter/{id}/send")
|
||||
suspend fun sendNewsletter(@Path("id") id: String): Response<NewsletterSendResponse>
|
||||
|
||||
@DELETE("/api/newsletter/{id}")
|
||||
suspend fun deleteNewsletter(@Path("id") id: String): Response<AuthMessageResponse>
|
||||
|
||||
@GET("/api/newsletter/groups/public-list")
|
||||
suspend fun publicNewsletterGroups(): Response<NewsletterGroupsResponse>
|
||||
|
||||
@@ -531,5 +717,8 @@ interface ApiService {
|
||||
suspend fun confirmNewsletter(@Query("token") token: String): Response<AuthMessageResponse>
|
||||
|
||||
@GET("/api/cms/password-reset-diagnostics")
|
||||
suspend fun passwordResetDiagnostics(): Response<PasswordResetDiagnosticsResponse>
|
||||
suspend fun passwordResetDiagnostics(
|
||||
@Query("email") email: String? = null,
|
||||
@Query("failedOnly") failedOnly: Boolean = true,
|
||||
): Response<PasswordResetDiagnosticsResponse>
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
package de.harheimertc.data
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
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.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import okhttp3.Cache
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.JavaNetCookieJar
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
@@ -15,6 +21,7 @@ import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import javax.inject.Singleton
|
||||
import java.net.CookieManager
|
||||
import java.net.CookiePolicy
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
@@ -27,15 +34,48 @@ object NetworkModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(authInterceptor: AuthInterceptor, accessTokenAuthenticator: AccessTokenAuthenticator): OkHttpClient {
|
||||
fun provideHttpCache(@ApplicationContext context: Context): Cache =
|
||||
Cache(context.cacheDir.resolve("http_cache"), 25L * 1024L * 1024L)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(
|
||||
@ApplicationContext context: Context,
|
||||
authInterceptor: AuthInterceptor,
|
||||
accessTokenAuthenticator: AccessTokenAuthenticator,
|
||||
cache: Cache,
|
||||
): OkHttpClient {
|
||||
val logging = HttpLoggingInterceptor()
|
||||
logging.level = HttpLoggingInterceptor.Level.BASIC
|
||||
logging.level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.BASIC
|
||||
val cookies = CookieManager().apply {
|
||||
setCookiePolicy(CookiePolicy.ACCEPT_ALL)
|
||||
}
|
||||
return OkHttpClient.Builder()
|
||||
.cache(cache)
|
||||
.cookieJar(JavaNetCookieJar(cookies))
|
||||
.addInterceptor(authInterceptor)
|
||||
.addInterceptor { chain ->
|
||||
val request = chain.request()
|
||||
if (request.method == "GET" && !hasNetwork(context)) {
|
||||
val offlineRequest = request.newBuilder()
|
||||
.cacheControl(CacheControl.Builder().onlyIfCached().maxStale(7, TimeUnit.DAYS).build())
|
||||
.build()
|
||||
chain.proceed(offlineRequest)
|
||||
} else {
|
||||
chain.proceed(request)
|
||||
}
|
||||
}
|
||||
.addNetworkInterceptor { chain ->
|
||||
val response = chain.proceed(chain.request())
|
||||
val request = response.request
|
||||
if (request.method == "GET" && request.header("Authorization").isNullOrBlank()) {
|
||||
response.newBuilder()
|
||||
.header("Cache-Control", "public, max-age=300")
|
||||
.build()
|
||||
} else {
|
||||
response
|
||||
}
|
||||
}
|
||||
.authenticator(accessTokenAuthenticator)
|
||||
.addInterceptor(logging)
|
||||
.build()
|
||||
@@ -44,8 +84,10 @@ object NetworkModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRetrofit(moshi: Moshi, client: OkHttpClient): Retrofit {
|
||||
val runtimeBase = BuildConfig.API_BASE_URL
|
||||
android.util.Log.i("NetworkModule", "Retrofit baseUrl runtime=$runtimeBase")
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(BuildConfig.API_BASE_URL)
|
||||
.baseUrl(runtimeBase)
|
||||
.client(client)
|
||||
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
||||
.build()
|
||||
@@ -54,4 +96,11 @@ object NetworkModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideApiService(retrofit: Retrofit): ApiService = retrofit.create(ApiService::class.java)
|
||||
|
||||
private fun hasNetwork(context: Context): Boolean {
|
||||
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false
|
||||
val network = manager.activeNetwork ?: return false
|
||||
val capabilities = manager.getNetworkCapabilities(network) ?: return false
|
||||
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package de.harheimertc.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class SecureOfflineCache @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val moshi: Moshi,
|
||||
) {
|
||||
private companion object {
|
||||
const val KEY_BIRTHDAYS = "birthdays"
|
||||
const val KEY_MEMBERS = "members"
|
||||
const val KEY_MEMBER_NEWS = "member_news"
|
||||
const val KEY_CMS_CONFIG = "cms_config"
|
||||
const val KEY_CMS_USERS = "cms_users"
|
||||
const val KEY_CONTACT_REQUESTS = "contact_requests"
|
||||
const val KEY_NEWSLETTERS = "newsletters"
|
||||
const val KEY_NEWSLETTER_GROUPS = "newsletter_groups"
|
||||
const val KEY_PASSWORD_RESET_DIAGNOSTICS = "password_reset_diagnostics"
|
||||
const val TIMESTAMP_SUFFIX = "_ts"
|
||||
}
|
||||
|
||||
private val preferences by lazy {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"harheimertc_offline_cache",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
}
|
||||
|
||||
fun putBirthdays(response: BirthdaysResponse) = put(KEY_BIRTHDAYS, response, BirthdaysResponse::class.java)
|
||||
fun getBirthdays(maxAgeMillis: Long? = null): BirthdaysResponse? = get(KEY_BIRTHDAYS, BirthdaysResponse::class.java, maxAgeMillis)
|
||||
|
||||
fun putMembers(response: MembersResponse) = put(KEY_MEMBERS, response, MembersResponse::class.java)
|
||||
fun getMembers(maxAgeMillis: Long? = null): MembersResponse? = get(KEY_MEMBERS, MembersResponse::class.java, maxAgeMillis)
|
||||
|
||||
fun putNews(response: NewsResponse) = put(KEY_MEMBER_NEWS, response, NewsResponse::class.java)
|
||||
fun getNews(maxAgeMillis: Long? = null): NewsResponse? = get(KEY_MEMBER_NEWS, NewsResponse::class.java, maxAgeMillis)
|
||||
|
||||
fun putConfig(response: ConfigResponse) = put(KEY_CMS_CONFIG, response, ConfigResponse::class.java)
|
||||
fun getConfig(maxAgeMillis: Long? = null): ConfigResponse? = get(KEY_CMS_CONFIG, ConfigResponse::class.java, maxAgeMillis)
|
||||
|
||||
fun putCmsUsers(response: CmsUsersResponse) = put(KEY_CMS_USERS, response, CmsUsersResponse::class.java)
|
||||
fun getCmsUsers(maxAgeMillis: Long? = null): CmsUsersResponse? = get(KEY_CMS_USERS, CmsUsersResponse::class.java, maxAgeMillis)
|
||||
|
||||
fun putContactRequests(response: List<ContactRequestDto>) {
|
||||
val type = Types.newParameterizedType(List::class.java, ContactRequestDto::class.java)
|
||||
val json = moshi.adapter<List<ContactRequestDto>>(type).toJson(response)
|
||||
preferences.edit()
|
||||
.putString(KEY_CONTACT_REQUESTS, json)
|
||||
.putLong(timestampKey(KEY_CONTACT_REQUESTS), System.currentTimeMillis())
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun getContactRequests(maxAgeMillis: Long? = null): List<ContactRequestDto>? {
|
||||
if (isExpired(KEY_CONTACT_REQUESTS, maxAgeMillis)) return null
|
||||
val json = preferences.getString(KEY_CONTACT_REQUESTS, null) ?: return null
|
||||
val type = Types.newParameterizedType(List::class.java, ContactRequestDto::class.java)
|
||||
return runCatching { moshi.adapter<List<ContactRequestDto>>(type).fromJson(json) }.getOrNull()
|
||||
}
|
||||
|
||||
fun putNewsletters(response: NewsletterListResponse) = put(KEY_NEWSLETTERS, response, NewsletterListResponse::class.java)
|
||||
fun getNewsletters(maxAgeMillis: Long? = null): NewsletterListResponse? =
|
||||
get(KEY_NEWSLETTERS, NewsletterListResponse::class.java, maxAgeMillis)
|
||||
|
||||
fun putNewsletterGroups(response: NewsletterGroupsResponse) = put(KEY_NEWSLETTER_GROUPS, response, NewsletterGroupsResponse::class.java)
|
||||
fun getNewsletterGroups(maxAgeMillis: Long? = null): NewsletterGroupsResponse? =
|
||||
get(KEY_NEWSLETTER_GROUPS, NewsletterGroupsResponse::class.java, maxAgeMillis)
|
||||
|
||||
fun putPasswordResetDiagnostics(response: PasswordResetDiagnosticsResponse) =
|
||||
put(KEY_PASSWORD_RESET_DIAGNOSTICS, response, PasswordResetDiagnosticsResponse::class.java)
|
||||
fun getPasswordResetDiagnostics(maxAgeMillis: Long? = null): PasswordResetDiagnosticsResponse? =
|
||||
get(KEY_PASSWORD_RESET_DIAGNOSTICS, PasswordResetDiagnosticsResponse::class.java, maxAgeMillis)
|
||||
|
||||
fun clearCmsProtectedCaches() {
|
||||
clear(
|
||||
KEY_CMS_CONFIG,
|
||||
KEY_CMS_USERS,
|
||||
KEY_CONTACT_REQUESTS,
|
||||
KEY_NEWSLETTERS,
|
||||
KEY_NEWSLETTER_GROUPS,
|
||||
KEY_PASSWORD_RESET_DIAGNOSTICS,
|
||||
KEY_MEMBER_NEWS,
|
||||
)
|
||||
}
|
||||
|
||||
fun clearCmsUsersCache() = clear(KEY_CMS_USERS)
|
||||
fun clearContactRequestsCache() = clear(KEY_CONTACT_REQUESTS)
|
||||
fun clearNewslettersCache() = clear(KEY_NEWSLETTERS)
|
||||
fun clearNewsletterGroupsCache() = clear(KEY_NEWSLETTER_GROUPS)
|
||||
fun clearPasswordResetDiagnosticsCache() = clear(KEY_PASSWORD_RESET_DIAGNOSTICS)
|
||||
fun clearCmsConfigCache() = clear(KEY_CMS_CONFIG)
|
||||
fun clearCmsNewsCache() = clear(KEY_MEMBER_NEWS)
|
||||
|
||||
private fun <T> put(key: String, value: T, type: Class<T>) {
|
||||
val json = moshi.adapter(type).toJson(value)
|
||||
preferences.edit()
|
||||
.putString(key, json)
|
||||
.putLong(timestampKey(key), System.currentTimeMillis())
|
||||
.apply()
|
||||
}
|
||||
|
||||
private fun <T> get(key: String, type: Class<T>, maxAgeMillis: Long? = null): T? {
|
||||
if (isExpired(key, maxAgeMillis)) return null
|
||||
val json = preferences.getString(key, null) ?: return null
|
||||
return runCatching { moshi.adapter(type).fromJson(json) }.getOrNull()
|
||||
}
|
||||
|
||||
private fun clear(vararg keys: String) {
|
||||
val editor = preferences.edit()
|
||||
keys.forEach { key ->
|
||||
editor.remove(key)
|
||||
editor.remove(timestampKey(key))
|
||||
}
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
private fun isExpired(key: String, maxAgeMillis: Long?): Boolean {
|
||||
if (maxAgeMillis == null) return false
|
||||
val savedAt = preferences.getLong(timestampKey(key), 0L)
|
||||
if (savedAt <= 0L) return true
|
||||
return (System.currentTimeMillis() - savedAt) > maxAgeMillis
|
||||
}
|
||||
|
||||
private fun timestampKey(key: String): String = key + TIMESTAMP_SUFFIX
|
||||
}
|
||||
@@ -6,4 +6,8 @@ interface AuthRepository {
|
||||
fun getSessionId(): String?
|
||||
fun setSession(accessToken: String?, refreshToken: String?, sessionId: String?)
|
||||
fun clearSession()
|
||||
// Device binding via Android Keystore (optional enhancement)
|
||||
fun ensureDeviceKey(): String?
|
||||
fun getDevicePublicKey(): String?
|
||||
fun signWithDeviceKey(data: ByteArray): ByteArray?
|
||||
}
|
||||
|
||||
@@ -4,11 +4,15 @@ import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.harheimertc.security.DeviceKeyManager
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AuthRepositoryImpl @Inject constructor(@param:ApplicationContext private val context: Context) : AuthRepository {
|
||||
class AuthRepositoryImpl @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val deviceKeyManager: DeviceKeyManager,
|
||||
) : AuthRepository {
|
||||
private val tokenKey = "auth_token"
|
||||
private val refreshTokenKey = "auth_refresh_token"
|
||||
private val sessionIdKey = "auth_session_id"
|
||||
@@ -46,4 +50,23 @@ class AuthRepositoryImpl @Inject constructor(@param:ApplicationContext private v
|
||||
.remove(sessionIdKey)
|
||||
.apply()
|
||||
}
|
||||
|
||||
// Keystore / device binding helpers
|
||||
override fun ensureDeviceKey(): String? = try {
|
||||
deviceKeyManager.ensureKeyPair()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
override fun getDevicePublicKey(): String? = try {
|
||||
deviceKeyManager.getPublicKeyBase64()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
override fun signWithDeviceKey(data: ByteArray): ByteArray? = try {
|
||||
deviceKeyManager.sign(data)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,42 +7,313 @@ import de.harheimertc.data.ContactRequestDto
|
||||
import de.harheimertc.data.NewsletterGroupsResponse
|
||||
import de.harheimertc.data.NewsletterListResponse
|
||||
import de.harheimertc.data.PasswordResetDiagnosticsResponse
|
||||
import de.harheimertc.data.SaveCsvRequest
|
||||
import de.harheimertc.data.SaveCsvResponse
|
||||
import de.harheimertc.data.SecureOfflineCache
|
||||
import javax.inject.Inject
|
||||
|
||||
class CmsRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun config(): Result<ConfigResponse> = runCatching {
|
||||
class CmsRepository @Inject constructor(
|
||||
private val api: ApiService,
|
||||
private val cache: SecureOfflineCache,
|
||||
) {
|
||||
private companion object {
|
||||
const val CMS_CACHE_MAX_AGE_MS = 24L * 60L * 60L * 1000L
|
||||
const val PASSWORD_RESET_DIAGNOSTICS_MAX_AGE_MS = 6L * 60L * 60L * 1000L
|
||||
}
|
||||
|
||||
suspend fun config(): Result<ConfigResponse> =
|
||||
fetchEncryptedFallback(
|
||||
load = {
|
||||
val response = api.config()
|
||||
if (!response.isSuccessful) error("Konfiguration konnte nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
},
|
||||
save = cache::putConfig,
|
||||
cached = { cache.getConfig(CMS_CACHE_MAX_AGE_MS) },
|
||||
fallbackMessage = "Konfiguration konnte nicht geladen werden.",
|
||||
)
|
||||
|
||||
suspend fun saveConfig(config: ConfigResponse): Result<ConfigResponse> = runCatching {
|
||||
val response = api.updateConfig(config)
|
||||
if (!response.isSuccessful) error("Konfiguration konnte nicht gespeichert werden.")
|
||||
val saved = response.body() ?: error("Leere Antwort vom Server.")
|
||||
cache.putConfig(saved)
|
||||
saved
|
||||
}
|
||||
|
||||
suspend fun users(): Result<CmsUsersResponse> = runCatching {
|
||||
suspend fun vereinsmeisterschaften(): Result<List<MeisterschaftResult>> = runCatching {
|
||||
val response = api.vereinsmeisterschaften()
|
||||
if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht geladen werden.")
|
||||
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
|
||||
if (values.size < 6) return@mapNotNull null
|
||||
MeisterschaftResult(
|
||||
year = values[0],
|
||||
category = values[1],
|
||||
rank = values[2],
|
||||
playerOne = values[3],
|
||||
playerTwo = values[4],
|
||||
note = values[5],
|
||||
imageOne = values.getOrElse(6) { "" },
|
||||
imageTwo = values.getOrElse(7) { "" },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveVereinsmeisterschaften(results: List<MeisterschaftResult>): Result<SaveCsvResponse> = runCatching {
|
||||
val csvHeader = "Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung,imageFilename1,imageFilename2"
|
||||
val csvRows = results.map { result ->
|
||||
listOf(
|
||||
result.year,
|
||||
result.category,
|
||||
result.rank,
|
||||
result.playerOne,
|
||||
result.playerTwo,
|
||||
result.note,
|
||||
result.imageOne,
|
||||
result.imageTwo,
|
||||
).joinToString(",") { value -> "\"${value.replace("\"", "\"\"")}\"" }
|
||||
}
|
||||
val response = api.saveCsv(
|
||||
SaveCsvRequest(
|
||||
filename = "vereinsmeisterschaften.csv",
|
||||
content = listOf(csvHeader).plus(csvRows).joinToString("\n"),
|
||||
),
|
||||
)
|
||||
if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht gespeichert werden.")
|
||||
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun users(): Result<CmsUsersResponse> =
|
||||
fetchEncryptedFallback(
|
||||
load = {
|
||||
val response = api.cmsUsers()
|
||||
if (!response.isSuccessful) error("Benutzer konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
},
|
||||
save = cache::putCmsUsers,
|
||||
cached = { cache.getCmsUsers(CMS_CACHE_MAX_AGE_MS) },
|
||||
fallbackMessage = "Benutzer konnten nicht geladen werden.",
|
||||
)
|
||||
|
||||
suspend fun updateUserRoles(id: String, roles: List<String>): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||
val req = de.harheimertc.data.ApiService.UpdateUserRolesRequest(id, roles)
|
||||
val response = api.updateUserRoles(req)
|
||||
if (!response.isSuccessful) error("Benutzerrollen konnten nicht aktualisiert werden.")
|
||||
cache.clearCmsUsersCache()
|
||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun contactRequests(): Result<List<ContactRequestDto>> = runCatching {
|
||||
suspend fun updateUserActive(id: String, active: Boolean): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||
val req = de.harheimertc.data.ApiService.UpdateUserActiveRequest(id, active)
|
||||
val response = api.updateUserActive(req)
|
||||
if (!response.isSuccessful) error("Benutzerstatus konnte nicht aktualisiert werden.")
|
||||
cache.clearCmsUsersCache()
|
||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun resendInvite(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||
val response = api.resendInvite(id)
|
||||
if (!response.isSuccessful) error("Einladung konnte nicht erneut gesendet werden.")
|
||||
cache.clearCmsUsersCache()
|
||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun contactRequests(): Result<List<ContactRequestDto>> =
|
||||
fetchEncryptedFallback(
|
||||
load = {
|
||||
val response = api.contactRequests()
|
||||
if (!response.isSuccessful) error("Kontaktanfragen konnten nicht geladen werden.")
|
||||
response.body() ?: emptyList()
|
||||
},
|
||||
save = cache::putContactRequests,
|
||||
cached = { cache.getContactRequests(CMS_CACHE_MAX_AGE_MS) },
|
||||
fallbackMessage = "Kontaktanfragen konnten nicht geladen werden.",
|
||||
)
|
||||
|
||||
suspend fun replyToContactRequest(id: String, message: String): Result<de.harheimertc.data.ContactResponse> = runCatching {
|
||||
val req = ApiService.ContactReplyRequest(message)
|
||||
val response = api.replyToContactRequest(id, req)
|
||||
if (!response.isSuccessful) error("Antwort konnte nicht gesendet werden.")
|
||||
cache.clearContactRequestsCache()
|
||||
response.body() ?: de.harheimertc.data.ContactResponse(ok = false)
|
||||
}
|
||||
|
||||
suspend fun newsletters(): Result<NewsletterListResponse> = runCatching {
|
||||
suspend fun toggleContactRequestStatus(id: String): Result<de.harheimertc.data.ContactResponse> = runCatching {
|
||||
val response = api.toggleContactRequestStatus(id)
|
||||
if (!response.isSuccessful) error("Status konnte nicht geändert werden.")
|
||||
cache.clearContactRequestsCache()
|
||||
response.body() ?: de.harheimertc.data.ContactResponse(ok = false)
|
||||
}
|
||||
|
||||
suspend fun newsletters(): Result<NewsletterListResponse> =
|
||||
fetchEncryptedFallback(
|
||||
load = {
|
||||
val response = api.newsletters()
|
||||
if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
}
|
||||
},
|
||||
save = cache::putNewsletters,
|
||||
cached = { cache.getNewsletters(CMS_CACHE_MAX_AGE_MS) },
|
||||
fallbackMessage = "Newsletter konnten nicht geladen werden.",
|
||||
)
|
||||
|
||||
suspend fun newsletterGroups(): Result<NewsletterGroupsResponse> = runCatching {
|
||||
suspend fun newsletterGroups(): Result<NewsletterGroupsResponse> =
|
||||
fetchEncryptedFallback(
|
||||
load = {
|
||||
val response = api.newsletterGroups()
|
||||
if (!response.isSuccessful) error("Newsletter-Gruppen konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
}
|
||||
},
|
||||
save = cache::putNewsletterGroups,
|
||||
cached = { cache.getNewsletterGroups(CMS_CACHE_MAX_AGE_MS) },
|
||||
fallbackMessage = "Newsletter-Gruppen konnten nicht geladen werden.",
|
||||
)
|
||||
|
||||
suspend fun passwordResetDiagnostics(): Result<PasswordResetDiagnosticsResponse> = runCatching {
|
||||
val response = api.passwordResetDiagnostics()
|
||||
suspend fun passwordResetDiagnostics(
|
||||
email: String? = null,
|
||||
failedOnly: Boolean = true,
|
||||
): Result<PasswordResetDiagnosticsResponse> {
|
||||
val normalizedEmail = email?.trim().orEmpty()
|
||||
val canUseSharedCache = normalizedEmail.isBlank() && failedOnly
|
||||
return fetchEncryptedFallback(
|
||||
load = {
|
||||
val response = api.passwordResetDiagnostics(
|
||||
email = normalizedEmail.takeIf { it.isNotBlank() },
|
||||
failedOnly = failedOnly,
|
||||
)
|
||||
if (!response.isSuccessful) error("Passwort-Reset-Diagnose konnte nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
},
|
||||
save = { response ->
|
||||
if (canUseSharedCache) cache.putPasswordResetDiagnostics(response)
|
||||
},
|
||||
cached = {
|
||||
if (canUseSharedCache) {
|
||||
cache.getPasswordResetDiagnostics(PASSWORD_RESET_DIAGNOSTICS_MAX_AGE_MS)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
fallbackMessage = "Passwort-Reset-Diagnose konnte nicht geladen werden.",
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun news(): Result<de.harheimertc.data.NewsResponse> =
|
||||
fetchEncryptedFallback(
|
||||
load = {
|
||||
val response = api.memberNews()
|
||||
if (!response.isSuccessful) error("News konnten nicht geladen werden.")
|
||||
response.body() ?: de.harheimertc.data.NewsResponse()
|
||||
},
|
||||
save = cache::putNews,
|
||||
cached = { cache.getNews(CMS_CACHE_MAX_AGE_MS) },
|
||||
fallbackMessage = "News konnten nicht geladen werden.",
|
||||
)
|
||||
|
||||
suspend fun saveNews(request: de.harheimertc.data.NewsSaveRequest): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||
val response = api.saveNews(request)
|
||||
if (!response.isSuccessful) error("News konnten nicht gespeichert werden.")
|
||||
cache.clearCmsNewsCache()
|
||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun createNewsletter(request: de.harheimertc.data.NewsletterCreateRequest): Result<de.harheimertc.data.NewsletterCreateResponse> = runCatching {
|
||||
val response = api.createNewsletter(request)
|
||||
if (!response.isSuccessful) error("Newsletter konnte nicht erstellt werden.")
|
||||
cache.clearNewslettersCache()
|
||||
response.body() ?: de.harheimertc.data.NewsletterCreateResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun updateNewsletter(id: String, patch: Map<String, Any?>): Result<de.harheimertc.data.NewsletterCreateResponse> = runCatching {
|
||||
val response = api.updateNewsletter(id, patch)
|
||||
if (!response.isSuccessful) error("Newsletter konnte nicht aktualisiert werden.")
|
||||
cache.clearNewslettersCache()
|
||||
response.body() ?: de.harheimertc.data.NewsletterCreateResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun sendNewsletter(id: String): Result<de.harheimertc.data.NewsletterSendResponse> = runCatching {
|
||||
val response = api.sendNewsletter(id)
|
||||
if (!response.isSuccessful) error("Newsletter konnte nicht versendet werden.")
|
||||
cache.clearNewslettersCache()
|
||||
response.body() ?: de.harheimertc.data.NewsletterSendResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun deleteNewsletter(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||
val response = api.deleteNewsletter(id)
|
||||
if (!response.isSuccessful) error("Newsletter konnte nicht gelöscht werden.")
|
||||
cache.clearNewslettersCache()
|
||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun createNewsletterGroup(payload: Map<String, @JvmSuppressWildcards Any?>): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||
// use generic POST via Retrofit? build request through create endpoint
|
||||
val response = api.createNewsletterGroup(payload)
|
||||
if (!response.isSuccessful) error("Gruppe konnte nicht erstellt werden.")
|
||||
cache.clearNewsletterGroupsCache()
|
||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun updateNewsletterGroup(id: String, patch: Map<String, Any?>): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||
val response = api.updateNewsletterGroup(id, patch)
|
||||
if (!response.isSuccessful) error("Gruppe konnte nicht aktualisiert werden.")
|
||||
cache.clearNewsletterGroupsCache()
|
||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun deleteNewsletterGroup(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||
val response = api.deleteNewsletterGroup(id)
|
||||
if (!response.isSuccessful) error("Gruppe konnte nicht gelöscht werden.")
|
||||
cache.clearNewsletterGroupsCache()
|
||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun deleteNews(id: Int): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||
val response = api.deleteNews(id)
|
||||
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
|
||||
cache.clearCmsNewsCache()
|
||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
private suspend fun <T> fetchEncryptedFallback(
|
||||
load: suspend () -> T,
|
||||
save: (T) -> Unit,
|
||||
cached: () -> T?,
|
||||
fallbackMessage: String,
|
||||
): Result<T> = runCatching {
|
||||
runCatching { load() }
|
||||
.onSuccess(save)
|
||||
.getOrElse { original ->
|
||||
cached() ?: throw IllegalStateException(fallbackMessage, original)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseCsv(csv: String): List<List<String>> =
|
||||
csv.lineSequence().filter(String::isNotBlank).map(::parseCsvLine).toList()
|
||||
|
||||
private fun parseCsvLine(line: String): List<String> {
|
||||
val values = mutableListOf<String>()
|
||||
val value = StringBuilder()
|
||||
var quoted = false
|
||||
var index = 0
|
||||
while (index < line.length) {
|
||||
when (val char = line[index]) {
|
||||
'"' -> {
|
||||
if (quoted && index + 1 < line.length && line[index + 1] == '"') {
|
||||
value.append('"')
|
||||
index++
|
||||
} else {
|
||||
quoted = !quoted
|
||||
}
|
||||
}
|
||||
',' -> if (quoted) value.append(char) else {
|
||||
values += value.toString().trim()
|
||||
value.clear()
|
||||
}
|
||||
else -> value.append(char)
|
||||
}
|
||||
index++
|
||||
}
|
||||
values += value.toString().trim()
|
||||
return values
|
||||
}
|
||||
|
||||
@@ -1,22 +1,45 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.GalleryImageDto
|
||||
import de.harheimertc.data.GalleryPaginationDto
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class GalleryRepository @Inject constructor(private val api: ApiService) {
|
||||
class GalleryRepository @Inject constructor(
|
||||
private val api: ApiService,
|
||||
@param:ApplicationContext private val context: Context,
|
||||
) {
|
||||
suspend fun hasPublicImages(): Result<Boolean> = runCatching {
|
||||
val response = api.publicGalleryImages()
|
||||
val response = api.galerieList(page = 1, perPage = 1)
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
response.body().orEmpty().isNotEmpty()
|
||||
response.body()?.images.orEmpty().isNotEmpty()
|
||||
}
|
||||
|
||||
suspend fun fetchImages(): Result<List<String>> {
|
||||
suspend fun fetchImages(page: Int = 1, perPage: Int = 60): Result<GalleryPage> {
|
||||
return try {
|
||||
val resp = api.galerieList()
|
||||
val resp = api.galerieList(page = page, perPage = perPage)
|
||||
if (resp.isSuccessful) {
|
||||
Result.success(resp.body() ?: emptyList())
|
||||
val body = resp.body()
|
||||
Result.success(
|
||||
GalleryPage(
|
||||
images = body?.images.orEmpty().map { it.toGalleryImage() },
|
||||
pagination = body?.pagination ?: GalleryPaginationDto(),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
Result.failure(Exception("HTTP ${resp.code()}"))
|
||||
}
|
||||
@@ -24,4 +47,78 @@ class GalleryRepository @Inject constructor(private val api: ApiService) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun uploadImage(uri: Uri, title: String, description: String, isPublic: Boolean): Result<Unit> = runCatching {
|
||||
val titleValue = title.trim()
|
||||
require(titleValue.isNotBlank()) { "Bitte einen Titel eintragen." }
|
||||
|
||||
val uploadFile = prepareCompressedUploadFile(uri)
|
||||
val mediaType = "image/jpeg".toMediaType()
|
||||
val imageBody = uploadFile.asRequestBody(mediaType)
|
||||
val imagePart = MultipartBody.Part.createFormData("image", uploadFile.name, imageBody)
|
||||
val textType = "text/plain".toMediaType()
|
||||
|
||||
val response = api.uploadGalleryImage(
|
||||
image = imagePart,
|
||||
title = titleValue.toRequestBody(textType),
|
||||
description = description.trim().toRequestBody(textType),
|
||||
isPublic = isPublic.toString().toRequestBody(textType),
|
||||
)
|
||||
uploadFile.delete()
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
val body = response.body()
|
||||
if (body?.success == false) error(body.message ?: "Fehler beim Hochladen des Bildes")
|
||||
}
|
||||
|
||||
private fun GalleryImageDto.toGalleryImage(): GalleryImage {
|
||||
val base = BuildConfig.API_BASE_URL.trimEnd('/')
|
||||
return GalleryImage(
|
||||
id = id,
|
||||
title = title,
|
||||
description = description,
|
||||
isPublic = isPublic,
|
||||
uploadedAt = uploadedAt,
|
||||
previewUrl = "$base/api/media/galerie/$id?preview=true",
|
||||
imageUrl = "$base/api/media/galerie/$id",
|
||||
)
|
||||
}
|
||||
|
||||
private fun prepareCompressedUploadFile(uri: Uri): File {
|
||||
val inputBytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
?: error("Bilddatei konnte nicht gelesen werden.")
|
||||
val original = BitmapFactory.decodeByteArray(inputBytes, 0, inputBytes.size)
|
||||
?: error("Bilddatei konnte nicht verarbeitet werden.")
|
||||
val scaled = original.scaleInside(maxSize = 2000)
|
||||
val file = File(context.cacheDir, "gallery_upload_${System.currentTimeMillis()}.jpg")
|
||||
FileOutputStream(file).use { out ->
|
||||
scaled.compress(Bitmap.CompressFormat.JPEG, 85, out)
|
||||
}
|
||||
if (scaled !== original) scaled.recycle()
|
||||
original.recycle()
|
||||
return file
|
||||
}
|
||||
|
||||
private fun Bitmap.scaleInside(maxSize: Int): Bitmap {
|
||||
val largestSide = maxOf(width, height)
|
||||
if (largestSide <= maxSize) return this
|
||||
val scale = maxSize.toFloat() / largestSide.toFloat()
|
||||
val nextWidth = (width * scale).toInt().coerceAtLeast(1)
|
||||
val nextHeight = (height * scale).toInt().coerceAtLeast(1)
|
||||
return Bitmap.createScaledBitmap(this, nextWidth, nextHeight, true)
|
||||
}
|
||||
}
|
||||
|
||||
data class GalleryImage(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val isPublic: Boolean,
|
||||
val uploadedAt: String?,
|
||||
val previewUrl: String,
|
||||
val imageUrl: String,
|
||||
)
|
||||
|
||||
data class GalleryPage(
|
||||
val images: List<GalleryImage>,
|
||||
val pagination: GalleryPaginationDto,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.harheimertc.data.HomepageSectionDto
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class HomeLayoutPreferences @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val moshi: Moshi,
|
||||
) {
|
||||
private val sectionListType = Types.newParameterizedType(List::class.java, HomepageSectionDto::class.java)
|
||||
private val sectionListAdapter = moshi.adapter<List<HomepageSectionDto>>(sectionListType)
|
||||
|
||||
private val preferences by lazy {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"harheimertc_home_layout",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
}
|
||||
|
||||
fun getSections(): List<HomepageSectionDto>? {
|
||||
val json = preferences.getString(HOME_SECTIONS_KEY, null) ?: return null
|
||||
return runCatching { sectionListAdapter.fromJson(json) }.getOrNull()
|
||||
}
|
||||
|
||||
fun setSections(sections: List<HomepageSectionDto>) {
|
||||
val json = sectionListAdapter.toJson(sections)
|
||||
preferences.edit().putString(HOME_SECTIONS_KEY, json).apply()
|
||||
}
|
||||
|
||||
fun clearSections() {
|
||||
preferences.edit().remove(HOME_SECTIONS_KEY).apply()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val HOME_SECTIONS_KEY = "home_sections"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.HomepageSectionDto
|
||||
import de.harheimertc.data.NewsDto
|
||||
import de.harheimertc.data.SeasonDto
|
||||
import de.harheimertc.data.SpielDto
|
||||
import de.harheimertc.data.SpielplanResponse
|
||||
import de.harheimertc.data.TerminDto
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -10,15 +13,37 @@ import javax.inject.Singleton
|
||||
data class HomeData(
|
||||
val termine: List<TerminDto>,
|
||||
val spiele: List<SpielDto>,
|
||||
val spielplanSeasons: List<SeasonDto>,
|
||||
val selectedSpielplanSeason: String?,
|
||||
val news: List<NewsDto>,
|
||||
val homepageSections: List<HomepageSectionDto>,
|
||||
)
|
||||
|
||||
@Singleton
|
||||
class HomeRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchHomeData(): Result<HomeData> = runCatching {
|
||||
val termine = api.termine().body()?.termine.orEmpty()
|
||||
val spiele = api.spielplan().body()?.data.orEmpty()
|
||||
val spielplanResponse = api.spielplan().body()
|
||||
val spiele = spielplanResponse?.data.orEmpty()
|
||||
val news = api.publicNews().body()?.news.orEmpty()
|
||||
HomeData(termine, spiele, news)
|
||||
val homepageSections = runCatching {
|
||||
val configResponse = api.config()
|
||||
if (!configResponse.isSuccessful) return@runCatching emptyList()
|
||||
configResponse.body()?.homepage?.sections.orEmpty()
|
||||
}.getOrDefault(emptyList())
|
||||
HomeData(
|
||||
termine = termine,
|
||||
spiele = spiele,
|
||||
spielplanSeasons = spielplanResponse?.seasons.orEmpty(),
|
||||
selectedSpielplanSeason = spielplanResponse?.season,
|
||||
news = news,
|
||||
homepageSections = homepageSections,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun fetchSpielplanForSeason(season: String): Result<SpielplanResponse> = runCatching {
|
||||
val response = api.spielplan(season)
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,34 +5,104 @@ import de.harheimertc.data.BirthdaysResponse
|
||||
import de.harheimertc.data.MembersResponse
|
||||
import de.harheimertc.data.NewsResponse
|
||||
import de.harheimertc.data.NewsSaveRequest
|
||||
import de.harheimertc.data.SecureOfflineCache
|
||||
import javax.inject.Inject
|
||||
|
||||
class MemberAreaRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun birthdays(): Result<BirthdaysResponse> = runCatching {
|
||||
class MemberAreaRepository @Inject constructor(
|
||||
private val api: ApiService,
|
||||
private val cache: SecureOfflineCache,
|
||||
) {
|
||||
suspend fun birthdays(): Result<BirthdaysResponse> =
|
||||
fetchEncryptedFallback(
|
||||
load = {
|
||||
val response = api.birthdays()
|
||||
if (!response.isSuccessful) error("Geburtstage konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
}
|
||||
},
|
||||
save = cache::putBirthdays,
|
||||
cached = cache::getBirthdays,
|
||||
fallbackMessage = "Geburtstage konnten nicht geladen werden.",
|
||||
)
|
||||
|
||||
suspend fun members(): Result<MembersResponse> = runCatching {
|
||||
suspend fun members(): Result<MembersResponse> =
|
||||
fetchEncryptedFallback(
|
||||
load = {
|
||||
val response = api.members()
|
||||
if (!response.isSuccessful) error("Mitglieder konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
}
|
||||
},
|
||||
save = cache::putMembers,
|
||||
cached = cache::getMembers,
|
||||
fallbackMessage = "Mitglieder konnten nicht geladen werden.",
|
||||
)
|
||||
|
||||
suspend fun news(): Result<NewsResponse> = runCatching {
|
||||
suspend fun news(): Result<NewsResponse> =
|
||||
fetchEncryptedFallback(
|
||||
load = {
|
||||
val response = api.memberNews()
|
||||
if (!response.isSuccessful) error("News konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
if (!response.isSuccessful) {
|
||||
try {
|
||||
val body = response.errorBody()?.string()
|
||||
android.util.Log.w("MemberAreaRepository", "memberNews failed: code=${response.code()} body=${body?.take(500)}")
|
||||
} catch (e: Exception) {
|
||||
// ignore
|
||||
}
|
||||
error("News konnten nicht geladen werden.")
|
||||
}
|
||||
response.body() ?: run {
|
||||
android.util.Log.w("MemberAreaRepository", "memberNews: successful but empty body (null)")
|
||||
NewsResponse(success = false, news = emptyList())
|
||||
}
|
||||
},
|
||||
save = cache::putNews,
|
||||
cached = cache::getNews,
|
||||
fallbackMessage = "News konnten nicht geladen werden.",
|
||||
)
|
||||
|
||||
suspend fun saveNews(request: NewsSaveRequest): Result<Unit> = runCatching {
|
||||
val response = api.saveNews(request)
|
||||
if (!response.isSuccessful) error("News konnten nicht gespeichert werden.")
|
||||
}
|
||||
|
||||
suspend fun saveMember(request: de.harheimertc.data.ApiService.MemberSaveRequest): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||
val response = api.saveMember(request)
|
||||
if (!response.isSuccessful) error("Mitglied konnte nicht gespeichert werden.")
|
||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun deleteMember(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||
val response = api.deleteMember(mapOf("id" to id))
|
||||
if (!response.isSuccessful) error("Mitglied konnte nicht gelöscht werden.")
|
||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun bulkImport(members: List<Map<String, String>>): Result<de.harheimertc.data.ApiService.BulkImportResponse> = runCatching {
|
||||
val response = api.bulkImportMembers(de.harheimertc.data.ApiService.BulkImportRequest(members))
|
||||
if (!response.isSuccessful) error("Bulk-Import fehlgeschlagen")
|
||||
response.body() ?: de.harheimertc.data.ApiService.BulkImportResponse(success = false)
|
||||
}
|
||||
|
||||
suspend fun toggleMannschaftsspieler(memberId: String): Result<Map<String, Any>> = runCatching {
|
||||
val response = api.toggleMannschaftsspieler(mapOf("memberId" to memberId))
|
||||
if (!response.isSuccessful) error("Status konnte nicht umgeschaltet werden.")
|
||||
response.body() ?: emptyMap()
|
||||
}
|
||||
|
||||
suspend fun deleteNews(id: Int): Result<Unit> = runCatching {
|
||||
val response = api.deleteNews(id)
|
||||
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
|
||||
}
|
||||
|
||||
private suspend fun <T> fetchEncryptedFallback(
|
||||
load: suspend () -> T,
|
||||
save: (T) -> Unit,
|
||||
cached: () -> T?,
|
||||
fallbackMessage: String,
|
||||
): Result<T> = runCatching {
|
||||
runCatching { load() }
|
||||
.onSuccess(save)
|
||||
.getOrElse { original ->
|
||||
cached() ?: throw IllegalStateException(fallbackMessage, original)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package de.harheimertc.security
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.KeyStore
|
||||
import java.security.Signature
|
||||
import java.security.spec.ECGenParameterSpec
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class DeviceKeyManager @Inject constructor(@param:ApplicationContext private val context: Context) {
|
||||
private val alias = "harheimertc_device_key"
|
||||
private val keyStore: KeyStore by lazy {
|
||||
KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||
}
|
||||
|
||||
fun ensureKeyPair(): String? {
|
||||
try {
|
||||
if (!keyStore.containsAlias(alias)) {
|
||||
val kpg = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore")
|
||||
val specBuilder = KeyGenParameterSpec.Builder(
|
||||
alias,
|
||||
KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY
|
||||
)
|
||||
.setDigests(KeyProperties.DIGEST_SHA256)
|
||||
.setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
|
||||
.setUserAuthenticationRequired(false)
|
||||
|
||||
// For older APIs, KeyGenParameterSpec.Builder methods exist from API 23+
|
||||
kpg.initialize(specBuilder.build())
|
||||
kpg.generateKeyPair()
|
||||
}
|
||||
val pub = keyStore.getCertificate(alias).publicKey.encoded
|
||||
return Base64.encodeToString(pub, Base64.NO_WRAP)
|
||||
} catch (e: Exception) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
fun getPublicKeyBase64(): String? {
|
||||
return try {
|
||||
if (!keyStore.containsAlias(alias)) return null
|
||||
val pub = keyStore.getCertificate(alias).publicKey.encoded
|
||||
Base64.encodeToString(pub, Base64.NO_WRAP)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun sign(data: ByteArray): ByteArray? {
|
||||
return try {
|
||||
val privateKey = keyStore.getKey(alias, null) as? java.security.PrivateKey ?: return null
|
||||
val sig = Signature.getInstance("SHA256withECDSA")
|
||||
sig.initSign(privateKey)
|
||||
sig.update(data)
|
||||
sig.sign()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteKey() {
|
||||
try {
|
||||
if (keyStore.containsAlias(alias)) keyStore.deleteEntry(alias)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,13 +61,17 @@ fun AppNavigationHeader(
|
||||
if (webTabletNavigation) {
|
||||
WebTabletNavigation(selectedRoute, onNavigate, navigationState)
|
||||
} else {
|
||||
CompactNavigation(selectedRoute, onNavigate)
|
||||
CompactNavigation(selectedRoute, onNavigate, navigationState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompactNavigation(selectedRoute: String?, onNavigate: (String) -> Unit) {
|
||||
private fun CompactNavigation(
|
||||
selectedRoute: String?,
|
||||
onNavigate: (String) -> Unit,
|
||||
navigationState: NavigationUiState = NavigationUiState(),
|
||||
) {
|
||||
BrandRow(onLogin = { onNavigate(Destinations.Login.route) })
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
||||
@@ -75,6 +79,9 @@ private fun CompactNavigation(selectedRoute: String?, onNavigate: (String) -> Un
|
||||
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))
|
||||
if (navigationState.isAdmin || navigationState.canAccessNewsletter || navigationState.canAccessContactRequests) {
|
||||
CompactLink("CMS", Destinations.Cms.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +92,9 @@ private fun WebTabletNavigation(
|
||||
navigationState: NavigationUiState,
|
||||
) {
|
||||
val section = menuSection(selectedRoute)
|
||||
var cmsExpanded = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
|
||||
// Helper that closes the CMS submenu when navigating away
|
||||
val navigateAndClose: (String) -> Unit = { route -> cmsExpanded.value = false; onNavigate(route) }
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Brand()
|
||||
Spacer(Modifier.width(16.dp))
|
||||
@@ -93,12 +103,12 @@ private fun WebTabletNavigation(
|
||||
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) })
|
||||
MainLink("Start", selectedRoute == Destinations.Home.route, onClick = { navigateAndClose(Destinations.Home.route) })
|
||||
MainLink("Verein", section == MenuSection.VEREIN, onClick = { navigateAndClose(Destinations.VereinAbout.route) })
|
||||
MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = { navigateAndClose(Destinations.Mannschaften.route) })
|
||||
MainLink("Training", section == MenuSection.TRAINING, onClick = { navigateAndClose(Destinations.Training.route) })
|
||||
MainLink("Mitgliedschaft", selectedRoute == Destinations.Membership.route, onClick = { navigateAndClose(Destinations.Membership.route) })
|
||||
MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = { navigateAndClose(Destinations.Termine.route) })
|
||||
if (navigationState.showGallery) {
|
||||
MainLink("Galerie", selectedRoute == Destinations.Gallery.route, onClick = { onNavigate(Destinations.Gallery.route) })
|
||||
}
|
||||
@@ -106,11 +116,18 @@ private fun WebTabletNavigation(
|
||||
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) }
|
||||
MainLink("Kontakt", selectedRoute == Destinations.Contact.route, primary = true, onClick = { navigateAndClose(Destinations.Contact.route) })
|
||||
TextButton(onClick = { navigateAndClose(Destinations.Login.route) }) { Text("Login", color = Color.White) }
|
||||
}
|
||||
}
|
||||
val subItems = submenu(section, navigationState)
|
||||
// determine CMS parent index and children
|
||||
val cmsIndex = subItems.indexOfFirst { it.label == "CMS" }
|
||||
val cmsChildren = if (cmsIndex >= 0) subItems.subList(cmsIndex + 1, subItems.size) else emptyList<MenuTarget>()
|
||||
if (cmsChildren.any { it.route == selectedRoute }) {
|
||||
cmsExpanded.value = true
|
||||
}
|
||||
// First row: render all subitems but do NOT render CMS children here
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -118,8 +135,33 @@ private fun WebTabletNavigation(
|
||||
.padding(top = 3.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
subItems.forEach { item ->
|
||||
SubLink(item.label, item.route == selectedRoute) { onNavigate(item.route) }
|
||||
subItems.forEachIndexed { idx, item ->
|
||||
if (idx == cmsIndex) {
|
||||
// CMS parent toggle
|
||||
SubLink(item.label, item.route == selectedRoute) {
|
||||
cmsExpanded.value = !cmsExpanded.value
|
||||
}
|
||||
} else if (idx > cmsIndex && cmsIndex >= 0) {
|
||||
// skip cms children here; they'll be rendered in the second row when expanded
|
||||
} else {
|
||||
// normal item before CMS: close cms submenu on navigate
|
||||
SubLink(item.label, item.route == selectedRoute) { navigateAndClose(item.route) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second row: when CMS expanded, render its children beneath
|
||||
if (cmsExpanded.value && cmsChildren.isNotEmpty()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(top = 6.dp, bottom = 3.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
cmsChildren.forEach { child ->
|
||||
SubLink(child.label, child.route == selectedRoute) { onNavigate(child.route) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -301,6 +343,8 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuT
|
||||
add(MenuTarget("Mein Profil", Destinations.Profile.route))
|
||||
add(MenuTarget("API-Dokumentation", Destinations.MemberApi.route))
|
||||
if (state.isAdmin || state.canAccessContactRequests || state.canAccessNewsletter) add(MenuTarget("CMS", Destinations.Cms.route))
|
||||
// CMS child items (will be rendered when CMS parent is expanded)
|
||||
if (state.isAdmin || state.canAccessContactRequests || state.canAccessNewsletter) add(MenuTarget("Übersicht", Destinations.Cms.route))
|
||||
if (state.isAdmin) add(MenuTarget("Startseite", Destinations.CmsStartseite.route))
|
||||
if (state.isAdmin) add(MenuTarget("Inhalte", Destinations.CmsInhalte.route))
|
||||
if (state.isAdmin) add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route))
|
||||
|
||||
@@ -17,7 +17,6 @@ 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
|
||||
@@ -25,21 +24,35 @@ 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.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import coil.request.ImageRequest
|
||||
import de.harheimertc.R
|
||||
import de.harheimertc.repositories.GalleryImage
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ImageGrid(images: List<String>, modifier: Modifier = Modifier) {
|
||||
val selected = remember { mutableStateOf<String?>(null) }
|
||||
fun ImageGrid(images: List<GalleryImage>, modifier: Modifier = Modifier) {
|
||||
val selected = remember { mutableStateOf<GalleryImage?>(null) }
|
||||
val context = LocalContext.current
|
||||
|
||||
LazyVerticalGrid(columns = GridCells.Fixed(3), modifier = modifier.padding(8.dp)) {
|
||||
items(images) { img ->
|
||||
val description = stringResource(R.string.gallery_image_description, img.title.ifBlank { img.id })
|
||||
Card(modifier = Modifier.padding(4.dp), elevation = CardDefaults.cardElevation(4.dp)) {
|
||||
AsyncImage(
|
||||
model = img,
|
||||
contentDescription = "Gallery image",
|
||||
model = ImageRequest.Builder(context)
|
||||
.data(img.previewUrl)
|
||||
.size(300, 300)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = description,
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
.semantics { contentDescription = description }
|
||||
.clickable { selected.value = img },
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
@@ -51,9 +64,20 @@ fun ImageGrid(images: List<String>, modifier: Modifier = Modifier) {
|
||||
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")
|
||||
AsyncImage(
|
||||
model = selected.value?.imageUrl,
|
||||
contentDescription = selected.value?.title ?: stringResource(R.string.gallery_title),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
Button(
|
||||
onClick = { selected.value = null },
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.semantics { contentDescription = context.getString(R.string.gallery_close_image) },
|
||||
colors = ButtonDefaults.buttonColors(),
|
||||
) {
|
||||
Text(stringResource(R.string.gallery_upload_hide))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
package de.harheimertc.ui.components
|
||||
|
||||
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.rememberScrollState
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
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.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun NativeRichTextEditor(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var fieldValue by remember(value) { mutableStateOf(TextFieldValue(value, TextRange(value.length))) }
|
||||
var linkDialog by remember { mutableStateOf(false) }
|
||||
var imageDialog by remember { mutableStateOf(false) }
|
||||
|
||||
fun commit(next: TextFieldValue) {
|
||||
fieldValue = next
|
||||
onValueChange(normalizeEmptyHtml(next.text))
|
||||
}
|
||||
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(label, style = MaterialTheme.typography.titleLarge)
|
||||
ToolbarRow(
|
||||
onAction = { action ->
|
||||
when (action) {
|
||||
RichTextAction.Link -> linkDialog = true
|
||||
RichTextAction.Image -> imageDialog = true
|
||||
RichTextAction.Clean -> commit(fieldValue.copy(text = stripHtml(fieldValue.text), selection = TextRange(stripHtml(fieldValue.text).length)))
|
||||
else -> commit(applyAction(fieldValue, action))
|
||||
}
|
||||
},
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = fieldValue,
|
||||
onValueChange = { commit(it) },
|
||||
label = { Text("HTML-Inhalt") },
|
||||
minLines = 12,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Surface(color = Color(0xFFF4F4F5), modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Vorschau", style = MaterialTheme.typography.labelLarge)
|
||||
if (fieldValue.text.isBlank()) Text("Noch kein Inhalt.", color = Color(0xFF71717A)) else RichText(fieldValue.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (linkDialog) {
|
||||
UrlDialog(
|
||||
title = "Link einfügen",
|
||||
placeholder = "https://...",
|
||||
onDismiss = { linkDialog = false },
|
||||
onConfirm = { url ->
|
||||
commit(applyLink(fieldValue, url))
|
||||
linkDialog = false
|
||||
},
|
||||
)
|
||||
}
|
||||
if (imageDialog) {
|
||||
UrlDialog(
|
||||
title = "Bild einfügen",
|
||||
placeholder = "https://.../bild.jpg",
|
||||
onDismiss = { imageDialog = false },
|
||||
onConfirm = { url ->
|
||||
commit(insertHtml(fieldValue, """<p><img src="${escapeHtml(url)}"></p>"""))
|
||||
imageDialog = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToolbarRow(onAction: (RichTextAction) -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
RichTextAction.entries.forEach { action ->
|
||||
AssistChip(onClick = { onAction(action) }, label = { Text(action.label) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UrlDialog(title: String, placeholder: String, onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
|
||||
var value by remember { mutableStateOf("") }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
OutlinedTextField(value = value, onValueChange = { value = it }, label = { Text(placeholder) }, singleLine = true)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = { onConfirm(value.trim()) }, enabled = value.isNotBlank()) { Text("Einfügen") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Abbrechen") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private enum class RichTextAction(val label: String) {
|
||||
H1("H1"),
|
||||
H2("H2"),
|
||||
H3("H3"),
|
||||
Bold("B"),
|
||||
Italic("I"),
|
||||
Underline("U"),
|
||||
Strike("S"),
|
||||
Color("Farbe"),
|
||||
Background("Marker"),
|
||||
OrderedList("1."),
|
||||
BulletList("•"),
|
||||
AlignCenter("Zentriert"),
|
||||
Link("Link"),
|
||||
Image("Bild"),
|
||||
Blockquote("Zitat"),
|
||||
CodeBlock("Code"),
|
||||
Clean("Clean"),
|
||||
}
|
||||
|
||||
private fun applyAction(value: TextFieldValue, action: RichTextAction): TextFieldValue = when (action) {
|
||||
RichTextAction.H1 -> wrapBlock(value, "h1")
|
||||
RichTextAction.H2 -> wrapBlock(value, "h2")
|
||||
RichTextAction.H3 -> wrapBlock(value, "h3")
|
||||
RichTextAction.Bold -> wrapInline(value, "strong")
|
||||
RichTextAction.Italic -> wrapInline(value, "em")
|
||||
RichTextAction.Underline -> wrapInline(value, "u")
|
||||
RichTextAction.Strike -> wrapInline(value, "s")
|
||||
RichTextAction.Color -> wrapInline(value, "span", " style=\"color: #dc2626;\"")
|
||||
RichTextAction.Background -> wrapInline(value, "span", " style=\"background-color: #fef3c7;\"")
|
||||
RichTextAction.OrderedList -> wrapLines(value, "ol")
|
||||
RichTextAction.BulletList -> wrapLines(value, "ul")
|
||||
RichTextAction.AlignCenter -> wrapSelection(value, """<p class="ql-align-center">""", "</p>")
|
||||
RichTextAction.Blockquote -> wrapBlock(value, "blockquote")
|
||||
RichTextAction.CodeBlock -> wrapSelection(value, """<pre class="ql-syntax" spellcheck="false">""", "</pre>")
|
||||
RichTextAction.Link,
|
||||
RichTextAction.Image,
|
||||
RichTextAction.Clean -> value
|
||||
}
|
||||
|
||||
private fun applyLink(value: TextFieldValue, url: String): TextFieldValue {
|
||||
val safeUrl = escapeHtml(url)
|
||||
val label = selectedText(value).ifBlank { safeUrl }
|
||||
return replaceSelection(value, """<a href="$safeUrl">$label</a>""")
|
||||
}
|
||||
|
||||
private fun wrapInline(value: TextFieldValue, tag: String, attrs: String = ""): TextFieldValue =
|
||||
wrapSelection(value, "<$tag$attrs>", "</$tag>")
|
||||
|
||||
private fun wrapBlock(value: TextFieldValue, tag: String): TextFieldValue =
|
||||
wrapSelection(value, "<$tag>", "</$tag>")
|
||||
|
||||
private fun wrapLines(value: TextFieldValue, listTag: String): TextFieldValue {
|
||||
val lines = selectedText(value).ifBlank { "Listeneintrag" }
|
||||
.lines()
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString("") { "<li>${escapeHtml(it)}</li>" }
|
||||
return replaceSelection(value, "<$listTag>$lines</$listTag>")
|
||||
}
|
||||
|
||||
private fun wrapSelection(value: TextFieldValue, prefix: String, suffix: String): TextFieldValue =
|
||||
replaceSelection(value, prefix + selectedText(value).ifBlank { "Text" } + suffix)
|
||||
|
||||
private fun insertHtml(value: TextFieldValue, html: String): TextFieldValue = replaceSelection(value, html)
|
||||
|
||||
private fun replaceSelection(value: TextFieldValue, replacement: String): TextFieldValue {
|
||||
val start = value.selection.min.coerceIn(0, value.text.length)
|
||||
val end = value.selection.max.coerceIn(0, value.text.length)
|
||||
val next = value.text.replaceRange(start, end, replacement)
|
||||
val cursor = start + replacement.length
|
||||
return TextFieldValue(next, TextRange(cursor))
|
||||
}
|
||||
|
||||
private fun selectedText(value: TextFieldValue): String {
|
||||
val start = value.selection.min.coerceIn(0, value.text.length)
|
||||
val end = value.selection.max.coerceIn(0, value.text.length)
|
||||
return value.text.substring(start, end)
|
||||
}
|
||||
|
||||
// HTML helper functions moved to RichTextUtils.kt for reuse and testing
|
||||
@@ -0,0 +1,15 @@
|
||||
package de.harheimertc.ui.components
|
||||
|
||||
fun normalizeEmptyHtml(value: String): String =
|
||||
if (stripHtml(value).isBlank() && !value.contains("<img", ignoreCase = true)) "" else value
|
||||
|
||||
fun stripHtml(value: String): String = value
|
||||
.replace(Regex("<[^>]+>"), "")
|
||||
.replace(" ", " ")
|
||||
.trim()
|
||||
|
||||
fun escapeHtml(value: String): String = value
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
@@ -20,8 +20,9 @@ import de.harheimertc.ui.components.AppNavigationHeader
|
||||
fun NavGraph(
|
||||
navController: NavHostController,
|
||||
startDestination: String = Destinations.Home.route,
|
||||
navigationViewModel: NavigationViewModel = hiltViewModel(),
|
||||
navigationViewModelParam: NavigationViewModel? = null,
|
||||
) {
|
||||
val navigationViewModel: NavigationViewModel = navigationViewModelParam ?: hiltViewModel()
|
||||
val backStackEntry = navController.currentBackStackEntryAsState().value
|
||||
val route = backStackEntry?.destination?.route
|
||||
val currentRoute = if (route == Destinations.MannschaftDetail.route) {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
// Placeholder: functionality moved to CmsScreens.kt (CmsUserListPage / UserCard)
|
||||
@@ -0,0 +1,306 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.ui.draw.clip
|
||||
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.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import de.harheimertc.data.NewsDto
|
||||
import de.harheimertc.data.NewsSaveRequest
|
||||
import de.harheimertc.ui.components.FormMessages
|
||||
import de.harheimertc.ui.components.NativeRichTextEditor
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
var selection by remember { mutableStateOf(setOf<Int>()) }
|
||||
val loginVm: de.harheimertc.ui.screens.login.LoginViewModel = hiltViewModel()
|
||||
val loginState by loginVm.state.collectAsState()
|
||||
val canWrite = loginState.roles.any { it == "admin" || it == "vorstand" }
|
||||
val context = LocalContext.current
|
||||
var showSuccessDialog by remember { mutableStateOf(false) }
|
||||
|
||||
androidx.compose.runtime.LaunchedEffect(state.message) {
|
||||
if (!state.message.isNullOrBlank()) showSuccessDialog = true
|
||||
}
|
||||
|
||||
// Local dialog state for create/edit + delete confirmation (hoisted)
|
||||
var dialogOpen by remember { mutableStateOf(false) }
|
||||
var deletingIds by remember { mutableStateOf<List<Int>?>(null) }
|
||||
var editing by remember { mutableStateOf<NewsDto?>(null) }
|
||||
var title by remember { mutableStateOf("") }
|
||||
var content by remember { mutableStateOf("") }
|
||||
var isPublic by remember { mutableStateOf(false) }
|
||||
var isHidden by remember { mutableStateOf(false) }
|
||||
var expiresAt by remember { mutableStateOf("") } // format: yyyy-MM-dd'T'HH:mm
|
||||
|
||||
val dtFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm")
|
||||
val displayFormatter = DateTimeFormatter.ofPattern("d. MMMM yyyy, HH:mm", Locale.GERMANY)
|
||||
|
||||
fun convertUTCToLocal(utc: String?): String {
|
||||
if (utc.isNullOrBlank()) return ""
|
||||
return try {
|
||||
val instant = Instant.parse(utc)
|
||||
LocalDateTime.ofInstant(instant, ZoneId.systemDefault()).format(dtFormatter)
|
||||
} catch (e: Exception) { "" }
|
||||
}
|
||||
|
||||
fun convertLocalToUTC(local: String?): String? {
|
||||
if (local.isNullOrBlank()) return null
|
||||
return try {
|
||||
val ldt = LocalDateTime.parse(local, dtFormatter)
|
||||
ldt.atZone(ZoneId.systemDefault()).toInstant().toString()
|
||||
} catch (e: Exception) { null }
|
||||
}
|
||||
|
||||
// open create
|
||||
fun openAdd() {
|
||||
editing = null
|
||||
title = ""
|
||||
content = ""
|
||||
isPublic = false
|
||||
isHidden = false
|
||||
expiresAt = ""
|
||||
dialogOpen = true
|
||||
}
|
||||
|
||||
// open edit
|
||||
fun openEdit(item: NewsDto) {
|
||||
editing = item
|
||||
title = item.title
|
||||
content = item.content
|
||||
isPublic = item.isPublic
|
||||
isHidden = item.isHidden
|
||||
expiresAt = convertUTCToLocal(item.expiresAt)
|
||||
dialogOpen = true
|
||||
}
|
||||
|
||||
CmsPage(navController, showBackNavigation, "News", "Interne und öffentliche News") {
|
||||
if (state.loading) item { CircularProgressIndicator() }
|
||||
|
||||
item {
|
||||
Button(onClick = { viewModel.load(); /* ensure latest */ }, modifier = Modifier.fillMaxWidth()) { Text("Neu laden") }
|
||||
}
|
||||
|
||||
item {
|
||||
if (canWrite) Button(onClick = { openAdd() }, modifier = Modifier.fillMaxWidth()) { Text("News erstellen") }
|
||||
}
|
||||
|
||||
item {
|
||||
FormMessages(state.error, state.message)
|
||||
}
|
||||
|
||||
if (!state.loading && state.news.isEmpty()) item { Text("Noch keine News vorhanden.", modifier = Modifier.padding(12.dp)) }
|
||||
|
||||
// selection state for bulk actions (moved to outer scope)
|
||||
|
||||
items(state.news) { news ->
|
||||
val selected = news.id?.let { selection.contains(it) } ?: false
|
||||
NewsListItem(news = news, selected = selected, onSelect = { id, sel ->
|
||||
id?.let {
|
||||
selection = if (sel) selection + it else selection - it
|
||||
}
|
||||
}, onEdit = { openEdit(news) }, onDelete = { news.id?.let { id -> deletingIds = listOf(id) } })
|
||||
}
|
||||
|
||||
// bulk action bar
|
||||
if (selection.isNotEmpty()) {
|
||||
item {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = { viewModel.bulkSetPublic(selection.toList(), true) }) { Text("Als öffentlich markieren") }
|
||||
Button(onClick = { viewModel.bulkSetPublic(selection.toList(), false) }) { Text("Als nicht-öffentlich markieren") }
|
||||
Button(onClick = { viewModel.bulkSetHidden(selection.toList(), true) }) { Text("Ausblenden") }
|
||||
Button(onClick = { viewModel.bulkSetHidden(selection.toList(), false) }) { Text("Einblenden") }
|
||||
Button(onClick = { /* confirm then delete */ deletingIds = selection.toList() }) { Text("Löschen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (moved earlier)
|
||||
|
||||
// delete confirmation dialog
|
||||
if (deletingIds != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { deletingIds = null },
|
||||
title = { Text("News löschen") },
|
||||
text = { Text("Möchten Sie die ausgewählten News wirklich löschen?") },
|
||||
confirmButton = { Button(onClick = {
|
||||
deletingIds?.let { viewModel.bulkDelete(it) }
|
||||
deletingIds = null
|
||||
selection = emptySet()
|
||||
}) { Text("Löschen") } },
|
||||
dismissButton = { TextButton(onClick = { deletingIds = null }) { Text("Abbrechen") } },
|
||||
)
|
||||
}
|
||||
|
||||
// dialog for create/edit
|
||||
if (dialogOpen) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { dialogOpen = false },
|
||||
title = { Text(if (editing == null) "News erstellen" else "News bearbeiten") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(value = title, onValueChange = { title = it }, label = { Text("Titel *") }, modifier = Modifier.fillMaxWidth())
|
||||
NativeRichTextEditor(content, { content = it }, "Inhalt *")
|
||||
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||
Checkbox(checked = isPublic, onCheckedChange = { isPublic = it })
|
||||
Text("Öffentliche News (auf Startseite anzeigen)", modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
|
||||
if (isPublic) {
|
||||
// read-only datetime field that opens native pickers
|
||||
OutlinedTextField(
|
||||
value = expiresAt,
|
||||
onValueChange = { /* no-op: controlled by pickers */ },
|
||||
label = { Text("Ablaufdatum (optional)") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
// open date then time picker
|
||||
val now = java.util.Calendar.getInstance()
|
||||
val year = now.get(java.util.Calendar.YEAR)
|
||||
val month = now.get(java.util.Calendar.MONTH)
|
||||
val day = now.get(java.util.Calendar.DAY_OF_MONTH)
|
||||
android.app.DatePickerDialog(context, { _, y, m, d ->
|
||||
val hour = now.get(java.util.Calendar.HOUR_OF_DAY)
|
||||
val minute = now.get(java.util.Calendar.MINUTE)
|
||||
android.app.TimePickerDialog(context, { _, h, min ->
|
||||
val ldt = LocalDateTime.of(y, m + 1, d, h, min)
|
||||
expiresAt = ldt.format(dtFormatter)
|
||||
}, hour, minute, true).show()
|
||||
}, year, month, day).show()
|
||||
},
|
||||
readOnly = true,
|
||||
)
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||
Checkbox(checked = isHidden, onCheckedChange = { isHidden = it })
|
||||
Text("News ausblenden", modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
val err = state.error
|
||||
if (err != null) {
|
||||
Text(err, color = Color(0xFF842029))
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
val req = NewsSaveRequest(
|
||||
id = editing?.id,
|
||||
title = title,
|
||||
content = content,
|
||||
isPublic = isPublic,
|
||||
isHidden = isHidden,
|
||||
expiresAt = convertLocalToUTC(expiresAt),
|
||||
)
|
||||
viewModel.saveNews(req)
|
||||
dialogOpen = false
|
||||
}, enabled = !state.saving) { Text(if (state.saving) "Speichert..." else "Speichern") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { dialogOpen = false }) { Text("Abbrechen") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showSuccessDialog && !state.message.isNullOrBlank()) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showSuccessDialog = false },
|
||||
title = { Text("Erfolg") },
|
||||
text = { Text(state.message ?: "") },
|
||||
confirmButton = { Button(onClick = { showSuccessDialog = false }) { Text("OK") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NewsListItem(
|
||||
news: NewsDto,
|
||||
selected: Boolean = false,
|
||||
onSelect: (Int?, Boolean) -> Unit = { _, _ -> },
|
||||
onEdit: (NewsDto) -> Unit,
|
||||
onDelete: (Int) -> Unit,
|
||||
) {
|
||||
androidx.compose.material3.Surface(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||
Checkbox(checked = selected, onCheckedChange = { onSelect(news.id, it) })
|
||||
Text(news.title.ifBlank { "(Ohne Titel)" }, modifier = Modifier.padding(start = 8.dp))
|
||||
if (news.isPublic) {
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp)) {
|
||||
Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(Color(0xFF0EA5A6)))
|
||||
Text("Öffentlich", modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
}
|
||||
if (news.isHidden) {
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp)) {
|
||||
Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(Color.Gray))
|
||||
Text("Ausgeblendet", modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
}
|
||||
val expired = news.expiresAt?.let {
|
||||
try { Instant.parse(it).isBefore(Instant.now()) || Instant.parse(it).equals(Instant.now()) } catch (e: Exception) { false }
|
||||
} ?: false
|
||||
if (expired) {
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp)) {
|
||||
Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(Color(0xFFB91C1C)))
|
||||
Text("Abgelaufen", modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.padding(top = 4.dp)) {
|
||||
Text(news.author ?: "-", modifier = Modifier.padding(end = 12.dp))
|
||||
Text(news.created ?: "-")
|
||||
}
|
||||
if (news.updated != null && news.updated != news.created) {
|
||||
Text("Aktualisiert: ${news.updated}")
|
||||
}
|
||||
}
|
||||
Row {
|
||||
TextButton(onClick = { onEdit(news) }) { Text("Bearbeiten") }
|
||||
TextButton(onClick = { news.id?.let { onDelete(it) } }) { Text("Löschen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,23 +8,36 @@ import de.harheimertc.data.ConfigResponse
|
||||
import de.harheimertc.data.ContactRequestDto
|
||||
import de.harheimertc.data.NewsletterDto
|
||||
import de.harheimertc.data.NewsletterGroupDto
|
||||
import de.harheimertc.data.PasswordResetMatchingUserDto
|
||||
import de.harheimertc.data.PasswordResetAttemptDto
|
||||
import de.harheimertc.data.NewsDto
|
||||
import de.harheimertc.data.NewsSaveRequest
|
||||
import de.harheimertc.repositories.CmsRepository
|
||||
import de.harheimertc.repositories.MeisterschaftResult
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import de.harheimertc.ui.util.ErrorMapper
|
||||
|
||||
data class CmsUiState(
|
||||
val loading: Boolean = true,
|
||||
val saving: Boolean = false,
|
||||
val error: String? = null,
|
||||
val message: String? = null,
|
||||
val config: ConfigResponse? = null,
|
||||
val users: List<CmsUserDto> = emptyList(),
|
||||
val contactRequests: List<ContactRequestDto> = emptyList(),
|
||||
val newsletters: List<NewsletterDto> = emptyList(),
|
||||
val newsletterGroups: List<NewsletterGroupDto> = emptyList(),
|
||||
val passwordResetAttempts: List<PasswordResetAttemptDto> = emptyList(),
|
||||
val passwordResetMatchingUsers: List<PasswordResetMatchingUserDto> = emptyList(),
|
||||
val passwordResetRetentionHours: Int = 72,
|
||||
val passwordResetSearchTerm: String = "",
|
||||
val passwordResetFailedOnly: Boolean = true,
|
||||
val news: List<NewsDto> = emptyList(),
|
||||
val meisterschaften: List<MeisterschaftResult> = emptyList(),
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
@@ -41,22 +54,400 @@ class CmsViewModel @Inject constructor(
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, error = null)
|
||||
val config = async { repository.config().getOrNull() }
|
||||
val users = async { repository.users().getOrNull()?.users.orEmpty() }
|
||||
val requests = async { repository.contactRequests().getOrNull().orEmpty() }
|
||||
val newsletters = async { repository.newsletters().getOrNull()?.newsletters.orEmpty() }
|
||||
val groups = async { repository.newsletterGroups().getOrNull()?.groups.orEmpty() }
|
||||
val diagnostics = async { repository.passwordResetDiagnostics().getOrNull()?.attempts.orEmpty() }
|
||||
|
||||
val configRes = async { repository.config() }
|
||||
val usersRes = async { repository.users() }
|
||||
val requestsRes = async { repository.contactRequests() }
|
||||
val newslettersRes = async { repository.newsletters() }
|
||||
val groupsRes = async { repository.newsletterGroups() }
|
||||
val newsRes = async { repository.news() }
|
||||
val diagnosticsRes = async {
|
||||
repository.passwordResetDiagnostics(
|
||||
email = _state.value.passwordResetSearchTerm.takeIf { it.isNotBlank() },
|
||||
failedOnly = _state.value.passwordResetFailedOnly,
|
||||
)
|
||||
}
|
||||
val meisterschaftenRes = async { repository.vereinsmeisterschaften() }
|
||||
|
||||
val configResult = configRes.await()
|
||||
val usersResult = usersRes.await()
|
||||
val requestsResult = requestsRes.await()
|
||||
val newslettersResult = newslettersRes.await()
|
||||
val groupsResult = groupsRes.await()
|
||||
val newsResult = newsRes.await()
|
||||
val diagnosticsResult = diagnosticsRes.await()
|
||||
val meisterschaftenResult = meisterschaftenRes.await()
|
||||
|
||||
val errors = listOfNotNull(
|
||||
ErrorMapper.mapError(configResult.exceptionOrNull()),
|
||||
ErrorMapper.mapError(usersResult.exceptionOrNull()),
|
||||
ErrorMapper.mapError(requestsResult.exceptionOrNull()),
|
||||
ErrorMapper.mapError(newslettersResult.exceptionOrNull()),
|
||||
ErrorMapper.mapError(groupsResult.exceptionOrNull()),
|
||||
ErrorMapper.mapError(newsResult.exceptionOrNull()),
|
||||
ErrorMapper.mapError(diagnosticsResult.exceptionOrNull()),
|
||||
ErrorMapper.mapError(meisterschaftenResult.exceptionOrNull()),
|
||||
)
|
||||
|
||||
// Sort users so that pending (inactive) users come first,
|
||||
// followed by active users sorted by display name (case-insensitive).
|
||||
val fetchedUsers = usersResult.getOrNull()?.users.orEmpty()
|
||||
val pendingUsers = fetchedUsers.filter { it.active == false }
|
||||
val activeUsers = fetchedUsers.filter { it.active == true }
|
||||
.sortedBy { it.name.lowercase() }
|
||||
val orderedUsers = pendingUsers + activeUsers
|
||||
|
||||
_state.value = CmsUiState(
|
||||
loading = false,
|
||||
config = config.await(),
|
||||
users = users.await(),
|
||||
contactRequests = requests.await(),
|
||||
newsletters = newsletters.await(),
|
||||
newsletterGroups = groups.await(),
|
||||
passwordResetAttempts = diagnostics.await(),
|
||||
error = if (errors.isNotEmpty()) errors.joinToString("; ") else null,
|
||||
config = configResult.getOrNull(),
|
||||
users = orderedUsers,
|
||||
contactRequests = requestsResult.getOrNull().orEmpty(),
|
||||
newsletters = newslettersResult.getOrNull()?.newsletters.orEmpty(),
|
||||
newsletterGroups = groupsResult.getOrNull()?.groups.orEmpty(),
|
||||
news = newsResult.getOrNull()?.news.orEmpty(),
|
||||
passwordResetAttempts = diagnosticsResult.getOrNull()?.attempts.orEmpty(),
|
||||
passwordResetMatchingUsers = diagnosticsResult.getOrNull()?.matchingUsers.orEmpty(),
|
||||
passwordResetRetentionHours = diagnosticsResult.getOrNull()?.retentionHours ?: 72,
|
||||
passwordResetSearchTerm = diagnosticsResult.getOrNull()?.searchedEmail.orEmpty(),
|
||||
passwordResetFailedOnly = _state.value.passwordResetFailedOnly,
|
||||
meisterschaften = meisterschaftenResult.getOrNull().orEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadPasswordResetDiagnostics(email: String, failedOnly: Boolean) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(
|
||||
loading = true,
|
||||
error = null,
|
||||
passwordResetSearchTerm = email.trim(),
|
||||
passwordResetFailedOnly = failedOnly,
|
||||
)
|
||||
repository.passwordResetDiagnostics(
|
||||
email = email.trim().takeIf { it.isNotBlank() },
|
||||
failedOnly = failedOnly,
|
||||
)
|
||||
.onSuccess { response ->
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
passwordResetRetentionHours = response.retentionHours,
|
||||
passwordResetMatchingUsers = response.matchingUsers,
|
||||
passwordResetAttempts = response.attempts,
|
||||
passwordResetSearchTerm = response.searchedEmail.orEmpty(),
|
||||
)
|
||||
}
|
||||
.onFailure { err ->
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
passwordResetMatchingUsers = emptyList(),
|
||||
passwordResetAttempts = emptyList(),
|
||||
error = ErrorMapper.mapError(err) ?: "Passwort-Reset-Diagnose konnte nicht geladen werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveVereinsmeisterschaften(results: List<MeisterschaftResult>) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
repository.saveVereinsmeisterschaften(results)
|
||||
.onSuccess { response ->
|
||||
_state.value = _state.value.copy(
|
||||
saving = false,
|
||||
meisterschaften = results,
|
||||
message = response.message ?: "Vereinsmeisterschaften gespeichert.",
|
||||
)
|
||||
}
|
||||
.onFailure { err ->
|
||||
_state.value = _state.value.copy(
|
||||
saving = false,
|
||||
error = ErrorMapper.mapError(err) ?: "Vereinsmeisterschaften konnten nicht gespeichert werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveConfig(config: ConfigResponse) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
repository.saveConfig(config)
|
||||
.onSuccess { saved ->
|
||||
_state.value = _state.value.copy(
|
||||
saving = false,
|
||||
config = saved,
|
||||
message = "Inhalt gespeichert.",
|
||||
)
|
||||
}
|
||||
.onFailure {
|
||||
_state.value = _state.value.copy(
|
||||
saving = false,
|
||||
error = ErrorMapper.mapError(it) ?: "Inhalt konnte nicht gespeichert werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveNews(request: NewsSaveRequest) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
repository.saveNews(request)
|
||||
.onSuccess { msg ->
|
||||
// refresh news list directly to preserve message
|
||||
val newsRes = repository.news()
|
||||
_state.value = _state.value.copy(
|
||||
saving = false,
|
||||
news = newsRes.getOrNull()?.news.orEmpty(),
|
||||
message = msg.message ?: "Nachricht gespeichert.",
|
||||
)
|
||||
}
|
||||
.onFailure { err ->
|
||||
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "News konnten nicht gespeichert werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun bulkSetPublic(ids: List<Int>, makePublic: Boolean) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
ids.forEach { id ->
|
||||
val existing = _state.value.news.find { it.id == id }
|
||||
if (existing != null) {
|
||||
val req = NewsSaveRequest(
|
||||
id = existing.id,
|
||||
title = existing.title,
|
||||
content = existing.content,
|
||||
isPublic = makePublic,
|
||||
isHidden = existing.isHidden,
|
||||
expiresAt = existing.expiresAt,
|
||||
)
|
||||
repository.saveNews(req)
|
||||
}
|
||||
}
|
||||
val newsRes = repository.news()
|
||||
_state.value = _state.value.copy(saving = false, news = newsRes.getOrNull()?.news.orEmpty(), message = "Bulk-Update abgeschlossen.")
|
||||
}
|
||||
}
|
||||
|
||||
fun bulkSetHidden(ids: List<Int>, makeHidden: Boolean) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
ids.forEach { id ->
|
||||
val existing = _state.value.news.find { it.id == id }
|
||||
if (existing != null) {
|
||||
val req = NewsSaveRequest(
|
||||
id = existing.id,
|
||||
title = existing.title,
|
||||
content = existing.content,
|
||||
isPublic = existing.isPublic,
|
||||
isHidden = makeHidden,
|
||||
expiresAt = existing.expiresAt,
|
||||
)
|
||||
repository.saveNews(req)
|
||||
}
|
||||
}
|
||||
val newsRes = repository.news()
|
||||
_state.value = _state.value.copy(saving = false, news = newsRes.getOrNull()?.news.orEmpty(), message = "Bulk-Update abgeschlossen.")
|
||||
}
|
||||
}
|
||||
|
||||
fun bulkDelete(ids: List<Int>) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
ids.forEach { id ->
|
||||
repository.deleteNews(id)
|
||||
}
|
||||
val newsRes = repository.news()
|
||||
_state.value = _state.value.copy(saving = false, news = newsRes.getOrNull()?.news.orEmpty(), message = "Bulk-Löschung abgeschlossen.")
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteNews(id: Int) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
repository.deleteNews(id)
|
||||
.onSuccess { msg ->
|
||||
val newsRes = repository.news()
|
||||
_state.value = _state.value.copy(
|
||||
saving = false,
|
||||
news = newsRes.getOrNull()?.news.orEmpty(),
|
||||
message = msg.message ?: "Nachricht gelöscht.",
|
||||
)
|
||||
}
|
||||
.onFailure { err ->
|
||||
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "News konnten nicht gelöscht werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Newsletter (B4)
|
||||
fun saveNewsletter(request: de.harheimertc.data.NewsletterCreateRequest) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
repository.createNewsletter(request)
|
||||
.onSuccess { res ->
|
||||
val newslettersRes = repository.newsletters()
|
||||
_state.value = _state.value.copy(
|
||||
saving = false,
|
||||
newsletters = newslettersRes.getOrNull()?.newsletters.orEmpty(),
|
||||
message = res.message ?: "Newsletter gespeichert",
|
||||
)
|
||||
}
|
||||
.onFailure { err ->
|
||||
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Newsletter konnte nicht gespeichert werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNewsletter(id: String, patch: Map<String, Any?>) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
repository.updateNewsletter(id, patch)
|
||||
.onSuccess { res ->
|
||||
val newslettersRes = repository.newsletters()
|
||||
_state.value = _state.value.copy(saving = false, newsletters = newslettersRes.getOrNull()?.newsletters.orEmpty(), message = res.message ?: "Newsletter aktualisiert")
|
||||
}
|
||||
.onFailure { err -> _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Newsletter konnte nicht aktualisiert werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun sendNewsletter(id: String) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
repository.sendNewsletter(id)
|
||||
.onSuccess { res ->
|
||||
val newslettersRes = repository.newsletters()
|
||||
_state.value = _state.value.copy(saving = false, newsletters = newslettersRes.getOrNull()?.newsletters.orEmpty(), message = res.message ?: "Newsletter versendet")
|
||||
}
|
||||
.onFailure { err -> _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Newsletter konnte nicht versendet werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteNewsletter(id: String) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
repository.deleteNewsletter(id)
|
||||
.onSuccess { res ->
|
||||
val newslettersRes = repository.newsletters()
|
||||
_state.value = _state.value.copy(saving = false, newsletters = newslettersRes.getOrNull()?.newsletters.orEmpty(), message = res.message ?: "Newsletter gelöscht")
|
||||
}
|
||||
.onFailure { err -> _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Newsletter konnte nicht gelöscht werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
// --- Newsletter Groups (B4)
|
||||
fun createNewsletterGroup(payload: Map<String, Any?>) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
repository.createNewsletterGroup(payload)
|
||||
.onSuccess { res ->
|
||||
val groupsRes = repository.newsletterGroups()
|
||||
_state.value = _state.value.copy(saving = false, newsletterGroups = groupsRes.getOrNull()?.groups.orEmpty(), message = res.message ?: "Gruppe erstellt")
|
||||
}
|
||||
.onFailure { err -> _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Gruppe konnte nicht erstellt werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNewsletterGroup(id: String, patch: Map<String, Any?>) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
repository.updateNewsletterGroup(id, patch)
|
||||
.onSuccess { res ->
|
||||
val groupsRes = repository.newsletterGroups()
|
||||
_state.value = _state.value.copy(saving = false, newsletterGroups = groupsRes.getOrNull()?.groups.orEmpty(), message = res.message ?: "Gruppe aktualisiert")
|
||||
}
|
||||
.onFailure { err -> _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Gruppe konnte nicht aktualisiert werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteNewsletterGroup(id: String) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
repository.deleteNewsletterGroup(id)
|
||||
.onSuccess { res ->
|
||||
val groupsRes = repository.newsletterGroups()
|
||||
_state.value = _state.value.copy(saving = false, newsletterGroups = groupsRes.getOrNull()?.groups.orEmpty(), message = res.message ?: "Gruppe gelöscht")
|
||||
}
|
||||
.onFailure { err -> _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Gruppe konnte nicht gelöscht werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
// --- User management actions (B2)
|
||||
fun updateUserRoles(id: String, roles: List<String>) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
repository.updateUserRoles(id, roles)
|
||||
.onSuccess { msg ->
|
||||
val usersRes = repository.users()
|
||||
_state.value = _state.value.copy(
|
||||
saving = false,
|
||||
users = usersRes.getOrNull()?.users.orEmpty(),
|
||||
message = msg.message ?: "Rollen aktualisiert.",
|
||||
)
|
||||
}
|
||||
.onFailure { err ->
|
||||
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Rollen konnten nicht aktualisiert werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setUserActive(id: String, active: Boolean) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
repository.updateUserActive(id, active)
|
||||
.onSuccess { msg ->
|
||||
val usersRes = repository.users()
|
||||
_state.value = _state.value.copy(
|
||||
saving = false,
|
||||
users = usersRes.getOrNull()?.users.orEmpty(),
|
||||
message = msg.message ?: if (active) "Benutzer aktiviert." else "Benutzer deaktiviert.",
|
||||
)
|
||||
}
|
||||
.onFailure { err ->
|
||||
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Benutzerstatus konnte nicht geändert werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resendInvite(id: String) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
repository.resendInvite(id)
|
||||
.onSuccess { msg ->
|
||||
_state.value = _state.value.copy(saving = false, message = msg.message ?: "Einladung erneut gesendet.")
|
||||
}
|
||||
.onFailure { err ->
|
||||
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Einladung konnte nicht gesendet werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Contact requests (B3)
|
||||
fun replyToContactRequest(id: String, message: String) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
repository.replyToContactRequest(id, message)
|
||||
.onSuccess {
|
||||
val reqs = repository.contactRequests()
|
||||
_state.value = _state.value.copy(saving = false, contactRequests = reqs.getOrNull().orEmpty(), message = it.message ?: "Antwort gesendet.")
|
||||
}
|
||||
.onFailure { err ->
|
||||
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Antwort konnte nicht gesendet werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleContactRequestStatus(id: String) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
repository.toggleContactRequestStatus(id)
|
||||
.onSuccess {
|
||||
val reqs = repository.contactRequests()
|
||||
_state.value = _state.value.copy(saving = false, contactRequests = reqs.getOrNull().orEmpty(), message = it.message ?: "Status aktualisiert.")
|
||||
}
|
||||
.onFailure { err ->
|
||||
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Status konnte nicht geändert werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,41 @@
|
||||
package de.harheimertc.ui.screens.gallery
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
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.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import de.harheimertc.R
|
||||
import de.harheimertc.ui.components.FormMessages
|
||||
import de.harheimertc.ui.components.ImageGrid
|
||||
|
||||
@Composable
|
||||
@@ -17,17 +43,104 @@ fun GalleryScreen(viewModel: GalleryViewModel = hiltViewModel()) {
|
||||
val images by viewModel.images.collectAsState()
|
||||
val loading by viewModel.loading.collectAsState()
|
||||
val error by viewModel.error.collectAsState()
|
||||
val uploading by viewModel.uploading.collectAsState()
|
||||
val message by viewModel.message.collectAsState()
|
||||
val canUpload by viewModel.canUpload.collectAsState()
|
||||
var selectedUri by remember { mutableStateOf<android.net.Uri?>(null) }
|
||||
var title by remember { mutableStateOf("") }
|
||||
var description by remember { mutableStateOf("") }
|
||||
var isPublic by remember { mutableStateOf(false) }
|
||||
var showUpload by remember { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
val picker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
selectedUri = uri
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.gallery_title),
|
||||
modifier = Modifier.semantics { heading() },
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
if (canUpload) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(stringResource(R.string.gallery_upload_title), modifier = Modifier.weight(1f))
|
||||
OutlinedButton(onClick = { showUpload = !showUpload }) {
|
||||
Text(if (showUpload) stringResource(R.string.gallery_upload_hide) else stringResource(R.string.gallery_upload_show))
|
||||
}
|
||||
}
|
||||
if (showUpload) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedButton(
|
||||
onClick = { picker.launch("image/*") },
|
||||
enabled = !uploading,
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription = context.getString(R.string.gallery_upload_choose_file)
|
||||
},
|
||||
) {
|
||||
Text(selectedUri?.lastPathSegment ?: stringResource(R.string.gallery_upload_choose_file))
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = title,
|
||||
onValueChange = { title = it },
|
||||
label = { Text(stringResource(R.string.gallery_upload_image_title)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !uploading,
|
||||
singleLine = true,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text(stringResource(R.string.gallery_upload_description)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !uploading,
|
||||
minLines = 2,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = isPublic, onCheckedChange = { isPublic = it }, enabled = !uploading)
|
||||
Text(stringResource(R.string.gallery_upload_public))
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
selectedUri?.let { uri ->
|
||||
viewModel.upload(uri, title, description, isPublic)
|
||||
}
|
||||
},
|
||||
enabled = selectedUri != null && title.isNotBlank() && !uploading,
|
||||
) {
|
||||
Text(if (uploading) stringResource(R.string.gallery_uploading) else stringResource(R.string.gallery_upload_submit))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
FormMessages(error = error, message = message)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
if (loading) {
|
||||
CircularProgressIndicator()
|
||||
} else if (error != null) {
|
||||
Text(text = "Fehler: $error")
|
||||
} else if (images.isEmpty()) {
|
||||
Text(text = stringResource(R.string.gallery_empty))
|
||||
} else {
|
||||
ImageGrid(images = images)
|
||||
ImageGrid(images = images, modifier = Modifier.height(520.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// load on first composition
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) { viewModel.load() }
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||
viewModel.load()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,16 +3,22 @@ package de.harheimertc.ui.screens.gallery
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import android.net.Uri
|
||||
import de.harheimertc.repositories.GalleryImage
|
||||
import de.harheimertc.repositories.GalleryRepository
|
||||
import de.harheimertc.repositories.LoginRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class GalleryViewModel @Inject constructor(private val repo: GalleryRepository) : ViewModel() {
|
||||
private val _images = MutableStateFlow<List<String>>(emptyList())
|
||||
val images: StateFlow<List<String>> = _images
|
||||
class GalleryViewModel @Inject constructor(
|
||||
private val repo: GalleryRepository,
|
||||
private val loginRepository: LoginRepository,
|
||||
) : ViewModel() {
|
||||
private val _images = MutableStateFlow<List<GalleryImage>>(emptyList())
|
||||
val images: StateFlow<List<GalleryImage>> = _images
|
||||
|
||||
private val _loading = MutableStateFlow(false)
|
||||
val loading: StateFlow<Boolean> = _loading
|
||||
@@ -20,14 +26,43 @@ class GalleryViewModel @Inject constructor(private val repo: GalleryRepository)
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
val error: StateFlow<String?> = _error
|
||||
|
||||
private val _uploading = MutableStateFlow(false)
|
||||
val uploading: StateFlow<Boolean> = _uploading
|
||||
|
||||
private val _message = MutableStateFlow<String?>(null)
|
||||
val message: StateFlow<String?> = _message
|
||||
|
||||
private val _canUpload = MutableStateFlow(false)
|
||||
val canUpload: StateFlow<Boolean> = _canUpload
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_loading.value = true
|
||||
_error.value = null
|
||||
repo.fetchImages()
|
||||
.onSuccess { _images.value = it }
|
||||
.onSuccess { _images.value = it.images }
|
||||
.onFailure { _error.value = it.message ?: "Fehler" }
|
||||
loginRepository.status()
|
||||
.onSuccess { status ->
|
||||
val roles = (status.roles + status.user?.roles.orEmpty() + listOfNotNull(status.role)).toSet()
|
||||
_canUpload.value = roles.any { it in setOf("admin", "vorstand") }
|
||||
}
|
||||
_loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun upload(uri: Uri, title: String, description: String, isPublic: Boolean) {
|
||||
viewModelScope.launch {
|
||||
_uploading.value = true
|
||||
_error.value = null
|
||||
_message.value = null
|
||||
repo.uploadImage(uri, title, description, isPublic)
|
||||
.onSuccess {
|
||||
_message.value = "Bild erfolgreich hochgeladen."
|
||||
load()
|
||||
}
|
||||
.onFailure { _error.value = it.message ?: "Fehler beim Hochladen des Bildes" }
|
||||
_uploading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,16 +21,20 @@ import androidx.compose.material3.AlertDialog
|
||||
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.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -45,10 +49,13 @@ 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 de.harheimertc.ui.navigation.NavigationViewModel
|
||||
import androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.data.HomepageSectionDto
|
||||
import de.harheimertc.data.NewsDto
|
||||
import de.harheimertc.data.SeasonDto
|
||||
import de.harheimertc.data.SpielDto
|
||||
import de.harheimertc.data.TerminDto
|
||||
import de.harheimertc.ui.components.AppNavigationHeader
|
||||
@@ -72,8 +79,11 @@ fun HomeScreen(
|
||||
showNavigationHeader: Boolean = true,
|
||||
viewModel: HomeViewModel = hiltViewModel(),
|
||||
) {
|
||||
val navigationViewModel: NavigationViewModel = hiltViewModel()
|
||||
val navigationState by navigationViewModel.state.collectAsState()
|
||||
val state by viewModel.state.collectAsState()
|
||||
var selectedNews by remember { mutableStateOf<NewsDto?>(null) }
|
||||
var editHomeSections by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
selectedNews?.let { item ->
|
||||
AlertDialog(
|
||||
@@ -97,42 +107,352 @@ fun HomeScreen(
|
||||
AppNavigationHeader(
|
||||
selectedRoute = Destinations.Home.route,
|
||||
onNavigate = navController::navigate,
|
||||
navigationState = navigationState,
|
||||
)
|
||||
}
|
||||
}
|
||||
item { WebHero() }
|
||||
item {
|
||||
HomeCustomizationSection(
|
||||
sections = state.homepageSections,
|
||||
spielplanSeasons = state.spielplanSeasons,
|
||||
spielplanTeamsBySeason = state.spielplanTeamsBySeason,
|
||||
editEnabled = editHomeSections,
|
||||
onToggleEdit = { editHomeSections = !editHomeSections },
|
||||
onMoveUp = viewModel::moveSectionUp,
|
||||
onMoveDown = viewModel::moveSectionDown,
|
||||
onEnabledChange = viewModel::setSectionEnabled,
|
||||
onAddSpielplanWidget = viewModel::addSpielplanTeamWidget,
|
||||
onUpdateSpielplanWidget = viewModel::updateSpielplanTeamWidget,
|
||||
onReset = viewModel::resetSections,
|
||||
)
|
||||
}
|
||||
state.homepageSections.forEachIndexed { index, section ->
|
||||
if (!section.enabled) return@forEachIndexed
|
||||
val sectionKey = homeSectionKey(section)
|
||||
when (section.id) {
|
||||
"banner" -> item(key = "home_section_${sectionKey}_$index") { WebHero() }
|
||||
"termine" -> item(key = "home_section_${sectionKey}_$index") {
|
||||
HomeTermineSection(
|
||||
termine = state.termine,
|
||||
loading = state.loading,
|
||||
onAll = { navController.navigate(Destinations.Termine.route) },
|
||||
)
|
||||
}
|
||||
item {
|
||||
"spiele" -> item(key = "home_section_${sectionKey}_$index") {
|
||||
HomeGamesSection(
|
||||
spiele = state.spiele,
|
||||
loading = state.loading,
|
||||
onAll = { navController.navigate(Destinations.Spielplan.route) },
|
||||
)
|
||||
}
|
||||
"aktuelles" -> {
|
||||
if (state.news.isNotEmpty()) {
|
||||
item {
|
||||
item(key = "home_section_${sectionKey}_$index") {
|
||||
HomeNewsSection(
|
||||
news = state.news,
|
||||
onOpen = { selectedNews = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
}
|
||||
"kontakt" -> item(key = "home_section_${sectionKey}_$index") {
|
||||
HomeActionSection(
|
||||
onMembership = { navController.navigate(Destinations.Membership.route) },
|
||||
onContact = { navController.navigate(Destinations.Contact.route) },
|
||||
)
|
||||
}
|
||||
"training" -> item(key = "home_section_${sectionKey}_$index") {
|
||||
HomeExtraActionSection(
|
||||
title = "Training & Einstieg",
|
||||
body = "Alle Infos zu Trainingszeiten, Trainern und unserem Anfängerangebot.",
|
||||
action = "Zum Training",
|
||||
onClick = { navController.navigate(Destinations.Training.route) },
|
||||
)
|
||||
}
|
||||
"links" -> item(key = "home_section_${sectionKey}_$index") {
|
||||
HomeExtraActionSection(
|
||||
title = "Nützliche Links",
|
||||
body = "Direkter Zugang zu Verbänden, Ergebnisdiensten und hilfreichen Portalen.",
|
||||
action = "Links öffnen",
|
||||
onClick = { navController.navigate(Destinations.Links.route) },
|
||||
)
|
||||
}
|
||||
"vereinsmeisterschaften" -> item(key = "home_section_${sectionKey}_$index") {
|
||||
HomeExtraActionSection(
|
||||
title = "Vereinsmeisterschaften",
|
||||
body = "Ergebnisse und Historie unserer Vereinsmeisterschaften.",
|
||||
action = "Ergebnisse ansehen",
|
||||
onClick = { navController.navigate(Destinations.Vereinsmeisterschaften.route) },
|
||||
)
|
||||
}
|
||||
"spielplan_team" -> item(key = "home_section_${sectionKey}_$index") {
|
||||
HomeSpielplanTeamWidgetSection(
|
||||
section = section,
|
||||
spiele = state.spielplanWidgetPreviews[sectionKey].orEmpty(),
|
||||
error = state.spielplanWidgetErrors[sectionKey],
|
||||
loading = state.widgetsLoading,
|
||||
onOpenAll = { navController.navigate(Destinations.Spielplan.route) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
item { HomeFooter() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeCustomizationSection(
|
||||
sections: List<HomepageSectionDto>,
|
||||
spielplanSeasons: List<SeasonDto>,
|
||||
spielplanTeamsBySeason: Map<String, List<HomeSpielplanTeamOption>>,
|
||||
editEnabled: Boolean,
|
||||
onToggleEdit: () -> Unit,
|
||||
onMoveUp: (String) -> Unit,
|
||||
onMoveDown: (String) -> Unit,
|
||||
onEnabledChange: (String, Boolean) -> Unit,
|
||||
onAddSpielplanWidget: (season: String, teamName: String, teamAgeGroup: String) -> Unit,
|
||||
onUpdateSpielplanWidget: (sectionKey: String, season: String, teamName: String, teamAgeGroup: String) -> Unit,
|
||||
onReset: () -> Unit,
|
||||
) {
|
||||
var addSeason by rememberSaveable { mutableStateOf("") }
|
||||
var addTeamKey by rememberSaveable { mutableStateOf("") }
|
||||
val addTeamOptions = spielplanTeamsBySeason[addSeason].orEmpty()
|
||||
|
||||
LaunchedEffect(spielplanSeasons) {
|
||||
if (addSeason.isBlank() || spielplanSeasons.none { it.slug == addSeason }) {
|
||||
addSeason = spielplanSeasons.firstOrNull()?.slug.orEmpty()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(addSeason, addTeamOptions) {
|
||||
if (addTeamOptions.none { teamOptionKey(it) == addTeamKey }) {
|
||||
addTeamKey = addTeamOptions.firstOrNull()?.let(::teamOptionKey).orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
color = Color(0xFFFAFAFA),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
OutlinedButton(onClick = onToggleEdit) {
|
||||
Text(if (editEnabled) "Startseiten-Editor schließen" else "Startseite anpassen")
|
||||
}
|
||||
if (editEnabled) {
|
||||
Text(
|
||||
"Elemente ein-/ausblenden und Reihenfolge festlegen.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Accent700,
|
||||
)
|
||||
sections.forEachIndexed { index, section ->
|
||||
val label = homeSectionLabels[section.id] ?: section.id
|
||||
val sectionKey = homeSectionKey(section)
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(label, style = MaterialTheme.typography.titleMedium, color = Accent900)
|
||||
Text(section.id, style = MaterialTheme.typography.labelSmall, color = Accent500)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Anzeigen", color = Accent700, style = MaterialTheme.typography.labelSmall)
|
||||
androidx.compose.material3.Checkbox(
|
||||
checked = section.enabled,
|
||||
onCheckedChange = { enabled -> onEnabledChange(sectionKey, enabled) },
|
||||
)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
OutlinedButton(onClick = { onMoveUp(sectionKey) }, enabled = index > 0) { Text("Hoch") }
|
||||
OutlinedButton(onClick = { onMoveDown(sectionKey) }, enabled = index < sections.lastIndex) { Text("Runter") }
|
||||
}
|
||||
}
|
||||
|
||||
if (section.id == "spielplan_team") {
|
||||
SpielplanWidgetConfigEditor(
|
||||
section = section,
|
||||
seasons = spielplanSeasons,
|
||||
teamsBySeason = spielplanTeamsBySeason,
|
||||
onUpdate = { season, teamName, teamAgeGroup ->
|
||||
onUpdateSpielplanWidget(sectionKey, season, teamName, teamAgeGroup)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text("Widget hinzufügen", style = MaterialTheme.typography.titleMedium, color = Accent900)
|
||||
Text("Spielplan Mannschaft", style = MaterialTheme.typography.bodyMedium, color = Accent700)
|
||||
|
||||
SimpleSelector(
|
||||
label = "Saison",
|
||||
selected = spielplanSeasons.firstOrNull { it.slug == addSeason }?.let { formatSeasonLabel(it.slug) }
|
||||
?: "Bitte wählen",
|
||||
options = spielplanSeasons.map { season ->
|
||||
SelectOption(
|
||||
key = season.slug,
|
||||
label = formatSeasonLabel(season.slug),
|
||||
)
|
||||
},
|
||||
onSelect = { option ->
|
||||
addSeason = option.key
|
||||
},
|
||||
)
|
||||
|
||||
SimpleSelector(
|
||||
label = "Mannschaft",
|
||||
selected = addTeamOptions.firstOrNull { teamOptionKey(it) == addTeamKey }?.label ?: "Bitte wählen",
|
||||
options = addTeamOptions.map { team ->
|
||||
SelectOption(
|
||||
key = teamOptionKey(team),
|
||||
label = team.label,
|
||||
)
|
||||
},
|
||||
onSelect = { option ->
|
||||
addTeamKey = option.key
|
||||
},
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val selectedTeam = addTeamOptions.firstOrNull { teamOptionKey(it) == addTeamKey } ?: return@Button
|
||||
onAddSpielplanWidget(addSeason, selectedTeam.teamName, selectedTeam.teamAgeGroup)
|
||||
},
|
||||
enabled = addSeason.isNotBlank() && addTeamKey.isNotBlank(),
|
||||
) {
|
||||
Text("Widget hinzufügen")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextButton(onClick = onReset) {
|
||||
Text("Auf Server-Standard zurücksetzen")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class SelectOption(
|
||||
val key: String,
|
||||
val label: String,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun SimpleSelector(
|
||||
label: String,
|
||||
selected: String,
|
||||
options: List<SelectOption>,
|
||||
onSelect: (SelectOption) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(label, style = MaterialTheme.typography.labelSmall, color = Accent500)
|
||||
OutlinedButton(onClick = { expanded = true }, enabled = options.isNotEmpty()) {
|
||||
Text(selected)
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
options.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(option.label) },
|
||||
onClick = {
|
||||
onSelect(option)
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SpielplanWidgetConfigEditor(
|
||||
section: HomepageSectionDto,
|
||||
seasons: List<SeasonDto>,
|
||||
teamsBySeason: Map<String, List<HomeSpielplanTeamOption>>,
|
||||
onUpdate: (season: String, teamName: String, teamAgeGroup: String) -> Unit,
|
||||
) {
|
||||
val selectedSeason = section.config?.season.orEmpty()
|
||||
val selectedTeamName = section.config?.teamName.orEmpty()
|
||||
val selectedTeamAgeGroup = section.config?.teamAgeGroup.orEmpty()
|
||||
val teamOptions = teamsBySeason[selectedSeason].orEmpty()
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SimpleSelector(
|
||||
label = "Saison",
|
||||
selected = seasons.firstOrNull { it.slug == selectedSeason }?.let { formatSeasonLabel(it.slug) } ?: "Bitte wählen",
|
||||
options = seasons.map { season -> SelectOption(season.slug, formatSeasonLabel(season.slug)) },
|
||||
onSelect = { option ->
|
||||
val fallbackTeam = teamsBySeason[option.key].orEmpty().firstOrNull()
|
||||
onUpdate(
|
||||
option.key,
|
||||
fallbackTeam?.teamName ?: "",
|
||||
fallbackTeam?.teamAgeGroup ?: "",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
SimpleSelector(
|
||||
label = "Mannschaft",
|
||||
selected = teamOptions.firstOrNull {
|
||||
it.teamName == selectedTeamName && it.teamAgeGroup == selectedTeamAgeGroup
|
||||
}?.label ?: "Bitte wählen",
|
||||
options = teamOptions.map { team -> SelectOption(teamOptionKey(team), team.label) },
|
||||
onSelect = { option ->
|
||||
val selectedTeam = teamOptions.firstOrNull { teamOptionKey(it) == option.key }
|
||||
if (selectedTeam != null) {
|
||||
onUpdate(selectedSeason, selectedTeam.teamName, selectedTeam.teamAgeGroup)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeSpielplanTeamWidgetSection(
|
||||
section: HomepageSectionDto,
|
||||
spiele: List<SpielDto>,
|
||||
error: String?,
|
||||
loading: Boolean,
|
||||
onOpenAll: () -> Unit,
|
||||
) {
|
||||
val teamName = section.config?.teamName.orEmpty()
|
||||
val teamAgeGroup = section.config?.teamAgeGroup.orEmpty()
|
||||
val title = if (teamAgeGroup.contains("jugend", ignoreCase = true) && teamName.isNotBlank()) {
|
||||
"Spielplan: (J) $teamName"
|
||||
} else {
|
||||
"Spielplan: ${teamName.ifBlank { "Mannschaft" }}"
|
||||
}
|
||||
val season = section.config?.season.orEmpty()
|
||||
|
||||
HomeSection(title = title, subtitle = "Saison ${formatSeasonLabel(season)}", background = Color.White) {
|
||||
if (loading) {
|
||||
LoadingRow("Spiele werden geladen...")
|
||||
} else if (!error.isNullOrBlank()) {
|
||||
EmptyRow(error)
|
||||
} else if (spiele.isEmpty()) {
|
||||
EmptyRow("Keine kommenden Spiele für diese Mannschaft gefunden.")
|
||||
} else {
|
||||
spiele.forEach { spiel -> MatchCard(spiel) }
|
||||
}
|
||||
PrimaryAction("Voller Spielplan", onOpenAll)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WebHero() {
|
||||
val years = Calendar.getInstance().get(Calendar.YEAR) - 1954
|
||||
@@ -247,6 +567,18 @@ private fun HomeActionSection(onMembership: () -> Unit, onContact: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeExtraActionSection(title: String, body: String, action: String, onClick: () -> Unit) {
|
||||
HomeSection(title = null, background = Color.White) {
|
||||
ActionCard(
|
||||
title = title,
|
||||
body = body,
|
||||
action = action,
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeSection(
|
||||
title: String?,
|
||||
@@ -423,3 +755,27 @@ private fun formatNewsDate(value: String?): String {
|
||||
SimpleDateFormat("dd. MMMM yyyy", Locale.GERMANY).format(source.parse(value.take(10))!!)
|
||||
}.getOrDefault(value.take(10))
|
||||
}
|
||||
|
||||
private fun homeSectionKey(section: HomepageSectionDto): String =
|
||||
section.key?.takeIf { it.isNotBlank() } ?: section.id
|
||||
|
||||
private fun teamOptionKey(option: HomeSpielplanTeamOption): String =
|
||||
"${option.teamName}|${option.teamAgeGroup}"
|
||||
|
||||
private fun formatSeasonLabel(value: String): String {
|
||||
val match = Regex("^(\\d{2})--(\\d{2})$").find(value)
|
||||
if (match == null) return value.ifBlank { "-" }
|
||||
return "20${match.groupValues[1]}/${match.groupValues[2]}"
|
||||
}
|
||||
|
||||
private val homeSectionLabels = mapOf(
|
||||
"banner" to "Banner (Willkommen)",
|
||||
"aktuelles" to "Aktuelles",
|
||||
"termine" to "Kommende Termine",
|
||||
"spiele" to "Nächste Spiele",
|
||||
"kontakt" to "Kontakt-Boxen",
|
||||
"training" to "Training-Teaser",
|
||||
"links" to "Links-Teaser",
|
||||
"vereinsmeisterschaften" to "Vereinsmeisterschaften-Teaser",
|
||||
"spielplan_team" to "Widget: Spielplan Mannschaft",
|
||||
)
|
||||
|
||||
@@ -3,30 +3,58 @@ package de.harheimertc.ui.screens.home
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.HomepageSectionConfigDto
|
||||
import de.harheimertc.data.HomepageSectionDto
|
||||
import de.harheimertc.data.NewsDto
|
||||
import de.harheimertc.data.SeasonDto
|
||||
import de.harheimertc.data.SpielDto
|
||||
import de.harheimertc.data.TerminDto
|
||||
import de.harheimertc.repositories.HomeLayoutPreferences
|
||||
import de.harheimertc.repositories.HomeRepository
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class HomeSpielplanTeamOption(
|
||||
val teamName: String,
|
||||
val teamAgeGroup: String,
|
||||
) {
|
||||
val label: String
|
||||
get() = if (teamAgeGroup.contains("jugend", ignoreCase = true)) {
|
||||
"(J) $teamName"
|
||||
} else {
|
||||
teamName
|
||||
}
|
||||
}
|
||||
|
||||
data class HomeUiState(
|
||||
val loading: Boolean = true,
|
||||
val termine: List<TerminDto> = emptyList(),
|
||||
val spiele: List<SpielDto> = emptyList(),
|
||||
val news: List<NewsDto> = emptyList(),
|
||||
val homepageSections: List<HomepageSectionDto> = defaultHomepageSections,
|
||||
val spielplanSeasons: List<SeasonDto> = emptyList(),
|
||||
val spielplanTeamsBySeason: Map<String, List<HomeSpielplanTeamOption>> = emptyMap(),
|
||||
val spielplanWidgetPreviews: Map<String, List<SpielDto>> = emptyMap(),
|
||||
val spielplanWidgetErrors: Map<String, String> = emptyMap(),
|
||||
val widgetsLoading: Boolean = false,
|
||||
val error: Boolean = false,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class HomeViewModel @Inject constructor(private val repository: HomeRepository) : ViewModel() {
|
||||
class HomeViewModel @Inject constructor(
|
||||
private val repository: HomeRepository,
|
||||
private val layoutPreferences: HomeLayoutPreferences,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(HomeUiState())
|
||||
val state: StateFlow<HomeUiState> = _state
|
||||
private var serverSections: List<HomepageSectionDto> = defaultHomepageSections
|
||||
private val seasonGamesCache = mutableMapOf<String, List<SpielDto>>()
|
||||
|
||||
init {
|
||||
load()
|
||||
@@ -37,6 +65,19 @@ class HomeViewModel @Inject constructor(private val repository: HomeRepository)
|
||||
_state.value = _state.value.copy(loading = true, error = false)
|
||||
repository.fetchHomeData()
|
||||
.onSuccess { data ->
|
||||
serverSections = normalizedHomepageSections(data.homepageSections)
|
||||
val sections = mergeWithUserSections(
|
||||
server = serverSections,
|
||||
user = layoutPreferences.getSections(),
|
||||
)
|
||||
seasonGamesCache.clear()
|
||||
data.selectedSpielplanSeason?.takeIf { it.isNotBlank() }?.let { season ->
|
||||
seasonGamesCache[season] = data.spiele
|
||||
}
|
||||
val widgetData = loadWidgetData(
|
||||
sections = sections,
|
||||
seasons = data.spielplanSeasons,
|
||||
)
|
||||
_state.value = HomeUiState(
|
||||
loading = false,
|
||||
termine = data.termine
|
||||
@@ -53,6 +94,11 @@ class HomeViewModel @Inject constructor(private val repository: HomeRepository)
|
||||
.sortedBy { it.asDate() }
|
||||
.take(3),
|
||||
news = data.news.take(3),
|
||||
homepageSections = sections,
|
||||
spielplanSeasons = widgetData.seasons,
|
||||
spielplanTeamsBySeason = widgetData.teamsBySeason,
|
||||
spielplanWidgetPreviews = widgetData.previewGamesBySectionKey,
|
||||
spielplanWidgetErrors = widgetData.errorsBySectionKey,
|
||||
)
|
||||
}
|
||||
.onFailure {
|
||||
@@ -60,8 +106,189 @@ class HomeViewModel @Inject constructor(private val repository: HomeRepository)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun moveSectionUp(sectionKey: String) {
|
||||
updateSections { sections ->
|
||||
val index = sections.indexOfFirst { sectionKey(it) == sectionKey }
|
||||
if (index <= 0) return@updateSections sections
|
||||
sections.toMutableList().also { list ->
|
||||
val current = list.removeAt(index)
|
||||
list.add(index - 1, current)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun moveSectionDown(sectionKey: String) {
|
||||
updateSections { sections ->
|
||||
val index = sections.indexOfFirst { sectionKey(it) == sectionKey }
|
||||
if (index < 0 || index >= sections.lastIndex) return@updateSections sections
|
||||
sections.toMutableList().also { list ->
|
||||
val current = list.removeAt(index)
|
||||
list.add(index + 1, current)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setSectionEnabled(sectionKey: String, enabled: Boolean) {
|
||||
updateSections { sections ->
|
||||
sections.map { section ->
|
||||
if (sectionKey(section) == sectionKey) section.copy(enabled = enabled) else section
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addSpielplanTeamWidget(season: String, teamName: String, teamAgeGroup: String) {
|
||||
val normalizedSeason = season.trim()
|
||||
val normalizedTeamName = teamName.trim()
|
||||
if (normalizedSeason.isBlank() || normalizedTeamName.isBlank()) return
|
||||
val newSection = HomepageSectionDto(
|
||||
id = WIDGET_SECTION_ID,
|
||||
key = "${WIDGET_SECTION_ID}_${UUID.randomUUID()}",
|
||||
enabled = true,
|
||||
config = HomepageSectionConfigDto(
|
||||
season = normalizedSeason,
|
||||
teamName = normalizedTeamName,
|
||||
teamAgeGroup = teamAgeGroup.trim(),
|
||||
),
|
||||
)
|
||||
updateSections { sections -> sections + newSection }
|
||||
refreshWidgetData()
|
||||
}
|
||||
|
||||
fun updateSpielplanTeamWidget(
|
||||
sectionKey: String,
|
||||
season: String,
|
||||
teamName: String,
|
||||
teamAgeGroup: String,
|
||||
) {
|
||||
updateSections { sections ->
|
||||
sections.map { section ->
|
||||
if (sectionKey(section) != sectionKey) return@map section
|
||||
section.copy(
|
||||
config = HomepageSectionConfigDto(
|
||||
season = season.trim(),
|
||||
teamName = teamName.trim(),
|
||||
teamAgeGroup = teamAgeGroup.trim(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
refreshWidgetData()
|
||||
}
|
||||
|
||||
fun resetSections() {
|
||||
val reset = serverSections
|
||||
layoutPreferences.clearSections()
|
||||
_state.value = _state.value.copy(homepageSections = reset)
|
||||
refreshWidgetData()
|
||||
}
|
||||
|
||||
private fun updateSections(transform: (List<HomepageSectionDto>) -> List<HomepageSectionDto>) {
|
||||
val updated = transform(_state.value.homepageSections)
|
||||
if (updated == _state.value.homepageSections) return
|
||||
layoutPreferences.setSections(updated)
|
||||
_state.value = _state.value.copy(homepageSections = updated)
|
||||
}
|
||||
|
||||
private fun refreshWidgetData() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(widgetsLoading = true)
|
||||
val widgetData = loadWidgetData(
|
||||
sections = _state.value.homepageSections,
|
||||
seasons = _state.value.spielplanSeasons,
|
||||
)
|
||||
_state.value = _state.value.copy(
|
||||
spielplanSeasons = widgetData.seasons,
|
||||
spielplanTeamsBySeason = widgetData.teamsBySeason,
|
||||
spielplanWidgetPreviews = widgetData.previewGamesBySectionKey,
|
||||
spielplanWidgetErrors = widgetData.errorsBySectionKey,
|
||||
widgetsLoading = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadWidgetData(
|
||||
sections: List<HomepageSectionDto>,
|
||||
seasons: List<SeasonDto>,
|
||||
): HomeWidgetData {
|
||||
val allSeasons = seasons
|
||||
.filter { it.slug.isNotBlank() }
|
||||
.distinctBy { it.slug }
|
||||
.toMutableList()
|
||||
seasonGamesCache.keys.forEach { slug ->
|
||||
if (allSeasons.none { it.slug == slug }) {
|
||||
allSeasons += SeasonDto(slug = slug, label = slug)
|
||||
}
|
||||
}
|
||||
val neededWidgetSeasons = sections
|
||||
.asSequence()
|
||||
.filter { it.id == WIDGET_SECTION_ID }
|
||||
.mapNotNull { it.config?.season?.takeIf(String::isNotBlank) }
|
||||
.toSet()
|
||||
|
||||
allSeasons.forEach { season ->
|
||||
ensureSeasonLoaded(season.slug)
|
||||
}
|
||||
neededWidgetSeasons.forEach { season ->
|
||||
if (allSeasons.none { it.slug == season }) {
|
||||
allSeasons += SeasonDto(slug = season, label = season)
|
||||
}
|
||||
ensureSeasonLoaded(season)
|
||||
}
|
||||
|
||||
val teamsBySeason = buildMap {
|
||||
allSeasons.forEach { season ->
|
||||
val games = seasonGamesCache[season.slug] ?: return@forEach
|
||||
put(season.slug, extractHarheimerTeams(games))
|
||||
}
|
||||
}
|
||||
|
||||
val previews = mutableMapOf<String, List<SpielDto>>()
|
||||
val errors = mutableMapOf<String, String>()
|
||||
|
||||
sections.forEach { section ->
|
||||
if (section.id != WIDGET_SECTION_ID) return@forEach
|
||||
val key = sectionKey(section)
|
||||
val config = section.config
|
||||
val season = config?.season.orEmpty()
|
||||
val teamName = config?.teamName.orEmpty()
|
||||
if (season.isBlank() || teamName.isBlank()) {
|
||||
errors[key] = "Bitte Saison und Mannschaft wählen."
|
||||
previews[key] = emptyList()
|
||||
return@forEach
|
||||
}
|
||||
val games = seasonGamesCache[season]
|
||||
if (games == null) {
|
||||
errors[key] = "Spielplan konnte nicht geladen werden."
|
||||
previews[key] = emptyList()
|
||||
return@forEach
|
||||
}
|
||||
previews[key] = filterUpcomingTeamGames(games, teamName, config?.teamAgeGroup.orEmpty())
|
||||
}
|
||||
|
||||
return HomeWidgetData(
|
||||
seasons = allSeasons,
|
||||
teamsBySeason = teamsBySeason,
|
||||
previewGamesBySectionKey = previews,
|
||||
errorsBySectionKey = errors,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun ensureSeasonLoaded(season: String) {
|
||||
if (seasonGamesCache.containsKey(season)) return
|
||||
repository.fetchSpielplanForSeason(season).onSuccess { response ->
|
||||
seasonGamesCache[season] = response.data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class HomeWidgetData(
|
||||
val seasons: List<SeasonDto>,
|
||||
val teamsBySeason: Map<String, List<HomeSpielplanTeamOption>>,
|
||||
val previewGamesBySectionKey: Map<String, List<SpielDto>>,
|
||||
val errorsBySectionKey: Map<String, String>,
|
||||
)
|
||||
|
||||
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"))
|
||||
@@ -70,3 +297,110 @@ private fun TerminDto.asDateTime(): LocalDateTime? = runCatching {
|
||||
fun SpielDto.asDate(): LocalDate? = runCatching {
|
||||
LocalDate.parse(termin.substringBefore(' '), DateTimeFormatter.ofPattern("dd.MM.yyyy"))
|
||||
}.getOrNull()
|
||||
|
||||
private val defaultHomepageSections = listOf(
|
||||
HomepageSectionDto(id = "banner", key = "banner", enabled = true),
|
||||
HomepageSectionDto(id = "aktuelles", key = "aktuelles", enabled = true),
|
||||
HomepageSectionDto(id = "termine", key = "termine", enabled = true),
|
||||
HomepageSectionDto(id = "spiele", key = "spiele", enabled = true),
|
||||
HomepageSectionDto(id = "kontakt", key = "kontakt", enabled = true),
|
||||
HomepageSectionDto(id = "training", key = "training", enabled = false),
|
||||
HomepageSectionDto(id = "links", key = "links", enabled = false),
|
||||
HomepageSectionDto(id = "vereinsmeisterschaften", key = "vereinsmeisterschaften", enabled = false),
|
||||
)
|
||||
|
||||
private fun normalizedHomepageSections(configuredSections: List<HomepageSectionDto>): List<HomepageSectionDto> {
|
||||
val configured = configuredSections
|
||||
.filter { it.id.isNotBlank() }
|
||||
.mapIndexed { index, section ->
|
||||
val fallback = if (section.id == WIDGET_SECTION_ID) "${section.id}_${index + 1}" else section.id
|
||||
section.copy(key = section.key?.takeIf { it.isNotBlank() } ?: fallback)
|
||||
}
|
||||
val knownIds = configured.map { it.id }.toMutableSet()
|
||||
return buildList {
|
||||
addAll(configured)
|
||||
defaultHomepageSections.forEach { section ->
|
||||
if (knownIds.add(section.id)) add(section)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun mergeWithUserSections(
|
||||
server: List<HomepageSectionDto>,
|
||||
user: List<HomepageSectionDto>?,
|
||||
): List<HomepageSectionDto> {
|
||||
if (user.isNullOrEmpty()) return server
|
||||
val serverById = server.associateBy { it.id }
|
||||
val serverByKey = server.associateBy { sectionKey(it) }
|
||||
val ordered = buildList<HomepageSectionDto> {
|
||||
user.forEach { userSection ->
|
||||
val matchedServerSection = serverByKey[sectionKey(userSection)]
|
||||
?: if (userSection.id == WIDGET_SECTION_ID) null else serverById[userSection.id]
|
||||
if (matchedServerSection != null) {
|
||||
if (none { sectionKey(it) == sectionKey(matchedServerSection) }) {
|
||||
add(
|
||||
matchedServerSection.copy(
|
||||
enabled = userSection.enabled,
|
||||
key = sectionKey(userSection),
|
||||
config = userSection.config,
|
||||
),
|
||||
)
|
||||
}
|
||||
return@forEach
|
||||
}
|
||||
if (userSection.id == WIDGET_SECTION_ID && none { sectionKey(it) == sectionKey(userSection) }) {
|
||||
add(
|
||||
userSection.copy(
|
||||
key = userSection.key?.takeIf { it.isNotBlank() }
|
||||
?: "${WIDGET_SECTION_ID}_${UUID.randomUUID()}",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
server.forEach { serverSection ->
|
||||
if (none { sectionKey(it) == sectionKey(serverSection) }) add(serverSection)
|
||||
}
|
||||
}
|
||||
return ordered.ifEmpty { server }
|
||||
}
|
||||
|
||||
private fun sectionKey(section: HomepageSectionDto): String =
|
||||
section.key?.takeIf { it.isNotBlank() } ?: section.id
|
||||
|
||||
private fun extractHarheimerTeams(games: List<SpielDto>): List<HomeSpielplanTeamOption> =
|
||||
games
|
||||
.flatMap { game ->
|
||||
listOf(
|
||||
HomeSpielplanTeamOption(game.heimMannschaft.trim(), game.heimAltersklasse.trim()),
|
||||
HomeSpielplanTeamOption(game.gastMannschaft.trim(), game.gastAltersklasse.trim()),
|
||||
)
|
||||
}
|
||||
.filter { option -> option.teamName.contains("Harheimer TC", ignoreCase = true) }
|
||||
.filter { option -> option.teamName.isNotBlank() }
|
||||
.distinctBy { "${it.teamName}|${it.teamAgeGroup}" }
|
||||
.sortedBy { it.label }
|
||||
|
||||
private fun filterUpcomingTeamGames(
|
||||
games: List<SpielDto>,
|
||||
teamName: String,
|
||||
teamAgeGroup: String,
|
||||
): List<SpielDto> {
|
||||
val normalizedTeam = teamName.trim()
|
||||
val normalizedAgeGroup = teamAgeGroup.trim()
|
||||
val today = LocalDate.now()
|
||||
return games
|
||||
.asSequence()
|
||||
.filter { game ->
|
||||
val homeMatch = game.heimMannschaft.trim() == normalizedTeam &&
|
||||
(normalizedAgeGroup.isBlank() || game.heimAltersklasse.trim() == normalizedAgeGroup)
|
||||
val awayMatch = game.gastMannschaft.trim() == normalizedTeam &&
|
||||
(normalizedAgeGroup.isBlank() || game.gastAltersklasse.trim() == normalizedAgeGroup)
|
||||
homeMatch || awayMatch
|
||||
}
|
||||
.filter { game -> game.asDate()?.let { !it.isBefore(today) } == true }
|
||||
.sortedBy { it.asDate() }
|
||||
.take(5)
|
||||
.toList()
|
||||
}
|
||||
|
||||
private const val WIDGET_SECTION_ID = "spielplan_team"
|
||||
|
||||
@@ -11,6 +11,8 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
@@ -20,8 +22,12 @@ 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.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import android.util.Log
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
@@ -45,13 +51,73 @@ fun MembersScreen(
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val query = state.query.trim()
|
||||
val members = state.members
|
||||
.filter { member ->
|
||||
var sortAsc by remember { mutableStateOf(true) }
|
||||
var sortField by remember { mutableStateOf("Nachname") }
|
||||
|
||||
val filtered = state.members.filter { member ->
|
||||
query.isBlank() ||
|
||||
member.name.contains(query, ignoreCase = true) ||
|
||||
member.email.orEmpty().contains(query, ignoreCase = true)
|
||||
}
|
||||
.sortedWith(compareBy<MemberDto> { it.lastName.ifBlank { it.name } }.thenBy { it.firstName })
|
||||
|
||||
// helpers
|
||||
fun dayMonthKey(m: MemberDto): Int {
|
||||
val src = m.geburtsdatum ?: m.birthday ?: return Int.MAX_VALUE
|
||||
val s = src.trim()
|
||||
try {
|
||||
if (s.matches(Regex("^\\d{4}[-./].*"))) {
|
||||
val ld = java.time.LocalDate.parse(s)
|
||||
return ld.monthValue * 100 + ld.dayOfMonth
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
// fallback: handle ISO without year (MM-DD or M-D), or German (DD.MM(.YYYY))
|
||||
val isoNoYear = Regex("^(\\d{1,2})[-/](\\d{1,2})$")
|
||||
val german = Regex("^(\\d{1,2})\\.(\\d{1,2})(?:\\.(\\d{2,4}))?$")
|
||||
isoNoYear.find(s)?.let {
|
||||
val (mo, d) = it.destructured
|
||||
return try { mo.toInt() * 100 + d.toInt() } catch (_: Exception) { Int.MAX_VALUE }
|
||||
}
|
||||
german.find(s)?.let {
|
||||
val (d, mo, _) = it.destructured
|
||||
return try { mo.toInt() * 100 + d.toInt() } catch (_: Exception) { Int.MAX_VALUE }
|
||||
}
|
||||
val r = Regex("(\\d{1,2})\\D+(\\d{1,2})")
|
||||
val match = r.find(s) ?: return Int.MAX_VALUE
|
||||
val (a, b) = match.destructured
|
||||
return try {
|
||||
if (a.toInt() > 12) b.toInt() * 100 + a.toInt() else a.toInt() * 100 + b.toInt()
|
||||
} catch (_: Exception) { Int.MAX_VALUE }
|
||||
}
|
||||
|
||||
fun ageKey(m: MemberDto): Int {
|
||||
val src = m.geburtsdatum ?: m.birthday ?: return Int.MAX_VALUE
|
||||
val s = src.trim()
|
||||
try {
|
||||
if (s.matches(Regex("^\\d{4}[-./].*"))) {
|
||||
val ld = java.time.LocalDate.parse(s)
|
||||
val current = java.time.LocalDate.now().year
|
||||
return current - ld.year
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
val germanYear = Regex("^(\\d{1,2})\\.(\\d{1,2})\\.(\\d{2,4})$")
|
||||
germanYear.find(s)?.let {
|
||||
val yearStr = it.groupValues[3]
|
||||
return try { java.time.LocalDate.now().year - yearStr.toInt() } catch (_: Exception) { Int.MAX_VALUE }
|
||||
}
|
||||
return Int.MAX_VALUE
|
||||
}
|
||||
|
||||
val members = when (sortField) {
|
||||
"Vorname" -> filtered.sortedWith(compareBy({ it.firstName.ifBlank { it.name } }, { it.lastName }))
|
||||
"Geburtstag" -> filtered.sortedWith(compareBy({ dayMonthKey(it) }, { it.lastName }))
|
||||
"Alter" -> filtered.sortedWith(compareBy({ ageKey(it) }, { it.lastName }))
|
||||
else -> filtered.sortedWith(compareBy<MemberDto> { it.lastName.ifBlank { it.name } }.thenBy { it.firstName })
|
||||
}.let { if (sortAsc) it else it.asReversed() }
|
||||
|
||||
var viewMode by remember { mutableStateOf("cards") }
|
||||
var onlyHallKey by remember { mutableStateOf(false) }
|
||||
val display = remember(members, onlyHallKey) { if (!onlyHallKey) members else members.filter { it.hasHallKey } }
|
||||
Log.i("MembersScreen", "viewMode=$viewMode displayCount=${display.size}")
|
||||
|
||||
MemberAreaPage(navController, showBackNavigation, "Mitgliederliste", "Kontaktdaten der Vereinsmitglieder") {
|
||||
item {
|
||||
@@ -63,11 +129,52 @@ fun MembersScreen(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
item {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Sortieren nach", color = Accent700)
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
TextButton(onClick = { expanded = true }) { Text(sortField) }
|
||||
androidx.compose.material3.DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
listOf("Nachname", "Vorname", "Geburtstag", "Alter").forEach { opt ->
|
||||
androidx.compose.material3.DropdownMenuItem(text = { Text(opt) }, onClick = { sortField = opt; expanded = false })
|
||||
}
|
||||
}
|
||||
TextButton(onClick = { sortAsc = !sortAsc }) { Text(if (sortAsc) "A→Z" else "Z→A") }
|
||||
}
|
||||
}
|
||||
item {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = { viewMode = if (viewMode == "cards") "table" else "cards" }, colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFF3F4F6))) {
|
||||
Text(if (viewMode == "cards") "Tabelle" else "Karten", color = Accent900)
|
||||
}
|
||||
androidx.compose.material3.Checkbox(checked = onlyHallKey, onCheckedChange = { onlyHallKey = it })
|
||||
Text("Nur mit Hallenschlüssel", color = Accent700)
|
||||
}
|
||||
}
|
||||
item {
|
||||
if (viewMode == "table") {
|
||||
Text("DEBUG: TABLE", color = Color.Red, modifier = Modifier.fillMaxWidth().padding(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
state.loading -> item { CircularProgressIndicator(color = Primary600) }
|
||||
state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) }
|
||||
members.isEmpty() -> item { Text("Keine Mitglieder gefunden.", color = Accent700) }
|
||||
else -> items(members.size) { index -> MemberCard(members[index]) }
|
||||
display.isEmpty() -> item { Text("Keine Mitglieder gefunden.", color = Accent700) }
|
||||
else -> if (viewMode == "table") {
|
||||
items(display.size) { index ->
|
||||
val m = display[index]
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(6.dp)) {
|
||||
Row(Modifier.fillMaxWidth().padding(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Column(Modifier.weight(1f)) { Text(m.name, color = Accent900) }
|
||||
Column(Modifier.weight(1f)) { Text(m.email ?: "-", color = Primary600) }
|
||||
Column(Modifier.weight(1f)) { Text(m.phone ?: "-", color = Accent700) }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items(display.size) { index -> MemberCard(display[index]) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,7 +248,7 @@ private fun MemberAreaPage(
|
||||
Text("< Intern", color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
Text(title, style = MaterialTheme.typography.displayLarge, color = Accent900)
|
||||
Text(title, style = MaterialTheme.typography.headlineMedium, color = Accent900)
|
||||
Text(subtitle, color = Accent500, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
content()
|
||||
@@ -149,13 +256,16 @@ private fun MemberAreaPage(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MemberCard(member: MemberDto) {
|
||||
private fun MemberCard(member: MemberDto, onEdit: (MemberDto) -> Unit = {}, onDelete: (MemberDto) -> Unit = {}) {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
Text(member.name.ifBlank { "${member.firstName} ${member.lastName}".trim() }, style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
if (!member.email.isNullOrBlank()) Text(member.email, color = Primary600)
|
||||
if (!member.phone.isNullOrBlank()) Text(member.phone, color = Accent700)
|
||||
if (!member.birthday.isNullOrBlank()) Text("Geburtstag: ${member.birthday}", color = Accent500)
|
||||
if (!member.birthday.isNullOrBlank()) {
|
||||
val display = formatDayMonth(member.birthday) ?: member.birthday
|
||||
Text("Geburtstag: $display", color = Accent500)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Badge(if (member.hasLogin) "Login" else member.source.ifBlank { "Mitglied" })
|
||||
if (member.isMannschaftsspieler) Badge("Mannschaft")
|
||||
@@ -164,6 +274,12 @@ private fun MemberCard(member: MemberDto) {
|
||||
if (member.email.isNullOrBlank() && member.phone.isNullOrBlank()) {
|
||||
Text("Kontaktdaten sind für dich nicht freigegeben.", color = Accent500)
|
||||
}
|
||||
Row {
|
||||
if (member.editable) {
|
||||
TextButton(onClick = { onEdit(member) }) { Text("Bearbeiten") }
|
||||
TextButton(onClick = { onDelete(member) }) { Text("Löschen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,3 +317,48 @@ private fun ErrorCard(message: String, onRetry: () -> Unit) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeToIso(srcRaw: String?): String? {
|
||||
val src = srcRaw?.trim() ?: return null
|
||||
try {
|
||||
if (src.matches(Regex("^\\d{4}[-./].*"))) {
|
||||
val ld = java.time.LocalDate.parse(src)
|
||||
return String.format("%04d-%02d-%02d", ld.year, ld.monthValue, ld.dayOfMonth)
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
val isoNoYear = Regex("^(\\d{1,2})[-/](\\d{1,2})$")
|
||||
isoNoYear.find(src)?.let {
|
||||
val (mo, d) = it.destructured
|
||||
return String.format("%02d-%02d", mo.toInt(), d.toInt())
|
||||
}
|
||||
val german = Regex("^(\\d{1,2})\\.(\\d{1,2})(?:\\.(\\d{2,4}))?$")
|
||||
german.find(src)?.let {
|
||||
val (d, mo, y) = it.destructured
|
||||
return if (y.isNullOrBlank()) String.format("%02d-%02d", mo.toInt(), d.toInt()) else String.format("%04d-%02d-%02d", y.toInt(), mo.toInt(), d.toInt())
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun formatDayMonth(srcRaw: String?): String? {
|
||||
val src = srcRaw?.trim() ?: return null
|
||||
try {
|
||||
if (src.matches(Regex("^\\d{4}[-./].*"))) {
|
||||
val ld = java.time.LocalDate.parse(src)
|
||||
return String.format("%02d.%02d.", ld.dayOfMonth, ld.monthValue)
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
val isoNoYear = Regex("^(\\d{1,2})[-/](\\d{1,2})$")
|
||||
isoNoYear.find(src)?.let {
|
||||
val (mo, d) = it.destructured
|
||||
return try { String.format("%02d.%02d.", d.toInt(), mo.toInt()) } catch (_: Exception) { null }
|
||||
}
|
||||
val german = Regex("^(\\d{1,2})\\.(\\d{1,2})(?:\\.(\\d{2,4}))?$")
|
||||
german.find(src)?.let {
|
||||
val (d, mo, _) = it.destructured
|
||||
return try { String.format("%02d.%02d.", d.toInt(), mo.toInt()) } catch (_: Exception) { null }
|
||||
}
|
||||
val r = Regex("(\\d{1,2})\\D+(\\d{1,2})")
|
||||
val match = r.find(src) ?: return null
|
||||
val (a, b) = match.destructured
|
||||
return try { String.format("%02d.%02d.", a.toInt(), b.toInt()) } catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
@@ -41,6 +41,38 @@ class MembersViewModel @Inject constructor(
|
||||
.onFailure { _state.value = _state.value.copy(loading = false, error = it.message ?: "Mitglieder konnten nicht geladen werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun saveMember(request: de.harheimertc.data.ApiService.MemberSaveRequest) {
|
||||
viewModelScope.launch {
|
||||
repository.saveMember(request)
|
||||
.onSuccess { _ -> load() }
|
||||
.onFailure { /* expose errors if needed */ }
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteMember(id: String) {
|
||||
viewModelScope.launch {
|
||||
repository.deleteMember(id)
|
||||
.onSuccess { _ -> load() }
|
||||
.onFailure { /* handle error */ }
|
||||
}
|
||||
}
|
||||
|
||||
fun bulkImport(members: List<Map<String, String>>) {
|
||||
viewModelScope.launch {
|
||||
repository.bulkImport(members)
|
||||
.onSuccess { _ -> load() }
|
||||
.onFailure { /* handle error */ }
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleMannschaftsspieler(memberId: String) {
|
||||
viewModelScope.launch {
|
||||
repository.toggleMannschaftsspieler(memberId)
|
||||
.onSuccess { _ -> load() }
|
||||
.onFailure { /* handle error */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class MemberNewsUiState(
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package de.harheimertc.ui.util
|
||||
|
||||
import java.net.UnknownHostException
|
||||
|
||||
object ErrorMapper {
|
||||
fun mapError(t: Throwable?): String? {
|
||||
if (t == null) return null
|
||||
return when (t) {
|
||||
is UnknownHostException -> "Server nicht erreichbar. Prüfe Netzwerkverbindung."
|
||||
else -> {
|
||||
val msg = t.message
|
||||
when {
|
||||
msg == null -> "Unbekannter Fehler"
|
||||
msg.contains("401") || msg.contains("Unauthorized", ignoreCase = true) -> "Nicht autorisiert"
|
||||
msg.contains("timeout", ignoreCase = true) -> "Zeitüberschreitung beim Server"
|
||||
else -> msg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
android-app/app/src/main/res/drawable/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
BIN
android-app/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
android-app/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
android-app/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
android-app/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
android-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
16
android-app/app/src/main/res/values-en/strings.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<resources>
|
||||
<string name="app_name">Harheimer TC</string>
|
||||
<string name="gallery_title">Bildergalerie</string>
|
||||
<string name="gallery_empty">Noch keine Bilder in der Galerie.</string>
|
||||
<string name="gallery_upload_title">Bild hochladen</string>
|
||||
<string name="gallery_upload_show">Öffnen</string>
|
||||
<string name="gallery_upload_hide">Schließen</string>
|
||||
<string name="gallery_upload_choose_file">Bilddatei auswählen</string>
|
||||
<string name="gallery_upload_image_title">Titel</string>
|
||||
<string name="gallery_upload_description">Beschreibung (optional)</string>
|
||||
<string name="gallery_upload_public">Öffentlich sichtbar</string>
|
||||
<string name="gallery_upload_submit">Bild hochladen</string>
|
||||
<string name="gallery_uploading">Wird hochgeladen...</string>
|
||||
<string name="gallery_close_image">Bild schließen</string>
|
||||
<string name="gallery_image_description">Galeriebild: %1$s</string>
|
||||
</resources>
|
||||
@@ -3,4 +3,5 @@
|
||||
<color name="primary_500">#ef4444</color>
|
||||
<color name="primary_600">#dc2626</color>
|
||||
<color name="accent_500">#71717a</color>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
16
android-app/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<resources>
|
||||
<string name="app_name">Harheimer TC</string>
|
||||
<string name="gallery_title">Bildergalerie</string>
|
||||
<string name="gallery_empty">Noch keine Bilder in der Galerie.</string>
|
||||
<string name="gallery_upload_title">Bild hochladen</string>
|
||||
<string name="gallery_upload_show">Öffnen</string>
|
||||
<string name="gallery_upload_hide">Schließen</string>
|
||||
<string name="gallery_upload_choose_file">Bilddatei auswählen</string>
|
||||
<string name="gallery_upload_image_title">Titel</string>
|
||||
<string name="gallery_upload_description">Beschreibung (optional)</string>
|
||||
<string name="gallery_upload_public">Öffentlich sichtbar</string>
|
||||
<string name="gallery_upload_submit">Bild hochladen</string>
|
||||
<string name="gallery_uploading">Wird hochgeladen...</string>
|
||||
<string name="gallery_close_image">Bild schließen</string>
|
||||
<string name="gallery_image_description">Galeriebild: %1$s</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,30 @@
|
||||
package de.harheimertc.ui.components
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class FormComponentsTest {
|
||||
@Test
|
||||
fun isValidEmailAcceptsCommonAddresses() {
|
||||
assertTrue(isValidEmail("mitglied@example.de"))
|
||||
assertTrue(isValidEmail(" vorstand.name+test@harheimertc.de "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isValidEmailRejectsInvalidAddresses() {
|
||||
assertFalse(isValidEmail(""))
|
||||
assertFalse(isValidEmail("mitglied"))
|
||||
assertFalse(isValidEmail("mitglied@example"))
|
||||
assertFalse(isValidEmail("mitglied @example.de"))
|
||||
assertFalse(isValidEmail("mitglied@@example.de"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isValidIsoDateRequiresIsoShape() {
|
||||
assertTrue(isValidIsoDate("2026-05-28"))
|
||||
assertFalse(isValidIsoDate("28.05.2026"))
|
||||
assertFalse(isValidIsoDate("2026-5-28"))
|
||||
assertFalse(isValidIsoDate(""))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package de.harheimertc.ui.components
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.Assert.assertEquals
|
||||
|
||||
class RichTextUtilsTest {
|
||||
@Test
|
||||
fun stripHtml_removesTagsAndEntities() {
|
||||
val html = "<p><strong>Hallo</strong> Welt</p>"
|
||||
val stripped = stripHtml(html)
|
||||
assertEquals("Hallo Welt", stripped)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun normalizeEmptyHtml_returnsEmptyForBlankContent() {
|
||||
val html = "<p><br></p>"
|
||||
val normalized = normalizeEmptyHtml(html)
|
||||
assertEquals("", normalized)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun escapeHtml_escapesSpecialChars() {
|
||||
val raw = "https://example.com/?q=1&name=\"x\""
|
||||
val escaped = escapeHtml(raw)
|
||||
assertEquals("https://example.com/?q=1&name="x"", escaped)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class CmsViewModelTest {
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(dispatcher)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun load_populatesState() = runTest {
|
||||
val repo = mockk<de.harheimertc.repositories.CmsRepository>()
|
||||
val config = de.harheimertc.data.ConfigResponse(website = de.harheimertc.data.WebsiteDto(verantwortlicher = de.harheimertc.data.WebsiteResponsibleDto(vorname = "TestFirst", nachname = "TestLast", email = "a@b")))
|
||||
val users = de.harheimertc.data.CmsUsersResponse(listOf(de.harheimertc.data.CmsUserDto(id = "1", email = "u@e", name = "U")))
|
||||
coEvery { repo.config() } returns Result.success(config)
|
||||
coEvery { repo.users() } returns Result.success(users)
|
||||
coEvery { repo.contactRequests() } returns Result.success(emptyList())
|
||||
coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse())
|
||||
coEvery { repo.newsletterGroups() } returns Result.success(de.harheimertc.data.NewsletterGroupsResponse())
|
||||
coEvery { repo.news() } returns Result.success(de.harheimertc.data.NewsResponse(success = true, news = listOf(de.harheimertc.data.NewsDto(id = 5, title = "T", content = "C"))))
|
||||
coEvery { repo.passwordResetDiagnostics(any(), any()) } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
|
||||
coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList())
|
||||
|
||||
val vm = CmsViewModel(repo)
|
||||
// advance init launched coroutine
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
val state = vm.state.value
|
||||
assertEquals(false, state.loading)
|
||||
assertEquals("TestFirst", state.config?.website?.verantwortlicher?.vorname)
|
||||
assertEquals(1, state.users.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveConfig_success_updatesState() = runTest {
|
||||
val repo = mockk<de.harheimertc.repositories.CmsRepository>()
|
||||
val cfg = de.harheimertc.data.ConfigResponse(website = de.harheimertc.data.WebsiteDto(verantwortlicher = de.harheimertc.data.WebsiteResponsibleDto(vorname = "X")))
|
||||
// stub load() calls during ViewModel init
|
||||
coEvery { repo.config() } returns Result.success(cfg)
|
||||
coEvery { repo.users() } returns Result.success(de.harheimertc.data.CmsUsersResponse())
|
||||
coEvery { repo.contactRequests() } returns Result.success(emptyList())
|
||||
coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse())
|
||||
coEvery { repo.newsletterGroups() } returns Result.success(de.harheimertc.data.NewsletterGroupsResponse())
|
||||
coEvery { repo.news() } returns Result.success(de.harheimertc.data.NewsResponse(success = true, news = listOf()))
|
||||
coEvery { repo.passwordResetDiagnostics(any(), any()) } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
|
||||
coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList())
|
||||
coEvery { repo.saveConfig(any()) } returns Result.success(cfg)
|
||||
coEvery { repo.saveNews(any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "ok"))
|
||||
val vm = CmsViewModel(repo)
|
||||
|
||||
// wait for init/load to finish before saving to avoid race
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
vm.saveConfig(cfg)
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
val state = vm.state.value
|
||||
assertEquals(false, state.saving)
|
||||
assertEquals("Inhalt gespeichert.", state.message)
|
||||
assertEquals("X", state.config?.website?.verantwortlicher?.vorname)
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveNews_success_updatesState() = runTest {
|
||||
val repo = mockk<de.harheimertc.repositories.CmsRepository>()
|
||||
val cfg = de.harheimertc.data.ConfigResponse(website = de.harheimertc.data.WebsiteDto(verantwortlicher = de.harheimertc.data.WebsiteResponsibleDto(vorname = "X")))
|
||||
coEvery { repo.config() } returns Result.success(cfg)
|
||||
coEvery { repo.users() } returns Result.success(de.harheimertc.data.CmsUsersResponse())
|
||||
coEvery { repo.contactRequests() } returns Result.success(emptyList())
|
||||
coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse())
|
||||
coEvery { repo.newsletterGroups() } returns Result.success(de.harheimertc.data.NewsletterGroupsResponse())
|
||||
coEvery { repo.news() } returns Result.success(de.harheimertc.data.NewsResponse(success = true, news = listOf()))
|
||||
coEvery { repo.passwordResetDiagnostics(any(), any()) } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
|
||||
coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList())
|
||||
coEvery { repo.saveNews(any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "saved"))
|
||||
|
||||
val vm = CmsViewModel(repo)
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
vm.saveNews(de.harheimertc.data.NewsSaveRequest(id = null, title = "t", content = "c"))
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
val state = vm.state.value
|
||||
assertEquals(false, state.saving)
|
||||
assertEquals("saved", state.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateUserRoles_updatesUsersAndMessage() = runTest {
|
||||
val repo = mockk<de.harheimertc.repositories.CmsRepository>()
|
||||
val cfg = de.harheimertc.data.ConfigResponse(website = de.harheimertc.data.WebsiteDto(verantwortlicher = de.harheimertc.data.WebsiteResponsibleDto(vorname = "X")))
|
||||
coEvery { repo.config() } returns Result.success(cfg)
|
||||
coEvery { repo.users() } returns Result.success(de.harheimertc.data.CmsUsersResponse(listOf(de.harheimertc.data.CmsUserDto(id = "1", email = "u@e", name = "U", roles = listOf("mitglied")))))
|
||||
coEvery { repo.contactRequests() } returns Result.success(emptyList())
|
||||
coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse())
|
||||
coEvery { repo.newsletterGroups() } returns Result.success(de.harheimertc.data.NewsletterGroupsResponse())
|
||||
coEvery { repo.news() } returns Result.success(de.harheimertc.data.NewsResponse(success = true, news = listOf()))
|
||||
coEvery { repo.passwordResetDiagnostics(any(), any()) } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
|
||||
coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList())
|
||||
|
||||
coEvery { repo.updateUserRoles(any(), any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "roles updated"))
|
||||
coEvery { repo.users() } returns Result.success(de.harheimertc.data.CmsUsersResponse(listOf(de.harheimertc.data.CmsUserDto(id = "1", email = "u@e", name = "U", roles = listOf("admin", "vorstand")))))
|
||||
|
||||
val vm = CmsViewModel(repo)
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
vm.updateUserRoles("1", listOf("admin", "vorstand"))
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
val state = vm.state.value
|
||||
assertEquals(false, state.saving)
|
||||
assertEquals("roles updated", state.message)
|
||||
assertEquals(listOf("admin", "vorstand"), state.users.first().roles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setUserActive_updatesUsersAndMessage() = runTest {
|
||||
val repo = mockk<de.harheimertc.repositories.CmsRepository>()
|
||||
val cfg = de.harheimertc.data.ConfigResponse(website = de.harheimertc.data.WebsiteDto(verantwortlicher = de.harheimertc.data.WebsiteResponsibleDto(vorname = "X")))
|
||||
coEvery { repo.config() } returns Result.success(cfg)
|
||||
coEvery { repo.users() } returns Result.success(de.harheimertc.data.CmsUsersResponse(listOf(de.harheimertc.data.CmsUserDto(id = "2", email = "v@e", name = "V", active = true))))
|
||||
coEvery { repo.contactRequests() } returns Result.success(emptyList())
|
||||
coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse())
|
||||
coEvery { repo.newsletterGroups() } returns Result.success(de.harheimertc.data.NewsletterGroupsResponse())
|
||||
coEvery { repo.news() } returns Result.success(de.harheimertc.data.NewsResponse(success = true, news = listOf()))
|
||||
coEvery { repo.passwordResetDiagnostics(any(), any()) } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
|
||||
coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList())
|
||||
|
||||
coEvery { repo.updateUserActive(any(), any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "user updated"))
|
||||
coEvery { repo.users() } returns Result.success(de.harheimertc.data.CmsUsersResponse(listOf(de.harheimertc.data.CmsUserDto(id = "2", email = "v@e", name = "V", active = false))))
|
||||
|
||||
val vm = CmsViewModel(repo)
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
vm.setUserActive("2", false)
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
val state = vm.state.value
|
||||
assertEquals(false, state.saving)
|
||||
assertEquals("user updated", state.message)
|
||||
assertEquals(false, state.users.first().active)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resendInvite_setsMessageOnSuccess() = runTest {
|
||||
val repo = mockk<de.harheimertc.repositories.CmsRepository>()
|
||||
val cfg = de.harheimertc.data.ConfigResponse(website = de.harheimertc.data.WebsiteDto(verantwortlicher = de.harheimertc.data.WebsiteResponsibleDto(vorname = "X")))
|
||||
coEvery { repo.config() } returns Result.success(cfg)
|
||||
coEvery { repo.users() } returns Result.success(de.harheimertc.data.CmsUsersResponse())
|
||||
coEvery { repo.contactRequests() } returns Result.success(emptyList())
|
||||
coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse())
|
||||
coEvery { repo.newsletterGroups() } returns Result.success(de.harheimertc.data.NewsletterGroupsResponse())
|
||||
coEvery { repo.news() } returns Result.success(de.harheimertc.data.NewsResponse(success = true, news = listOf()))
|
||||
coEvery { repo.passwordResetDiagnostics(any(), any()) } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
|
||||
coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList())
|
||||
|
||||
coEvery { repo.resendInvite(any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "invite sent"))
|
||||
|
||||
val vm = CmsViewModel(repo)
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
vm.resendInvite("10")
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
val state = vm.state.value
|
||||
assertEquals(false, state.saving)
|
||||
assertEquals("invite sent", state.message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package de.harheimertc.ui.screens.gallery
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import android.net.Uri
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class GalleryViewModelTest {
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(dispatcher)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun load_setsImagesAndCanUpload() = runTest {
|
||||
val galleryRepo = mockk<de.harheimertc.repositories.GalleryRepository>()
|
||||
val loginRepo = mockk<de.harheimertc.repositories.LoginRepository>()
|
||||
val page = de.harheimertc.repositories.GalleryPage(images = listOf(de.harheimertc.repositories.GalleryImage(id = "1", title = "T", description = "D", isPublic = true, uploadedAt = null, previewUrl = "p", imageUrl = "u")), pagination = de.harheimertc.data.GalleryPaginationDto())
|
||||
coEvery { galleryRepo.fetchImages() } returns Result.success(page)
|
||||
coEvery { loginRepo.status() } returns Result.success(de.harheimertc.data.AuthStatusResponse(isLoggedIn = true, roles = listOf("admin")))
|
||||
|
||||
val vm = GalleryViewModel(galleryRepo, loginRepo)
|
||||
vm.load()
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
assertEquals(1, vm.images.value.size)
|
||||
assertEquals(true, vm.canUpload.value)
|
||||
assertEquals(false, vm.loading.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun upload_callsUploadAndReloads() = runTest {
|
||||
val galleryRepo = mockk<de.harheimertc.repositories.GalleryRepository>()
|
||||
val loginRepo = mockk<de.harheimertc.repositories.LoginRepository>()
|
||||
coEvery { galleryRepo.uploadImage(any(), any(), any(), any()) } returns Result.success(Unit)
|
||||
coEvery { galleryRepo.fetchImages() } returns Result.success(de.harheimertc.repositories.GalleryPage(images = emptyList(), pagination = de.harheimertc.data.GalleryPaginationDto()))
|
||||
coEvery { loginRepo.status() } returns Result.success(de.harheimertc.data.AuthStatusResponse())
|
||||
|
||||
val vm = GalleryViewModel(galleryRepo, loginRepo)
|
||||
val testUri = mockk<Uri>()
|
||||
vm.upload(testUri, "t", "d", true)
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
assertEquals(false, vm.uploading.value)
|
||||
assertEquals("Bild erfolgreich hochgeladen.", vm.message.value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package de.harheimertc.ui.screens.login
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import com.squareup.moshi.Moshi
|
||||
import retrofit2.Response
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
// Fake implementations to avoid network/Hilt in unit tests
|
||||
// Simple stubs to avoid importing the real repositories and Hilt wiring in tests
|
||||
// Use mockk to mock the repositories directly for unit testing
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class LoginViewModelTest {
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(dispatcher)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun login_showsFieldErrors_whenInvalidInput() = runTest {
|
||||
val repo = mockk<de.harheimertc.repositories.LoginRepository>()
|
||||
val passkeyRepo = mockk<de.harheimertc.repositories.PasskeyRepository>()
|
||||
coEvery { repo.status() } returns Result.success(de.harheimertc.data.AuthStatusResponse())
|
||||
val vm = LoginViewModel(repo, passkeyRepo)
|
||||
|
||||
vm.setEmail("invalid-email")
|
||||
vm.setPassword("")
|
||||
vm.login()
|
||||
|
||||
val state = vm.state.value
|
||||
assertEquals(true, state.fieldErrors.containsKey("email"))
|
||||
assertEquals(true, state.fieldErrors.containsKey("password"))
|
||||
assertEquals("Bitte prüfen Sie die markierten Felder.", state.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun login_success_updatesState() = runTest {
|
||||
val loginResp = de.harheimertc.data.LoginResponse(user = de.harheimertc.data.AuthUserDto(name = "Max Mustermann", email = "max@ex.de", roles = listOf("user")))
|
||||
val repo = mockk<de.harheimertc.repositories.LoginRepository>()
|
||||
val passkeyRepo = mockk<de.harheimertc.repositories.PasskeyRepository>()
|
||||
coEvery { repo.login(any(), any()) } returns Result.success(loginResp)
|
||||
coEvery { repo.status() } returns Result.success(de.harheimertc.data.AuthStatusResponse())
|
||||
val vm = LoginViewModel(repo, passkeyRepo)
|
||||
|
||||
vm.setEmail("max@ex.de")
|
||||
vm.setPassword("secret")
|
||||
vm.login()
|
||||
|
||||
// advance until coroutines complete
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
val state = vm.state.value
|
||||
assertEquals(true, state.loggedIn)
|
||||
assertEquals("Max Mustermann", state.userName)
|
||||
assertEquals(false, state.loading)
|
||||
assertEquals("Anmeldung erfolgreich.", state.message)
|
||||
}
|
||||
}
|
||||
BIN
android-app/device_screenshot.png
Normal file
|
After Width: | Height: | Size: 340 KiB |
BIN
android-app/device_screenshot_after_back.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
@@ -1,3 +1,24 @@
|
||||
# Using AGP 9.2.1 defaults
|
||||
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8
|
||||
org.gradle.workers.max=2
|
||||
# Local API base URL for running the app from Android Studio / Gradle
|
||||
LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/
|
||||
|
||||
# Production backend for Play Store build variant
|
||||
PRODUCTION_API_BASE_URL=https://harheimertc.de/
|
||||
|
||||
# Android app versioning for Play Store uploads
|
||||
ANDROID_VERSION_CODE=4
|
||||
ANDROID_VERSION_NAME=1.0.0
|
||||
|
||||
# Enable R8 for release by default so mapping.txt is generated for Play Console.
|
||||
RELEASE_MINIFY_ENABLED=true
|
||||
|
||||
# Release signing (set in local, untracked gradle.properties or via CI secrets)
|
||||
# RELEASE_STORE_FILE=/absolute/path/to/keystore.jks
|
||||
# RELEASE_STORE_PASSWORD=***
|
||||
# RELEASE_KEY_ALIAS=***
|
||||
# RELEASE_KEY_PASSWORD=***
|
||||
|
||||
# Build and collect Play Store upload artifacts:
|
||||
# ./gradlew :app:collectPlayStoreArtifacts
|
||||
|
||||
BIN
android-app/member_screen.png
Normal file
|
After Width: | Height: | Size: 310 KiB |
|
After Width: | Height: | Size: 58 KiB |
BIN
android-app/playstore-assets/generated/playstore-icon-512.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
1
android-app/window_dump_emulator_app.xml
Normal file
1
android-app/window_dump_tablet.xml
Normal file
1
android-app/window_dump_tablet_app.xml
Normal file
@@ -19,6 +19,18 @@
|
||||
>
|
||||
Impressum
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/datenschutz"
|
||||
class="text-gray-400 hover:text-primary-400 transition-colors"
|
||||
>
|
||||
Datenschutz
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/konto-loeschen"
|
||||
class="text-gray-400 hover:text-primary-400 transition-colors"
|
||||
>
|
||||
Konto loeschen
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/kontakt"
|
||||
class="text-gray-400 hover:text-primary-400 transition-colors"
|
||||
|
||||
20
components/HomeLinksTeaser.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<section class="py-16 bg-white">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="bg-gray-50 rounded-xl shadow-sm p-8 md:p-10 border border-gray-200">
|
||||
<h2 class="text-2xl md:text-3xl font-display font-bold text-gray-900 mb-3">
|
||||
Nützliche Links
|
||||
</h2>
|
||||
<p class="text-gray-600 mb-6 max-w-3xl">
|
||||
Direkter Zugang zu Verbänden, Ergebnisdiensten und weiteren hilfreichen Portalen.
|
||||
</p>
|
||||
<NuxtLink
|
||||
to="/links"
|
||||
class="inline-flex items-center px-6 py-3 rounded-lg border border-primary-600 text-primary-700 hover:bg-primary-50 font-semibold transition-colors"
|
||||
>
|
||||
Links öffnen
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
187
components/HomeSpielplanTeamWidget.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<section class="py-16 bg-white">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="bg-gray-50 rounded-xl border border-gray-200 p-6 md:p-8">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 mb-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-900">
|
||||
Spielplan: {{ widgetTitle }}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 mt-1">
|
||||
Saison {{ seasonLabel }}
|
||||
</p>
|
||||
</div>
|
||||
<NuxtLink
|
||||
to="/spielplan"
|
||||
class="inline-flex items-center px-4 py-2 rounded-lg bg-primary-600 hover:bg-primary-700 text-white text-sm font-semibold transition-colors"
|
||||
>
|
||||
Voller Spielplan
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="text-sm text-gray-600"
|
||||
>
|
||||
Spiele werden geladen...
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="text-sm text-red-700"
|
||||
>
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="upcomingGames.length === 0"
|
||||
class="text-sm text-gray-600"
|
||||
>
|
||||
Keine kommenden Spiele für diese Mannschaft gefunden.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="space-y-3"
|
||||
>
|
||||
<div
|
||||
v-for="game in upcomingGames"
|
||||
:key="`${game.Termin}-${game.HeimMannschaft}-${game.GastMannschaft}`"
|
||||
class="bg-white rounded-lg border border-gray-200 p-4"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 mb-2">
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
{{ formatDate(game.Termin) }} {{ formatTime(game.Termin) }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
{{ game.Runde || '-' }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-sm text-gray-800">
|
||||
{{ game.HeimMannschaft }} vs {{ game.GastMannschaft }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
season: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
teamName: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
teamAgeGroup: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const isLoading = ref(false)
|
||||
const error = ref('')
|
||||
const games = ref([])
|
||||
|
||||
const widgetTitle = computed(() => {
|
||||
if (!props.teamName) return 'Mannschaft'
|
||||
const youth = String(props.teamAgeGroup || '').toLowerCase().includes('jugend')
|
||||
return youth ? `(J) ${props.teamName}` : props.teamName
|
||||
})
|
||||
|
||||
const seasonLabel = computed(() => {
|
||||
const match = String(props.season || '').match(/^(\d{2})--(\d{2})$/)
|
||||
if (!match) return props.season || '-'
|
||||
return `20${match[1]}/${match[2]}`
|
||||
})
|
||||
|
||||
const upcomingGames = computed(() => {
|
||||
const now = new Date()
|
||||
now.setHours(0, 0, 0, 0)
|
||||
return games.value
|
||||
.filter(game => {
|
||||
const gameDate = parseDate(game.Termin)
|
||||
return gameDate && gameDate >= now
|
||||
})
|
||||
.sort((a, b) => parseDate(a.Termin) - parseDate(b.Termin))
|
||||
.slice(0, 5)
|
||||
})
|
||||
|
||||
function parseDate(termin) {
|
||||
const raw = String(termin || '').trim()
|
||||
const datePart = raw.split(' ')[0]
|
||||
const [day, month, year] = datePart.split('.')
|
||||
if (!day || !month || !year) return null
|
||||
const parsed = new Date(Number(year), Number(month) - 1, Number(day))
|
||||
if (Number.isNaN(parsed.getTime())) return null
|
||||
parsed.setHours(0, 0, 0, 0)
|
||||
return parsed
|
||||
}
|
||||
|
||||
function formatDate(termin) {
|
||||
const parsed = parseDate(termin)
|
||||
if (!parsed) return termin || '-'
|
||||
return parsed.toLocaleDateString('de-DE', {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
function formatTime(termin) {
|
||||
const raw = String(termin || '')
|
||||
const timePart = raw.split(' ')[1]
|
||||
return timePart || ''
|
||||
}
|
||||
|
||||
function isConfiguredTeamMatch(game) {
|
||||
const teamName = String(props.teamName || '').trim()
|
||||
const teamAgeGroup = String(props.teamAgeGroup || '').trim()
|
||||
if (!teamName) return false
|
||||
|
||||
const homeMatch = String(game.HeimMannschaft || '').trim() === teamName &&
|
||||
(!teamAgeGroup || String(game.HeimMannschaftAltersklasse || '').trim() === teamAgeGroup)
|
||||
const awayMatch = String(game.GastMannschaft || '').trim() === teamName &&
|
||||
(!teamAgeGroup || String(game.GastMannschaftAltersklasse || '').trim() === teamAgeGroup)
|
||||
return homeMatch || awayMatch
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
if (!props.teamName || !props.season) {
|
||||
games.value = []
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const result = await $fetch('/api/spielplan', {
|
||||
query: { season: props.season }
|
||||
})
|
||||
if (!result?.success) {
|
||||
throw new Error(result?.message || 'Spielplan konnte nicht geladen werden.')
|
||||
}
|
||||
games.value = (result.data || []).filter(isConfiguredTeamMatch)
|
||||
} catch (err) {
|
||||
games.value = []
|
||||
error.value = err?.data?.message || err?.message || 'Spielplan konnte nicht geladen werden.'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.season, props.teamName, props.teamAgeGroup],
|
||||
() => {
|
||||
loadData()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
20
components/HomeTrainingTeaser.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<section class="py-16 bg-gray-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="bg-white rounded-xl shadow-lg p-8 md:p-10 border border-gray-100">
|
||||
<h2 class="text-2xl md:text-3xl font-display font-bold text-gray-900 mb-3">
|
||||
Training & Einstieg
|
||||
</h2>
|
||||
<p class="text-gray-600 mb-6 max-w-3xl">
|
||||
Alle Infos zu Trainingszeiten, Trainern und unserem Anfängerangebot auf einen Blick.
|
||||
</p>
|
||||
<NuxtLink
|
||||
to="/training"
|
||||
class="inline-flex items-center px-6 py-3 rounded-lg bg-primary-600 hover:bg-primary-700 text-white font-semibold transition-colors"
|
||||
>
|
||||
Zum Training
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
20
components/HomeVereinsmeisterschaftenTeaser.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<section class="py-16 bg-gray-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="bg-white rounded-xl shadow-lg p-8 md:p-10 border border-gray-100">
|
||||
<h2 class="text-2xl md:text-3xl font-display font-bold text-gray-900 mb-3">
|
||||
Vereinsmeisterschaften
|
||||
</h2>
|
||||
<p class="text-gray-600 mb-6 max-w-3xl">
|
||||
Ergebnisse, Historie und Einblicke in die Vereinsmeisterschaften.
|
||||
</p>
|
||||
<NuxtLink
|
||||
to="/vereinsmeisterschaften"
|
||||
class="inline-flex items-center px-6 py-3 rounded-lg bg-primary-600 hover:bg-primary-700 text-white font-semibold transition-colors"
|
||||
>
|
||||
Ergebnisse ansehen
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -7,8 +7,12 @@
|
||||
>
|
||||
<div class="pointer-events-auto mx-4 shadow-lg rounded-md overflow-hidden">
|
||||
<div class="px-4 py-3 bg-green-50 text-green-800 text-sm">
|
||||
<div class="font-medium">{{ toastTitle }}</div>
|
||||
<div class="mt-1">{{ toastMessage }}</div>
|
||||
<div class="font-medium">
|
||||
{{ toastTitle }}
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
{{ toastMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -57,7 +57,10 @@ has_tracked_files_under() {
|
||||
install_dependencies() {
|
||||
if [ -f "package-lock.json" ]; then
|
||||
echo " Running: npm ci"
|
||||
npm ci
|
||||
if ! npm ci; then
|
||||
echo " WARNING: npm ci fehlgeschlagen (Lockfile ggf. nicht synchron). Fallback auf npm install..."
|
||||
npm install
|
||||
fi
|
||||
else
|
||||
echo " WARNING: package-lock.json fehlt. Führe npm install aus..."
|
||||
npm install
|
||||
@@ -92,6 +95,10 @@ install_dependencies_if_needed() {
|
||||
echo " package-lock.json unverändert, überspringe npm ci"
|
||||
fi
|
||||
|
||||
if [ -f "package-lock.json" ]; then
|
||||
current_lock_hash="$(sha256sum package-lock.json | awk '{print $1}')"
|
||||
fi
|
||||
|
||||
printf '%s\n' "$current_lock_hash" > "$lock_hash_file"
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,10 @@ has_tracked_files_under() {
|
||||
install_dependencies() {
|
||||
if [ -f "package-lock.json" ]; then
|
||||
echo " Running: npm ci"
|
||||
npm ci
|
||||
if ! npm ci; then
|
||||
echo " WARNING: npm ci fehlgeschlagen (Lockfile ggf. nicht synchron). Fallback auf npm install..."
|
||||
npm install
|
||||
fi
|
||||
else
|
||||
echo " WARNING: package-lock.json fehlt. Führe npm install aus..."
|
||||
npm install
|
||||
@@ -105,6 +108,10 @@ install_dependencies_if_needed() {
|
||||
echo " package-lock.json unverändert, überspringe npm ci"
|
||||
fi
|
||||
|
||||
if [ -f "package-lock.json" ]; then
|
||||
current_lock_hash="$(sha256sum package-lock.json | awk '{print $1}')"
|
||||
fi
|
||||
|
||||
printf '%s\n' "$current_lock_hash" > "$lock_hash_file"
|
||||
}
|
||||
|
||||
|
||||
296
package-lock.json
generated
@@ -1,18 +1,17 @@
|
||||
{
|
||||
"name": "harheimertc-website",
|
||||
"version": "1.4.5",
|
||||
"version": "1.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "harheimertc-website",
|
||||
"version": "1.4.5",
|
||||
"version": "1.7.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@pinia/nuxt": "^0.11.2",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@tinymce/tinymce-vue": "^6.3.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"dompurify": "^3.3.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
@@ -24,7 +23,6 @@
|
||||
"pinia": "^3.0.3",
|
||||
"quill": "^2.0.2",
|
||||
"sharp": "^0.34.5",
|
||||
"tinymce": "^8.3.1",
|
||||
"vue": "^3.5.22"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -2795,76 +2793,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/vite-builder/node_modules/@eslint/config-array": {
|
||||
"version": "0.23.5",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz",
|
||||
"integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint/object-schema": "^3.0.5",
|
||||
"debug": "^4.3.1",
|
||||
"minimatch": "^10.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/vite-builder/node_modules/@eslint/config-helpers": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz",
|
||||
"integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint/core": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/vite-builder/node_modules/@eslint/core": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz",
|
||||
"integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.15"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/vite-builder/node_modules/@eslint/object-schema": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz",
|
||||
"integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/vite-builder/node_modules/@eslint/plugin-kit": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz",
|
||||
"integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint/core": "^1.2.1",
|
||||
"levn": "^0.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/vite-builder/node_modules/@nuxt/kit": {
|
||||
"version": "4.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.4.6.tgz",
|
||||
@@ -2896,31 +2824,6 @@
|
||||
"node": ">=18.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/vite-builder/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/vite-builder/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/vite-builder/node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
@@ -2936,172 +2839,6 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/vite-builder/node_modules/eslint": {
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz",
|
||||
"integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@eslint/config-array": "^0.23.5",
|
||||
"@eslint/config-helpers": "^0.6.0",
|
||||
"@eslint/core": "^1.2.1",
|
||||
"@eslint/plugin-kit": "^0.7.1",
|
||||
"@humanfs/node": "^0.16.6",
|
||||
"@humanwhocodes/module-importer": "^1.0.1",
|
||||
"@humanwhocodes/retry": "^0.4.2",
|
||||
"@types/estree": "^1.0.6",
|
||||
"ajv": "^6.14.0",
|
||||
"cross-spawn": "^7.0.6",
|
||||
"debug": "^4.3.2",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"eslint-scope": "^9.1.2",
|
||||
"eslint-visitor-keys": "^5.0.1",
|
||||
"espree": "^11.2.0",
|
||||
"esquery": "^1.7.0",
|
||||
"esutils": "^2.0.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"file-entry-cache": "^8.0.0",
|
||||
"find-up": "^5.0.0",
|
||||
"glob-parent": "^6.0.2",
|
||||
"ignore": "^5.2.0",
|
||||
"imurmurhash": "^0.1.4",
|
||||
"is-glob": "^4.0.0",
|
||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||
"minimatch": "^10.2.4",
|
||||
"natural-compare": "^1.4.0",
|
||||
"optionator": "^0.9.3"
|
||||
},
|
||||
"bin": {
|
||||
"eslint": "bin/eslint.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://eslint.org/donate"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"jiti": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"jiti": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/vite-builder/node_modules/eslint-scope": {
|
||||
"version": "9.1.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
|
||||
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/esrecurse": "^4.3.1",
|
||||
"@types/estree": "^1.0.8",
|
||||
"esrecurse": "^4.3.0",
|
||||
"estraverse": "^5.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/vite-builder/node_modules/eslint-visitor-keys": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/vite-builder/node_modules/eslint/node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/vite-builder/node_modules/eslint/node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/vite-builder/node_modules/espree": {
|
||||
"version": "11.2.0",
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
|
||||
"integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"acorn": "^8.16.0",
|
||||
"acorn-jsx": "^5.3.2",
|
||||
"eslint-visitor-keys": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/vite-builder/node_modules/glob-parent": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/vite-builder/node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/vite-builder/node_modules/npm-run-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz",
|
||||
@@ -5441,21 +5178,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tinymce/tinymce-vue": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tinymce/tinymce-vue/-/tinymce-vue-6.3.0.tgz",
|
||||
"integrity": "sha512-DSP8Jhd3XqCCliTnusfbmz3D8GqQ4iRzkc4aadYHDcJPVjkaqopJ61McOdH82CSy599vGLkPjGzqJYWJkRMiUA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"tinymce": "^8.0.0 || ^7.0.0 || ^6.0.0 || ^5.5.1",
|
||||
"vue": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"tinymce": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||
@@ -5494,14 +5216,6 @@
|
||||
"@types/trusted-types": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/esrecurse": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||
"integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -14246,12 +13960,6 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinymce": {
|
||||
"version": "8.3.1",
|
||||
"resolved": "https://registry.npmjs.org/tinymce/-/tinymce-8.3.1.tgz",
|
||||
"integrity": "sha512-mdQdTAA90aEIyhEteIwy+QQ6UnxPCd3qQ5MlGvvByOvnjyOSdBzBcmnXeqWuhGz3fIs3XBJjIw7JyIMiHjebqw==",
|
||||
"license": "SEE LICENSE IN license.md"
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "harheimertc-website",
|
||||
"version": "1.6.2",
|
||||
"version": "1.7.0",
|
||||
"description": "Moderne Webseite für den Harheimer Tischtennis Club",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -21,6 +21,8 @@
|
||||
"sync-public-data": "node scripts/sync-public-data.js",
|
||||
"import-spielplan": "node scripts/import-spielplan.js",
|
||||
"publish-spielplan": "node scripts/publish-imported-spielplan.js",
|
||||
"playstore:assets": "./scripts/playstore-assets.sh",
|
||||
"playstore:anonymize": "./scripts/anonymize-playstore-screenshot.sh",
|
||||
"test:watch": "vitest watch",
|
||||
"lint": "eslint . --fix"
|
||||
},
|
||||
@@ -28,7 +30,6 @@
|
||||
"@pinia/nuxt": "^0.11.2",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@tinymce/tinymce-vue": "^6.3.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"dompurify": "^3.3.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
@@ -40,7 +41,7 @@
|
||||
"pinia": "^3.0.3",
|
||||
"quill": "^2.0.2",
|
||||
"sharp": "^0.34.5",
|
||||
"tinymce": "^8.3.1",
|
||||
|
||||
"vue": "^3.5.22"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -33,7 +33,10 @@
|
||||
<!-- Tab Content -->
|
||||
<div>
|
||||
<CmsTermine v-if="activeTab === 'termine'" />
|
||||
<CmsMannschaften ref="cmsMannschaftenRef" v-if="activeTab === 'mannschaften'" />
|
||||
<CmsMannschaften
|
||||
v-if="activeTab === 'mannschaften'"
|
||||
ref="cmsMannschaftenRef"
|
||||
/>
|
||||
<CmsSpielplaene v-if="activeTab === 'spielplaene'" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
Verfügbare Elemente
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600">
|
||||
Ziehen Sie die Elemente per Drag & Drop oder verwenden Sie die Pfeil-Buttons, um die Reihenfolge zu ändern.
|
||||
Legen Sie Reihenfolge, Sichtbarkeit und Marker fest (ohne Marker, cookie, eingeloggt).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -110,13 +110,33 @@
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Marker -->
|
||||
<div class="flex items-center">
|
||||
<label class="text-sm text-gray-700 mr-2">Marker</label>
|
||||
<select
|
||||
v-model="section.marker"
|
||||
class="px-2 py-1 border border-gray-300 rounded-lg text-sm bg-white"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
<option value="">
|
||||
keiner
|
||||
</option>
|
||||
<option value="cookie">
|
||||
cookie
|
||||
</option>
|
||||
<option value="eingeloggt">
|
||||
eingeloggt
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info Box -->
|
||||
<div class="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<p class="text-sm text-blue-800">
|
||||
<strong>Hinweis:</strong> Deaktivierte Elemente werden auf der Startseite nicht angezeigt, bleiben aber in der Konfiguration erhalten.
|
||||
<strong>Hinweis:</strong> Marker steuern die Sichtbarkeit auf der Web-Startseite: cookie zeigt das Element bei vorhandenen Cookies, eingeloggt nur für angemeldete Nutzer.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -166,6 +186,30 @@ const availableSections = {
|
||||
kontakt: {
|
||||
label: 'Kontakt-Boxen',
|
||||
description: 'Mitglied werden & Kontakt aufnehmen'
|
||||
},
|
||||
training: {
|
||||
label: 'Training-Teaser',
|
||||
description: 'Direktzugang zu Training, Trainern und Anfängerbereich'
|
||||
},
|
||||
links: {
|
||||
label: 'Links-Teaser',
|
||||
description: 'Direktzugang zu den nützlichen Vereinslinks'
|
||||
},
|
||||
vereinsmeisterschaften: {
|
||||
label: 'Vereinsmeisterschaften-Teaser',
|
||||
description: 'Direktzugang zu Meisterschaftsergebnissen'
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMarker(marker) {
|
||||
return marker === 'cookie' || marker === 'eingeloggt' ? marker : ''
|
||||
}
|
||||
|
||||
function normalizeSection(section) {
|
||||
return {
|
||||
id: section?.id,
|
||||
enabled: section?.enabled !== false,
|
||||
marker: normalizeMarker(section?.marker)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,17 +229,23 @@ const loadConfig = async () => {
|
||||
|
||||
// Standard-Reihenfolge, falls nicht vorhanden
|
||||
const defaultSections = [
|
||||
{ id: 'banner', enabled: true },
|
||||
{ id: 'termine', enabled: true },
|
||||
{ id: 'spiele', enabled: true },
|
||||
{ id: 'aktuelles', enabled: true },
|
||||
{ id: 'kontakt', enabled: true }
|
||||
{ id: 'banner', enabled: true, marker: '' },
|
||||
{ id: 'termine', enabled: true, marker: '' },
|
||||
{ id: 'spiele', enabled: true, marker: '' },
|
||||
{ id: 'aktuelles', enabled: true, marker: '' },
|
||||
{ id: 'kontakt', enabled: true, marker: '' },
|
||||
{ id: 'training', enabled: false, marker: '' },
|
||||
{ id: 'links', enabled: false, marker: '' },
|
||||
{ id: 'vereinsmeisterschaften', enabled: false, marker: '' }
|
||||
]
|
||||
|
||||
if (config.homepage && config.homepage.sections && Array.isArray(config.homepage.sections)) {
|
||||
// Validiere und merge: Nur bekannte IDs verwenden, fehlende hinzufügen
|
||||
const knownIds = new Set(config.homepage.sections.map(s => s.id))
|
||||
const merged = [...config.homepage.sections]
|
||||
const normalized = config.homepage.sections
|
||||
.filter(s => s?.id)
|
||||
.map(normalizeSection)
|
||||
const knownIds = new Set(normalized.map(s => s.id))
|
||||
const merged = [...normalized]
|
||||
|
||||
// Füge fehlende Standard-Elemente hinzu
|
||||
for (const defaultSection of defaultSections) {
|
||||
@@ -204,9 +254,9 @@ const loadConfig = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
sections.value = merged
|
||||
sections.value = merged.map(normalizeSection)
|
||||
} else {
|
||||
sections.value = [...defaultSections]
|
||||
sections.value = defaultSections.map(normalizeSection)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Konfiguration:', error)
|
||||
@@ -242,7 +292,7 @@ const saveConfig = async () => {
|
||||
if (!config.homepage) {
|
||||
config.homepage = {}
|
||||
}
|
||||
config.homepage.sections = sections.value
|
||||
config.homepage.sections = sections.value.map(normalizeSection)
|
||||
|
||||
// Speichere Config
|
||||
await $fetch('/api/config', {
|
||||
|
||||
110
pages/datenschutz.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div class="min-h-full py-16 px-4 sm:px-6 lg:px-8 bg-gray-50">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<h1 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-6">
|
||||
Datenschutzerklärung
|
||||
</h1>
|
||||
<div class="w-24 h-1 bg-primary-600 mb-8" />
|
||||
|
||||
<div class="bg-white p-8 rounded-xl shadow-lg space-y-6 text-gray-700">
|
||||
<section>
|
||||
<h2 class="text-xl font-display font-bold text-gray-900 mb-2">
|
||||
1. Datenschutz auf einen Blick
|
||||
</h2>
|
||||
<p>
|
||||
Der Schutz Ihrer personenbezogenen Daten hat für den Harheimer TC 1954 e.V. einen hohen Stellenwert.
|
||||
Wir verarbeiten personenbezogene Daten vertraulich und entsprechend den gesetzlichen Datenschutzvorschriften.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-xl font-display font-bold text-gray-900 mb-2">
|
||||
2. Verantwortliche Stelle
|
||||
</h2>
|
||||
<p>
|
||||
Harheimer TC 1954 e.V.<br>
|
||||
Kontakt über die Angaben im
|
||||
<NuxtLink
|
||||
to="/impressum"
|
||||
class="text-primary-600 hover:underline"
|
||||
>
|
||||
Impressum
|
||||
</NuxtLink>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-xl font-display font-bold text-gray-900 mb-2">
|
||||
3. Verarbeitung personenbezogener Daten
|
||||
</h2>
|
||||
<p class="mb-3">
|
||||
Wir verarbeiten personenbezogene Daten insbesondere in folgenden Fällen:
|
||||
</p>
|
||||
<ul class="list-disc pl-6 space-y-1">
|
||||
<li>Kontaktanfragen über Formulare oder E-Mail</li>
|
||||
<li>Mitgliedschaftsanträge</li>
|
||||
<li>Nutzung des Mitgliederbereichs (Login, Profilfunktionen)</li>
|
||||
<li>Newsletter-Anmeldung und -Abmeldung</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-xl font-display font-bold text-gray-900 mb-2">
|
||||
4. Technische und organisatorische Maßnahmen
|
||||
</h2>
|
||||
<p>
|
||||
Für besonders schützenswerte Daten setzen wir technische Schutzmaßnahmen ein,
|
||||
darunter Verschlüsselung, rollenbasierte Zugriffssteuerung und abgesicherte
|
||||
Authentifizierungsverfahren.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-xl font-display font-bold text-gray-900 mb-2">
|
||||
5. Ihre Rechte
|
||||
</h2>
|
||||
<p class="mb-3">
|
||||
Sie haben im Rahmen der gesetzlichen Vorgaben insbesondere folgende Rechte:
|
||||
</p>
|
||||
<ul class="list-disc pl-6 space-y-1">
|
||||
<li>Auskunft über gespeicherte Daten</li>
|
||||
<li>Berichtigung unrichtiger Daten</li>
|
||||
<li>Löschung oder Einschränkung der Verarbeitung</li>
|
||||
<li>Widerspruch gegen die Verarbeitung</li>
|
||||
<li>Datenübertragbarkeit</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-xl font-display font-bold text-gray-900 mb-2">
|
||||
6. Kontakt zum Datenschutz
|
||||
</h2>
|
||||
<p>
|
||||
Bei Fragen zum Datenschutz nutzen Sie bitte die Kontaktangaben im
|
||||
<NuxtLink
|
||||
to="/impressum"
|
||||
class="text-primary-600 hover:underline"
|
||||
>
|
||||
Impressum
|
||||
</NuxtLink>
|
||||
oder unser
|
||||
<NuxtLink
|
||||
to="/kontakt"
|
||||
class="text-primary-600 hover:underline"
|
||||
>
|
||||
Kontaktformular
|
||||
</NuxtLink>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
useHead({
|
||||
title: 'Datenschutzerklärung - Harheimer TC',
|
||||
})
|
||||
</script>
|
||||
541
pages/index.vue
@@ -1,55 +1,562 @@
|
||||
<template>
|
||||
<div class="min-h-full">
|
||||
<div
|
||||
v-if="canCustomizeHome"
|
||||
class="fixed right-4 bottom-14 z-[60]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="w-9 h-9 rounded-full bg-white border border-gray-200 shadow-sm hover:shadow-md hover:bg-gray-50 flex items-center justify-center text-gray-700"
|
||||
:title="editorOpen ? 'Startseiteneditor schließen' : 'Startseiteneditor öffnen'"
|
||||
@click="editorOpen ? closeEditor() : openEditor()"
|
||||
>
|
||||
<X
|
||||
v-if="editorOpen"
|
||||
:size="15"
|
||||
/>
|
||||
<SlidersHorizontal
|
||||
v-else
|
||||
:size="15"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="editorOpen"
|
||||
class="fixed right-4 bottom-28 z-[60] w-[min(92vw,30rem)] bg-white border border-gray-200 rounded-xl shadow-xl p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 mb-3">
|
||||
<h2 class="text-base font-semibold text-gray-900">
|
||||
Startseiteneditor
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-500 mb-3">
|
||||
{{ isLoggedIn ? 'Einstellungen werden serverseitig für deinen Nutzer gespeichert.' : 'Einstellungen werden nur im Browser-Cookie gespeichert.' }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="editorSections.length === 0"
|
||||
class="text-sm text-gray-600"
|
||||
>
|
||||
Keine Elemente zur Konfiguration gefunden.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="space-y-2 max-h-[50vh] overflow-auto pr-1"
|
||||
>
|
||||
<div
|
||||
v-for="(section, index) in editorSections"
|
||||
:key="section.key"
|
||||
class="p-3 border border-gray-200 rounded-lg bg-gray-50"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-gray-900 truncate">
|
||||
{{ getSectionLabel(section) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 truncate">
|
||||
{{ section.id }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="px-2 py-1 text-xs rounded border border-gray-300 bg-white disabled:opacity-50"
|
||||
:disabled="index === 0 || isSavingSettings"
|
||||
@click="moveEditorSectionUp(index)"
|
||||
>
|
||||
Hoch
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-2 py-1 text-xs rounded border border-gray-300 bg-white disabled:opacity-50"
|
||||
:disabled="index === editorSections.length - 1 || isSavingSettings"
|
||||
@click="moveEditorSectionDown(index)"
|
||||
>
|
||||
Runter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input
|
||||
v-model="section.enabled"
|
||||
type="checkbox"
|
||||
:disabled="isSavingSettings"
|
||||
>
|
||||
Anzeigen
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="section.id === 'spielplan_team'"
|
||||
class="mt-3 grid grid-cols-1 gap-2"
|
||||
>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Saison</label>
|
||||
<select
|
||||
:value="section.config?.season || ''"
|
||||
class="mt-1 w-full px-2 py-1.5 text-sm border border-gray-300 rounded bg-white"
|
||||
:disabled="isSavingSettings || widgetOptionsLoading"
|
||||
@change="onWidgetSeasonChanged(section, $event.target.value)"
|
||||
>
|
||||
<option
|
||||
v-for="season in spielplanSeasons"
|
||||
:key="season.slug"
|
||||
:value="season.slug"
|
||||
>
|
||||
{{ season.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Mannschaft</label>
|
||||
<select
|
||||
:value="teamKeyFromConfig(section.config)"
|
||||
class="mt-1 w-full px-2 py-1.5 text-sm border border-gray-300 rounded bg-white"
|
||||
:disabled="isSavingSettings || widgetOptionsLoading"
|
||||
@change="onWidgetTeamChanged(section, $event.target.value)"
|
||||
>
|
||||
<option
|
||||
v-for="team in getTeamsForSeason(section.config?.season)"
|
||||
:key="team.key"
|
||||
:value="team.key"
|
||||
>
|
||||
{{ team.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 pt-4 border-t border-gray-200">
|
||||
<p class="text-sm font-semibold text-gray-900 mb-2">
|
||||
Widget hinzufügen
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 mb-3">
|
||||
Spielplan-Widget für eine konkrete Mannschaft und Saison.
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 gap-2">
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Saison</label>
|
||||
<select
|
||||
v-model="newWidgetSeason"
|
||||
class="mt-1 w-full px-2 py-1.5 text-sm border border-gray-300 rounded bg-white"
|
||||
:disabled="widgetOptionsLoading"
|
||||
@change="onNewWidgetSeasonChanged"
|
||||
>
|
||||
<option
|
||||
v-for="season in spielplanSeasons"
|
||||
:key="season.slug"
|
||||
:value="season.slug"
|
||||
>
|
||||
{{ season.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Mannschaft</label>
|
||||
<select
|
||||
v-model="newWidgetTeamKey"
|
||||
class="mt-1 w-full px-2 py-1.5 text-sm border border-gray-300 rounded bg-white"
|
||||
:disabled="widgetOptionsLoading"
|
||||
>
|
||||
<option
|
||||
v-for="team in newWidgetTeams"
|
||||
:key="team.key"
|
||||
:value="team.key"
|
||||
>
|
||||
{{ team.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 px-3 py-2 text-sm rounded-lg border border-primary-300 text-primary-700 hover:bg-primary-50 disabled:opacity-50"
|
||||
:disabled="!canAddSpielplanWidget || isSavingSettings"
|
||||
@click="addSpielplanWidget"
|
||||
>
|
||||
Spielplan-Widget hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-2 text-sm rounded-lg bg-primary-600 hover:bg-primary-700 text-white disabled:opacity-50"
|
||||
:disabled="isSavingSettings || editorSections.length === 0"
|
||||
@click="saveEditor"
|
||||
>
|
||||
{{ isSavingSettings ? 'Speichert...' : 'Speichern' }}
|
||||
</button>
|
||||
<p
|
||||
v-if="editorMessage"
|
||||
class="text-sm"
|
||||
:class="editorMessageType === 'error' ? 'text-red-700' : 'text-green-700'"
|
||||
>
|
||||
{{ editorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template
|
||||
v-for="section in enabledSections"
|
||||
:key="section.key"
|
||||
>
|
||||
<HomeSpielplanTeamWidget
|
||||
v-if="section.id === 'spielplan_team'"
|
||||
:season="section.config?.season"
|
||||
:team-name="section.config?.teamName"
|
||||
:team-age-group="section.config?.teamAgeGroup"
|
||||
/>
|
||||
<component
|
||||
:is="getComponentForSection(section.id)"
|
||||
v-for="section in enabledSections"
|
||||
:key="section.id"
|
||||
v-else
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { SlidersHorizontal, X } from 'lucide-vue-next'
|
||||
import Hero from '~/components/Hero.vue'
|
||||
import HomeTermine from '~/components/HomeTermine.vue'
|
||||
import Spielplan from '~/components/Spielplan.vue'
|
||||
import PublicNews from '~/components/PublicNews.vue'
|
||||
import HomeActions from '~/components/HomeActions.vue'
|
||||
import HomeTrainingTeaser from '~/components/HomeTrainingTeaser.vue'
|
||||
import HomeLinksTeaser from '~/components/HomeLinksTeaser.vue'
|
||||
import HomeVereinsmeisterschaftenTeaser from '~/components/HomeVereinsmeisterschaftenTeaser.vue'
|
||||
import HomeSpielplanTeamWidget from '~/components/HomeSpielplanTeamWidget.vue'
|
||||
|
||||
const { data: config } = await useFetch('/api/config')
|
||||
const { data: authStatus } = await useFetch('/api/auth/status')
|
||||
const { data: homepageSettings, refresh: refreshHomepageSettings } = await useFetch('/api/homepage/settings')
|
||||
|
||||
// Standard-Reihenfolge, falls Config nicht vorhanden
|
||||
const defaultSections = [
|
||||
const editorOpen = ref(false)
|
||||
const editorSections = ref([])
|
||||
const isSavingSettings = ref(false)
|
||||
const editorMessage = ref('')
|
||||
const editorMessageType = ref('success')
|
||||
const widgetOptionsLoading = ref(false)
|
||||
const spielplanSeasons = ref([])
|
||||
const teamOptionsBySeason = ref({})
|
||||
const newWidgetSeason = ref('')
|
||||
const newWidgetTeamKey = ref('')
|
||||
|
||||
const baseSectionDefinitions = [
|
||||
{ id: 'banner', enabled: true },
|
||||
{ id: 'termine', enabled: true },
|
||||
{ id: 'spiele', enabled: true },
|
||||
{ id: 'aktuelles', enabled: true },
|
||||
{ id: 'kontakt', enabled: true }
|
||||
{ id: 'kontakt', enabled: true },
|
||||
{ id: 'training', enabled: false },
|
||||
{ id: 'links', enabled: false },
|
||||
{ id: 'vereinsmeisterschaften', enabled: false }
|
||||
]
|
||||
const baseSectionIds = new Set(baseSectionDefinitions.map(section => section.id))
|
||||
|
||||
// Lade Sections aus Config oder verwende Standard
|
||||
const sections = computed(() => {
|
||||
if (config.value?.homepage?.sections && Array.isArray(config.value.homepage.sections)) {
|
||||
return config.value.homepage.sections
|
||||
function createEntryKey(id) {
|
||||
return `${id}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
function normalizeConfig(config) {
|
||||
if (!config || typeof config !== 'object') return undefined
|
||||
const normalized = {
|
||||
season: config.season ? String(config.season) : '',
|
||||
teamName: config.teamName ? String(config.teamName) : '',
|
||||
teamAgeGroup: config.teamAgeGroup ? String(config.teamAgeGroup) : ''
|
||||
}
|
||||
return defaultSections
|
||||
if (!normalized.season && !normalized.teamName && !normalized.teamAgeGroup) {
|
||||
return undefined
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function normalizeEntry(entry, index, fallbackId = '') {
|
||||
const id = String(entry?.id || fallbackId || '').trim()
|
||||
if (!id) return null
|
||||
return {
|
||||
key: entry?.key ? String(entry.key) : `${id}-${index}`,
|
||||
id,
|
||||
enabled: entry?.enabled !== false,
|
||||
config: normalizeConfig(entry?.config)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSectionList(rawSections) {
|
||||
const incoming = Array.isArray(rawSections) ? rawSections : []
|
||||
const sanitized = incoming
|
||||
.map((section, index) => normalizeEntry(section, index))
|
||||
.filter(Boolean)
|
||||
|
||||
if (sanitized.length === 0) {
|
||||
return baseSectionDefinitions.map((section, index) => normalizeEntry(
|
||||
{ ...section, key: `base-${section.id}` },
|
||||
index,
|
||||
section.id
|
||||
))
|
||||
}
|
||||
|
||||
const knownIds = new Set(sanitized.map(section => section.id))
|
||||
const merged = [...sanitized]
|
||||
for (const defaultSection of baseSectionDefinitions) {
|
||||
if (!knownIds.has(defaultSection.id)) {
|
||||
merged.push(normalizeEntry(
|
||||
{ ...defaultSection, key: `base-${defaultSection.id}` },
|
||||
merged.length,
|
||||
defaultSection.id
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
const sections = computed(() => normalizeSectionList(config.value?.homepage?.sections))
|
||||
const personalizedSections = computed(() => {
|
||||
const raw = homepageSettings.value?.sections
|
||||
const list = Array.isArray(raw) ? raw : []
|
||||
return list.map((section, index) => normalizeEntry(section, index)).filter(Boolean)
|
||||
})
|
||||
|
||||
// Filtere nur aktivierte Sections
|
||||
const enabledSections = computed(() => {
|
||||
return sections.value.filter(section => section.enabled !== false)
|
||||
})
|
||||
const isLoggedIn = computed(() => authStatus.value?.isLoggedIn === true)
|
||||
const canCustomizeHome = computed(() => sections.value.length > 0)
|
||||
|
||||
function applyPersonalization(baseSections, settingsSections) {
|
||||
if (!settingsSections.length) return baseSections
|
||||
|
||||
const presentBaseIds = new Set(
|
||||
settingsSections.filter(section => baseSectionIds.has(section.id)).map(section => section.id)
|
||||
)
|
||||
const missingBaseSections = baseSections.filter(section => !presentBaseIds.has(section.id))
|
||||
return [...settingsSections, ...missingBaseSections]
|
||||
}
|
||||
|
||||
const resolvedSections = computed(() => applyPersonalization([...sections.value], personalizedSections.value))
|
||||
const enabledSections = computed(() => resolvedSections.value.filter(section => section.enabled !== false))
|
||||
|
||||
// Mapping von Section-ID zu Komponente
|
||||
const componentMap = {
|
||||
banner: Hero,
|
||||
termine: HomeTermine,
|
||||
spiele: Spielplan,
|
||||
aktuelles: PublicNews,
|
||||
kontakt: HomeActions
|
||||
kontakt: HomeActions,
|
||||
training: HomeTrainingTeaser,
|
||||
links: HomeLinksTeaser,
|
||||
vereinsmeisterschaften: HomeVereinsmeisterschaftenTeaser
|
||||
}
|
||||
|
||||
function getComponentForSection(sectionId) {
|
||||
return componentMap[sectionId] || null
|
||||
}
|
||||
|
||||
function getSectionLabel(section) {
|
||||
if (section.id === 'spielplan_team') {
|
||||
if (!section.config?.teamName) return 'Spielplan-Widget'
|
||||
return `Spielplan: ${section.config.teamName}`
|
||||
}
|
||||
|
||||
const labels = {
|
||||
banner: 'Banner (Willkommen)',
|
||||
termine: 'Kommende Termine',
|
||||
spiele: 'Nächste Spiele',
|
||||
aktuelles: 'Aktuelles',
|
||||
kontakt: 'Kontakt-Boxen',
|
||||
training: 'Training-Teaser',
|
||||
links: 'Links-Teaser',
|
||||
vereinsmeisterschaften: 'Vereinsmeisterschaften-Teaser'
|
||||
}
|
||||
return labels[section.id] || section.id
|
||||
}
|
||||
|
||||
function getTeamsForSeason(seasonSlug) {
|
||||
if (!seasonSlug) return []
|
||||
return teamOptionsBySeason.value[seasonSlug] || []
|
||||
}
|
||||
|
||||
function teamKeyFromConfig(config) {
|
||||
if (!config?.teamName) return ''
|
||||
return `${config.teamName}||${config.teamAgeGroup || ''}`
|
||||
}
|
||||
|
||||
function applyTeamToSectionConfig(section, teamKey) {
|
||||
const season = section.config?.season || ''
|
||||
const teams = getTeamsForSeason(season)
|
||||
const team = teams.find(item => item.key === teamKey)
|
||||
if (!team) return
|
||||
section.config = {
|
||||
...section.config,
|
||||
season,
|
||||
teamName: team.teamName,
|
||||
teamAgeGroup: team.teamAgeGroup || ''
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureTeamOptions(seasonSlug) {
|
||||
if (!seasonSlug || teamOptionsBySeason.value[seasonSlug]) return
|
||||
const result = await $fetch('/api/homepage/spielplan-options', {
|
||||
query: { season: seasonSlug }
|
||||
})
|
||||
teamOptionsBySeason.value = {
|
||||
...teamOptionsBySeason.value,
|
||||
[seasonSlug]: result?.teams || []
|
||||
}
|
||||
if (!spielplanSeasons.value.length && Array.isArray(result?.seasons)) {
|
||||
spielplanSeasons.value = result.seasons
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWidgetOptions() {
|
||||
if (widgetOptionsLoading.value) return
|
||||
widgetOptionsLoading.value = true
|
||||
try {
|
||||
const result = await $fetch('/api/homepage/spielplan-options')
|
||||
spielplanSeasons.value = Array.isArray(result?.seasons) ? result.seasons : []
|
||||
|
||||
const selectedSeason = result?.selectedSeason || spielplanSeasons.value[0]?.slug || ''
|
||||
if (selectedSeason) {
|
||||
teamOptionsBySeason.value = {
|
||||
...teamOptionsBySeason.value,
|
||||
[selectedSeason]: result?.teams || []
|
||||
}
|
||||
}
|
||||
|
||||
if (!newWidgetSeason.value) {
|
||||
newWidgetSeason.value = selectedSeason
|
||||
}
|
||||
if (newWidgetSeason.value) {
|
||||
await ensureTeamOptions(newWidgetSeason.value)
|
||||
const teams = getTeamsForSeason(newWidgetSeason.value)
|
||||
if (teams.length && !newWidgetTeamKey.value) {
|
||||
newWidgetTeamKey.value = teams[0].key
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
widgetOptionsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openEditor() {
|
||||
editorMessage.value = ''
|
||||
editorSections.value = resolvedSections.value.map(section => ({
|
||||
key: section.key,
|
||||
id: section.id,
|
||||
enabled: section.enabled !== false,
|
||||
config: normalizeConfig(section.config)
|
||||
}))
|
||||
|
||||
await loadWidgetOptions()
|
||||
|
||||
for (const section of editorSections.value.filter(item => item.id === 'spielplan_team')) {
|
||||
const fallbackSeason = section.config?.season || newWidgetSeason.value || spielplanSeasons.value[0]?.slug || ''
|
||||
if (!section.config) section.config = {}
|
||||
section.config.season = fallbackSeason
|
||||
await ensureTeamOptions(fallbackSeason)
|
||||
const currentTeamKey = teamKeyFromConfig(section.config)
|
||||
const availableTeams = getTeamsForSeason(fallbackSeason)
|
||||
if (availableTeams.length && !availableTeams.find(item => item.key === currentTeamKey)) {
|
||||
applyTeamToSectionConfig(section, availableTeams[0].key)
|
||||
}
|
||||
}
|
||||
|
||||
editorOpen.value = true
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
editorOpen.value = false
|
||||
editorMessage.value = ''
|
||||
}
|
||||
|
||||
function moveEditorSectionUp(index) {
|
||||
if (index <= 0) return
|
||||
const item = editorSections.value[index]
|
||||
editorSections.value.splice(index, 1)
|
||||
editorSections.value.splice(index - 1, 0, item)
|
||||
}
|
||||
|
||||
function moveEditorSectionDown(index) {
|
||||
if (index >= editorSections.value.length - 1) return
|
||||
const item = editorSections.value[index]
|
||||
editorSections.value.splice(index, 1)
|
||||
editorSections.value.splice(index + 1, 0, item)
|
||||
}
|
||||
|
||||
async function onWidgetSeasonChanged(section, seasonSlug) {
|
||||
if (!section.config) section.config = {}
|
||||
section.config.season = seasonSlug
|
||||
await ensureTeamOptions(seasonSlug)
|
||||
const teams = getTeamsForSeason(seasonSlug)
|
||||
const currentTeamKey = teamKeyFromConfig(section.config)
|
||||
if (teams.length && !teams.find(item => item.key === currentTeamKey)) {
|
||||
applyTeamToSectionConfig(section, teams[0].key)
|
||||
}
|
||||
}
|
||||
|
||||
function onWidgetTeamChanged(section, teamKey) {
|
||||
applyTeamToSectionConfig(section, teamKey)
|
||||
}
|
||||
|
||||
const newWidgetTeams = computed(() => getTeamsForSeason(newWidgetSeason.value))
|
||||
const canAddSpielplanWidget = computed(() => !!newWidgetSeason.value && !!newWidgetTeamKey.value)
|
||||
|
||||
async function onNewWidgetSeasonChanged() {
|
||||
await ensureTeamOptions(newWidgetSeason.value)
|
||||
const teams = getTeamsForSeason(newWidgetSeason.value)
|
||||
newWidgetTeamKey.value = teams[0]?.key || ''
|
||||
}
|
||||
|
||||
function addSpielplanWidget() {
|
||||
const teams = getTeamsForSeason(newWidgetSeason.value)
|
||||
const selectedTeam = teams.find(team => team.key === newWidgetTeamKey.value)
|
||||
if (!selectedTeam) return
|
||||
|
||||
editorSections.value.push({
|
||||
key: createEntryKey('spielplan_team'),
|
||||
id: 'spielplan_team',
|
||||
enabled: true,
|
||||
config: {
|
||||
season: newWidgetSeason.value,
|
||||
teamName: selectedTeam.teamName,
|
||||
teamAgeGroup: selectedTeam.teamAgeGroup || ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function saveEditor() {
|
||||
isSavingSettings.value = true
|
||||
editorMessage.value = ''
|
||||
|
||||
try {
|
||||
await $fetch('/api/homepage/settings', {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
sections: editorSections.value.map((section, index) => ({
|
||||
key: section.key || `${section.id}-${index}`,
|
||||
id: section.id,
|
||||
enabled: section.enabled !== false,
|
||||
config: normalizeConfig(section.config)
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
await refreshHomepageSettings()
|
||||
editorMessageType.value = 'success'
|
||||
editorMessage.value = 'Startseiten-Einstellungen gespeichert.'
|
||||
} catch (error) {
|
||||
editorMessageType.value = 'error'
|
||||
editorMessage.value = error?.data?.message || 'Speichern fehlgeschlagen.'
|
||||
} finally {
|
||||
isSavingSettings.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
75
pages/konto-loeschen.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="min-h-full py-16 px-4 sm:px-6 lg:px-8 bg-gray-50">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<h1 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-6">
|
||||
Konto loeschen
|
||||
</h1>
|
||||
<div class="w-24 h-1 bg-primary-600 mb-8" />
|
||||
|
||||
<div class="bg-white p-8 rounded-xl shadow-lg space-y-6 text-gray-700">
|
||||
<section>
|
||||
<h2 class="text-xl font-display font-bold text-gray-900 mb-2">
|
||||
Kontoloeschung fuer App- und Webkonto
|
||||
</h2>
|
||||
<p>
|
||||
Sie koennen die Loeschung Ihres Harheimer-TC-Kontos jederzeit beantragen.
|
||||
Nach erfolgreicher Pruefung wird Ihr Zugang deaktiviert und Ihre personenbezogenen Daten
|
||||
gemaess den gesetzlichen Aufbewahrungspflichten geloescht oder anonymisiert.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-xl font-display font-bold text-gray-900 mb-2">
|
||||
So stellen Sie den Antrag
|
||||
</h2>
|
||||
<ol class="list-decimal pl-6 space-y-2">
|
||||
<li>Melden Sie sich mit Ihrem Konto im Mitgliederbereich an.</li>
|
||||
<li>Nutzen Sie das Kontaktformular oder schreiben Sie eine E-Mail mit dem Betreff "Konto loeschen".</li>
|
||||
<li>Nennen Sie dabei die E-Mail-Adresse Ihres Kontos zur Zuordnung.</li>
|
||||
</ol>
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<NuxtLink
|
||||
to="/kontakt"
|
||||
class="inline-flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Kontaktformular
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/impressum"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-900 font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Kontakt per E-Mail im Impressum
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-xl font-display font-bold text-gray-900 mb-2">
|
||||
Welche Daten sind betroffen?
|
||||
</h2>
|
||||
<ul class="list-disc pl-6 space-y-1">
|
||||
<li>Login-Daten und Zugangsdaten werden entfernt oder ungueltig gemacht.</li>
|
||||
<li>Persoenliche Profildaten im Mitgliederbereich werden geloescht oder anonymisiert.</li>
|
||||
<li>Rechtlich erforderliche Restdaten (z. B. vereins- oder steuerrechtlich) koennen befristet gespeichert bleiben.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-xl font-display font-bold text-gray-900 mb-2">
|
||||
Bearbeitungszeit
|
||||
</h2>
|
||||
<p>
|
||||
Wir bearbeiten Loeschanfragen in der Regel innerhalb von 30 Tagen.
|
||||
Bei Rueckfragen kontaktieren wir Sie ueber die hinterlegte E-Mail-Adresse.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
useHead({
|
||||
title: 'Konto loeschen - Harheimer TC',
|
||||
})
|
||||
</script>
|
||||
32
scripts/anonymize-playstore-screenshot.sh
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -lt 3 ]]; then
|
||||
echo "Nutzung: $0 <input.png> <output.png> <rects>"
|
||||
echo "rects Format: x,y,w,h;x,y,w,h"
|
||||
echo "Beispiel: $0 shot.png shot-anon.png '80,120,420,70;72,720,540,90'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
INPUT="$1"
|
||||
OUTPUT="$2"
|
||||
RECTS="$3"
|
||||
|
||||
if ! command -v magick >/dev/null 2>&1; then
|
||||
echo "Fehler: 'magick' (ImageMagick) ist nicht installiert."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TMP="$OUTPUT.tmp.png"
|
||||
cp "$INPUT" "$TMP"
|
||||
|
||||
IFS=';' read -r -a BOXES <<< "$RECTS"
|
||||
for box in "${BOXES[@]}"; do
|
||||
IFS=',' read -r x y w h <<< "$box"
|
||||
magick "$TMP" \
|
||||
\( -size "${w}x${h}" xc:black -alpha set -channel a -evaluate set 70% +channel \) \
|
||||
-geometry "+${x}+${y}" -composite "$TMP"
|
||||
done
|
||||
|
||||
mv "$TMP" "$OUTPUT"
|
||||
echo "Anonymisierte Datei geschrieben: $OUTPUT"
|
||||
69
scripts/dev-android.sh
Executable file
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PORT=3100
|
||||
HOST_URL="http://127.0.0.1:${PORT}"
|
||||
RETRIES=30
|
||||
SLEEP_INTERVAL=1
|
||||
|
||||
print() { echo "[dev-android] $*"; }
|
||||
|
||||
# 1) Ensure dev server is running (try curl)
|
||||
if curl -sSf "$HOST_URL/api/news-public" >/dev/null 2>&1; then
|
||||
print "Dev server already responding at ${HOST_URL}"
|
||||
else
|
||||
print "Dev server not responding, starting 'npm run dev' in background..."
|
||||
# Start dev server in a new session so we can keep this script interactive
|
||||
(cd "$(dirname "$(realpath "$0")")/.." && nohup npm run dev -- --host 0.0.0.0 --port ${PORT} >/tmp/harheimertc-nuxt.log 2>&1 &) || true
|
||||
print "Waiting for server to become ready (logs: /tmp/harheimertc-nuxt.log)"
|
||||
i=0
|
||||
until curl -sSf "$HOST_URL/api/news-public" >/dev/null 2>&1; do
|
||||
i=$((i+1))
|
||||
if [ $i -ge $RETRIES ]; then
|
||||
print "Server did not become ready after ${RETRIES} attempts. Check /tmp/harheimertc-nuxt.log"
|
||||
exit 1
|
||||
fi
|
||||
sleep $SLEEP_INTERVAL
|
||||
done
|
||||
print "Server is ready"
|
||||
fi
|
||||
|
||||
# 2) Wait for an adb device/emulator
|
||||
print "Waiting for adb device/emulator (ctrl-c to abort)..."
|
||||
while true; do
|
||||
# list devices and skip header
|
||||
devices=$(adb devices | sed '1d' | awk '{print $1 " " $2}' || true)
|
||||
if echo "$devices" | grep -q "device"; then
|
||||
print "Found device(s):"
|
||||
echo "$devices"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# 3) Try adb reverse with retries
|
||||
print "Setting adb reverse tcp:${PORT} -> tcp:${PORT}"
|
||||
count=0
|
||||
until adb reverse tcp:${PORT} tcp:${PORT}; do
|
||||
count=$((count+1))
|
||||
print "adb reverse failed (attempt ${count}). Retrying in 1s..."
|
||||
if [ $count -ge 10 ]; then
|
||||
print "adb reverse failed after ${count} attempts. Listing reverses and exiting with failure."
|
||||
adb reverse --list || true
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
print "adb reverse configured:"
|
||||
adb reverse --list || true
|
||||
|
||||
# 4) Verify from device
|
||||
print "Verifying from device via 127.0.0.1:${PORT}"
|
||||
if adb shell curl -sSf "http://127.0.0.1:${PORT}/api/news-public" >/dev/null 2>&1; then
|
||||
print "Success: emulator/device can reach host dev server via 127.0.0.1:${PORT}"
|
||||
exit 0
|
||||
else
|
||||
print "Verification failed from device. Try 'adb logcat' or check firewall/VM network settings."
|
||||
adb reverse --list || true
|
||||
exit 2
|
||||
fi
|
||||
69
scripts/playstore-assets.mjs
Normal file
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env node
|
||||
import { mkdir, readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import sharp from 'sharp'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const rootDir = path.resolve(__dirname, '..')
|
||||
const logoSrc = path.join(rootDir, 'public', 'images', 'logos', 'Harheimer TC.svg')
|
||||
const outDir = path.join(rootDir, 'android-app', 'playstore-assets', 'generated')
|
||||
|
||||
const iconOut = path.join(outDir, 'playstore-icon-512.png')
|
||||
const featureOut = path.join(outDir, 'playstore-feature-graphic-1024x500.png')
|
||||
|
||||
const featureOverlaySvg = `
|
||||
<svg width="1024" height="500" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#0f172a"/>
|
||||
<stop offset="100%" stop-color="#1e293b"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1024" height="500" fill="url(#bg)"/>
|
||||
<text x="430" y="185" font-size="66" font-family="DejaVu Sans, Arial, sans-serif" font-weight="700" fill="#e5e7eb">Harheimer TC</text>
|
||||
<text x="430" y="250" font-size="30" font-family="DejaVu Sans, Arial, sans-serif" fill="#cbd5e1">Tischtennis in Frankfurt-Harheim</text>
|
||||
</svg>
|
||||
`
|
||||
|
||||
async function generateAssets() {
|
||||
await mkdir(outDir, { recursive: true })
|
||||
|
||||
const logoBuffer = await readFile(logoSrc)
|
||||
const resizedLogoForIcon = await sharp(logoBuffer)
|
||||
.resize(440, 440, { fit: 'contain' })
|
||||
.png()
|
||||
.toBuffer()
|
||||
|
||||
await sharp({
|
||||
create: {
|
||||
width: 512,
|
||||
height: 512,
|
||||
channels: 4,
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||
},
|
||||
})
|
||||
.composite([{ input: resizedLogoForIcon, gravity: 'center' }])
|
||||
.png()
|
||||
.toFile(iconOut)
|
||||
|
||||
const resizedLogoForFeature = await sharp(logoBuffer)
|
||||
.resize(320, 320, { fit: 'contain' })
|
||||
.png()
|
||||
.toBuffer()
|
||||
|
||||
await sharp(Buffer.from(featureOverlaySvg))
|
||||
.composite([{ input: resizedLogoForFeature, left: 72, top: 90 }])
|
||||
.png()
|
||||
.toFile(featureOut)
|
||||
|
||||
console.log(`Fertig. Assets erzeugt in: ${outDir}`)
|
||||
console.log(`- ${path.basename(iconOut)}`)
|
||||
console.log(`- ${path.basename(featureOut)}`)
|
||||
}
|
||||
|
||||
generateAssets().catch((error) => {
|
||||
console.error('Fehler bei der Asset-Generierung:', error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
6
scripts/playstore-assets.sh
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
node "$ROOT_DIR/scripts/playstore-assets.mjs"
|
||||
59
server/api/homepage/settings.get.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { getUserFromToken } from '../../utils/auth.js'
|
||||
|
||||
function normalizeConfig(config) {
|
||||
if (!config || typeof config !== 'object') return undefined
|
||||
const normalized = {
|
||||
season: config.season ? String(config.season) : undefined,
|
||||
teamName: config.teamName ? String(config.teamName) : undefined,
|
||||
teamAgeGroup: config.teamAgeGroup ? String(config.teamAgeGroup) : undefined
|
||||
}
|
||||
if (!normalized.season && !normalized.teamName && !normalized.teamAgeGroup) {
|
||||
return undefined
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function parseSections(value) {
|
||||
if (!value || typeof value !== 'string') return []
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed
|
||||
.filter(section => section?.id)
|
||||
.map((section, index) => ({
|
||||
key: section.key ? String(section.key) : `${String(section.id)}-${index}`,
|
||||
id: String(section.id),
|
||||
enabled: section.enabled !== false,
|
||||
config: normalizeConfig(section.config)
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
const user = token ? await getUserFromToken(token) : null
|
||||
|
||||
const rawCookieSections = getCookie(event, 'homepage_sections')
|
||||
const cookieSections = parseSections(rawCookieSections)
|
||||
|
||||
const userSections = Array.isArray(user?.homepageSettings?.sections)
|
||||
? user.homepageSettings.sections
|
||||
.filter(section => section?.id)
|
||||
.map((section, index) => ({
|
||||
key: section.key ? String(section.key) : `${String(section.id)}-${index}`,
|
||||
id: String(section.id),
|
||||
enabled: section.enabled !== false,
|
||||
config: normalizeConfig(section.config)
|
||||
}))
|
||||
: []
|
||||
|
||||
const isLoggedIn = !!user
|
||||
|
||||
return {
|
||||
isLoggedIn,
|
||||
storage: isLoggedIn ? 'user' : 'cookie',
|
||||
sections: isLoggedIn ? userSections : cookieSections
|
||||
}
|
||||
})
|
||||
81
server/api/homepage/settings.put.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import { getUserFromToken, readUsers, writeUsers } from '../../utils/auth.js'
|
||||
|
||||
function normalizeConfig(config) {
|
||||
if (!config || typeof config !== 'object') return undefined
|
||||
const normalized = {
|
||||
season: config.season ? String(config.season) : undefined,
|
||||
teamName: config.teamName ? String(config.teamName) : undefined,
|
||||
teamAgeGroup: config.teamAgeGroup ? String(config.teamAgeGroup) : undefined
|
||||
}
|
||||
if (!normalized.season && !normalized.teamName && !normalized.teamAgeGroup) {
|
||||
return undefined
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function normalizeSections(sections) {
|
||||
if (!Array.isArray(sections)) return []
|
||||
const seenKeys = new Set()
|
||||
return sections
|
||||
.filter(section => section?.id)
|
||||
.map((section, index) => ({
|
||||
key: section.key ? String(section.key) : `${String(section.id)}-${index}`,
|
||||
id: String(section.id),
|
||||
enabled: section.enabled !== false,
|
||||
config: normalizeConfig(section.config)
|
||||
}))
|
||||
.filter(section => {
|
||||
if (seenKeys.has(section.key)) return false
|
||||
seenKeys.add(section.key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
const sections = normalizeSections(body?.sections)
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
const authUser = token ? await getUserFromToken(token) : null
|
||||
|
||||
if (!authUser) {
|
||||
|
||||
setCookie(event, 'homepage_sections', JSON.stringify(sections), {
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
httpOnly: false,
|
||||
maxAge: 60 * 60 * 24 * 180
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
storage: 'cookie',
|
||||
sections
|
||||
}
|
||||
}
|
||||
|
||||
const users = await readUsers()
|
||||
const userIndex = users.findIndex(user => user.id === authUser.id)
|
||||
if (userIndex < 0) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: 'Benutzer nicht gefunden.'
|
||||
})
|
||||
}
|
||||
|
||||
const current = users[userIndex]
|
||||
users[userIndex] = {
|
||||
...current,
|
||||
homepageSettings: {
|
||||
sections,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
await writeUsers(users)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
storage: 'user',
|
||||
sections
|
||||
}
|
||||
})
|
||||
61
server/api/homepage/spielplan-options.get.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import { listSpielplanSeasons, readSpielplanData, validateSeasonSlug } from '../../utils/spielplan-data.js'
|
||||
|
||||
function teamLabel(teamName, teamAgeGroup) {
|
||||
const name = String(teamName || '').trim()
|
||||
const age = String(teamAgeGroup || '').trim()
|
||||
if (!name) return ''
|
||||
const isYouth = age.toLowerCase().includes('jugend') || name.toLowerCase().includes('jugend')
|
||||
return isYouth ? `(J) ${name}` : name
|
||||
}
|
||||
|
||||
function extractHarheimerTeams(rows) {
|
||||
const seen = new Set()
|
||||
const teams = []
|
||||
|
||||
const addTeam = (teamName, teamAgeGroup) => {
|
||||
const name = String(teamName || '').trim()
|
||||
if (!name) return
|
||||
const age = String(teamAgeGroup || '').trim()
|
||||
const key = `${name}||${age}`
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
teams.push({
|
||||
key,
|
||||
label: teamLabel(name, age),
|
||||
teamName: name,
|
||||
teamAgeGroup: age
|
||||
})
|
||||
}
|
||||
|
||||
for (const row of rows || []) {
|
||||
if (String(row.HeimVereinName || '').trim() === 'Harheimer TC') {
|
||||
addTeam(row.HeimMannschaft, row.HeimMannschaftAltersklasse)
|
||||
}
|
||||
if (String(row.GastVereinName || '').trim() === 'Harheimer TC') {
|
||||
addTeam(row.GastMannschaft, row.GastMannschaftAltersklasse)
|
||||
}
|
||||
}
|
||||
|
||||
return teams.sort((a, b) => a.label.localeCompare(b.label, 'de'))
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event)
|
||||
if (query.season && !validateSeasonSlug(query.season)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Ungültiger Saison-Slug.'
|
||||
})
|
||||
}
|
||||
|
||||
const seasons = await listSpielplanSeasons()
|
||||
const selectedSeason = String(query.season || seasons[0]?.slug || '')
|
||||
const dataResult = await readSpielplanData(selectedSeason ? { season: selectedSeason } : {})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
selectedSeason,
|
||||
seasons,
|
||||
teams: extractHarheimerTeams(dataResult.data)
|
||||
}
|
||||
})
|
||||