51 Commits

Author SHA1 Message Date
Torsten Schulz (local)
5c3d78245f feat: add Datenschutzerklärung and Konto löschen pages
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 2m10s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
- Created datenschutz.vue for the privacy policy with sections on data protection, responsible entity, data processing, rights, and contact information.
- Created konto-loeschen.vue for account deletion requests, detailing the process, affected data, and processing time.
- Added anonymize-playstore-screenshot.sh script for image anonymization using ImageMagick.
- Introduced playstore-assets.mjs for generating Play Store assets, including icons and feature graphics, using sharp.
- Added playstore-assets.sh script to execute the asset generation script.
2026-05-29 16:51:36 +02:00
Torsten Schulz (local)
f5045c3cf0 feat: implement production release configuration and signing for Play Store 2026-05-29 16:39:59 +02:00
Torsten Schulz (local)
696c50f0fc fix: update ResponseBody creation in ApiService mock for consistency 2026-05-29 16:13:08 +02:00
Torsten Schulz (local)
b8bdbf0a8d feat: add homepage components and API for settings and spielplan options
- Introduced new Vue components for homepage teasers: HomeLinksTeaser, HomeSpielplanTeamWidget, HomeTrainingTeaser, and HomeVereinsmeisterschaftenTeaser.
- Created XML layout for tablet app window dump.
- Implemented API endpoints for fetching and updating homepage settings.
- Added API for retrieving spielplan options, including team extraction logic.
2026-05-29 15:37:45 +02:00
Torsten Schulz (local)
1ea9596006 Refactor code structure for improved readability and maintainability 2026-05-29 10:55:59 +02:00
Torsten Schulz (local)
cdbe71eaec Refactor code structure for improved readability and maintainability 2026-05-29 08:52:03 +02:00
Torsten Schulz (local)
125a00819d Refactor code structure for improved readability and maintainability 2026-05-29 00:13:12 +02:00
Torsten Schulz (local)
b4c31374c0 feat(android): sort CMS users, remove invite button, close CMS submenu on navigate 2026-05-29 00:11:42 +02:00
Torsten Schulz (local)
c8b7f5ec2e test: fix ViewModel unit tests (Cms/Gallery) and enable ByteBuddy experimental flag 2026-05-28 09:42:01 +02:00
Torsten Schulz (local)
0528334eb4 feat: replace success modal with non-blocking toast notification
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m10s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m14s
feat: add global event listener for mannschaften updates in Navigation component

feat: notify app of mannschaften changes after CSV save and handle visibility changes

refactor: remove unused anlagen page

fix: update CmsMannschaften reference in sportbetrieb page for reactivity

fix: enhance authentication token retrieval in passkey API endpoints

feat: implement refresh session and access token generation for Android clients in passkey login

fix: unify token retrieval method across passkey API endpoints

feat: add MediaTypes utility for JSON content type in Android app

feat: create PasskeyRepository for handling passkey authentication and registration in Android app

feat: add validated text field and rich text components for Android UI

feat: implement newsletter subscription and unsubscription screens in Android app

feat: create public pages including Impressum with dynamic content loading
2026-05-28 08:33:28 +02:00
Torsten Schulz (local)
e033d716dd feat: Add CMS and Member Area screens with ViewModels
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m23s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m18s
- Implemented CmsViewModel to manage CMS data loading and state.
- Created MemberAreaDetailScreens for displaying member information and news.
- Added MembersViewModel and MemberNewsViewModel for managing member data and news.
- Developed MemberAreaScreen to provide navigation and display member-related options.
- Introduced ProfileScreen and ProfileViewModel for user profile management.
- Implemented state management for loading, error handling, and form updates across screens.
2026-05-28 08:01:35 +02:00
Torsten Schulz (local)
e195d5d189 chore(version): update version to 1.6.1
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m6s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m13s
2026-05-27 20:45:37 +02:00
Torsten Schulz (local)
533e89c405 chore(ci): trigger analyze job after lint fixes
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m5s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m12s
2026-05-27 20:34:27 +02:00
Torsten Schulz (local)
083244bc83 chore(lint): run eslint --fix; add safe global fallbacks and fix empty catches
Some checks failed
Code Analysis and Production Deploy / deploy-production (push) Has been cancelled
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
Code Analysis and Production Deploy / analyze (push) Has been cancelled
2026-05-27 20:33:08 +02:00
Torsten Schulz (local)
9def0fdc32 chore(lint): add safe fallbacks for Nitro globals (getMethod/getRequestURL) in passkey and middleware handlers
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 2m50s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-27 20:02:46 +02:00
Torsten Schulz (local)
512756cb48 chore(lint): manual fixes - remove redundant global declarations; add safe getMethod fallback; remove unused catch vars
Some checks failed
Code Analysis and Production Deploy / deploy-production (push) Has been cancelled
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
Code Analysis and Production Deploy / analyze (push) Has been cancelled
2026-05-27 20:00:48 +02:00
Torsten Schulz (local)
19d2f21fc3 refactor(security): use centralized getServerDataPath in password-reset-log.js
Some checks failed
Code Analysis and Production Deploy / analyze (push) Successful in 5m5s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
2026-05-27 19:55:11 +02:00
Torsten Schulz (local)
5074e8f8f8 fix(security): centralize data path validation in getServerDataPath; enforce segment whitelist and resolved-path check
Some checks failed
Code Analysis and Production Deploy / deploy-production (push) Has been cancelled
Code Analysis and Production Deploy / analyze (push) Has been cancelled
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
2026-05-27 19:53:59 +02:00
Torsten Schulz (local)
18a08b0e7a fix(security): validate resolved data path to prevent path traversal
Some checks failed
Code Analysis and Production Deploy / deploy-production (push) Has been cancelled
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
Code Analysis and Production Deploy / analyze (push) Has been cancelled
2026-05-27 19:51:15 +02:00
Torsten Schulz (local)
026e4ba3e4 fix(password-reset-log): improve filename sanitization and error handling in getDataPath
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m3s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-27 19:41:12 +02:00
Torsten Schulz (local)
58fd7fa5c6 feat(auth): implement Android refresh token handling and session management
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m7s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
- Added support for generating Android access tokens and managing refresh sessions in the auth endpoints.
- Implemented new tests for login, logout, and refresh functionalities specific to Android clients.
- Enhanced password reset logging with normalization and masking of email addresses.
- Created a new diagnostics endpoint for password reset attempts, including filtering and summarizing logs.
- Introduced a new utility for managing password reset logs with retention policies.
- Added tests for password reset log utilities to ensure proper functionality and privacy compliance.
- Updated WebAuthn configuration tests to validate origin handling for production and allowed origins.
2026-05-27 19:34:53 +02:00
755442fb70 Merge branch 'main' into dev
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m5s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m15s
2026-05-27 19:05:18 +02:00
d31515bc79 Merge pull request 'dev' (#33) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m7s
Code Analysis and Production Deploy / deploy-production (push) Successful in 4m37s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #33
2026-05-27 19:04:15 +02:00
b729e90e38 Merge pull request 'dev' (#32) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m53s
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m7s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #32
2026-05-22 14:05:21 +02:00
92c0610dcb Merge pull request 'dev' (#31) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m45s
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m1s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #31
2026-05-21 00:09:31 +02:00
6db05c1dc6 Merge pull request 'dev' (#30) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m48s
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m4s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #30
2026-05-20 18:42:31 +02:00
68b6ab593a Merge pull request 'fix(ci): avoid shell-expansion in node version-compare helper' (#29) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m46s
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m4s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #29
2026-05-20 17:08:28 +02:00
4e42ddfee4 Merge pull request 'ci: ensure version-check scripts are executable; invoke via bash' (#28) from dev into main
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 13s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #28
2026-05-20 17:00:19 +02:00
6a6b8e0a1b Merge pull request 'ci: disable PR version-gate; use prod-version check on main' (#27) from dev into main
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 16s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #27
2026-05-20 16:11:57 +02:00
ddb170cffc Merge pull request 'chore: bump version to 1.4.7' (#26) from dev into main
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 11s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #26
2026-05-20 15:46:46 +02:00
66f2718714 Merge pull request 'ci: run analyze only on push; remove PR-specific checks' (#25) from dev into main
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 12s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #25
2026-05-20 13:16:41 +02:00
f2f5ddb8ce Merge pull request 'dev' (#24) from dev into main
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 12s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #24
2026-05-20 13:10:27 +02:00
4d13ea5de6 Merge pull request 'dev' (#23) from dev into main
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 11s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #23
2026-05-20 12:45:58 +02:00
45d343b1c1 Merge pull request 'dev' (#22) from dev into main
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 11s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #22
2026-05-20 12:24:53 +02:00
7b9bdb9a36 Merge pull request 'dev' (#21) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m43s
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m4s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #21
2026-05-20 12:09:48 +02:00
a48c65bfb8 Merge pull request 'dev' (#20) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m0s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #20
2026-05-20 11:46:54 +02:00
9a59fa5525 Merge pull request 'chore: bump version to 1.4.3; add logging and season-slug validation' (#19) from dev into main
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
Code Analysis and Production Deploy / deploy-production (push) Has been cancelled
Reviewed-on: #19
2026-05-20 11:31:42 +02:00
70acfe6d5e Merge pull request 'Aktualisiere die Version auf 1.4.3, füge Validierung für Saison-Slugs hinzu und implementiere ein Logging-System für Fehler und Informationen' (#18) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 1m51s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #18
2026-05-20 11:23:50 +02:00
01f7d075e9 Merge pull request 'Aktualisiere die Version auf 1.4.2 und füge Funktionen zur Fehlerprotokollierung und Validierung von Saison-Slugs hinzu' (#17) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 1m52s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #17
2026-05-20 11:09:48 +02:00
bb2785ba56 Merge pull request 'Refactor code structure for improved readability and maintainability' (#16) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 1m54s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #16
2026-05-20 10:59:14 +02:00
e871790ee8 Merge pull request 'Add script for importing match schedule and logging' (#15) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m4s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #15
2026-05-20 10:34:07 +02:00
b19a9f6b8e Merge pull request 'dev' (#14) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m2s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #14
2026-05-15 14:30:32 +02:00
667b65bf29 Merge pull request 'Enhance fillable template generation by adding logo and refactoring header drawing' (#13) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m1s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #13
2026-05-12 23:30:08 +02:00
21752fedcc Merge pull request 'Update package version to 1.2.0, enhance fillable template generation with underlined text fields, and update PDF templates' (#12) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m8s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #12
2026-05-12 23:05:16 +02:00
742687f82b Merge pull request 'dev' (#11) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m15s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #11
2026-05-06 16:06:18 +02:00
7152b54b68 Merge pull request 'Update candidate paths for CSV file retrieval in mannschaften.get.js' (#10) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m14s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #10
2026-05-05 15:20:52 +02:00
21b4e8bc9f Merge pull request 'dev' (#9) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 1m58s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #9
2026-04-27 16:52:43 +02:00
91fb3d79c5 Merge pull request 'dev' (#8) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m8s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #8
2026-04-27 16:46:55 +02:00
aee8705fa3 Merge pull request 'dev' (#7) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 1m56s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #7
2026-04-16 14:06:43 +02:00
99c03dccf2 Merge pull request 'Remove package version change requirement for main PRs in code-analysis.yml to streamline workflow.' (#6) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m47s
Code Analysis and Production Deploy / deploy-production (push) Successful in 1m55s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #6
2026-04-16 13:41:06 +02:00
d450175871 Merge pull request 'dev' (#5) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m47s
Code Analysis and Production Deploy / deploy-production (push) Successful in 1m58s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #5
2026-04-16 13:23:53 +02:00
178 changed files with 12449 additions and 999 deletions

View File

@@ -41,7 +41,7 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
[x] 4. CI-Build: Gradle-Config und GitHub Actions Skeleton
[x] 5. Theme: `Color.kt`, `Typography.kt`, `Theme.kt` erstellen und Tailwind-Farben mappen
[x] 6. Fonts: Inter + Montserrat einbinden (res/font oder GoogleFonts)
[ ] 7. Navigation: Compose Navigation-Graph mit Routen für alle Web-Seiten anlegen
[x] 7. Navigation: Compose Navigation-Graph mit Routen für alle Web-Seiten anlegen
[x] 7a. Umgebungen: Android-Varianten fuer lokal, Test-Instanz und Produktion mit eigener API-Basis konfigurieren
[x] 7b. Adaptive Navigation: Tablet mit persistentem Header/Hauptmenue, Smartphone mit bestehender screenbezogener Navigation
[x] 7c. Branding: vorhandenes Web-Logo als optimierte Android-Ressource in der App-Navigation verwenden
@@ -69,76 +69,103 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
- [x] `/training/trainer`
- [x] `/training/anfaenger`
- [x] `/tt-regeln`: Regelübersicht mit DTTB- und PDF-Aufruf
[ ] 10a. Weitere öffentliche bzw. bestehende Web-Routen prüfen und portieren
- [ ] `/anlagen`
- [ ] `/impressum`
- [ ] Legacy-/Doppelrouten klären: `/galerie`, `/geschichte`, `/satzung`, `/ueber-uns`, `/spielplan`, `/verein/tt-regeln`, `/mannschaft/[slug]`, `/mannschaften/herren`, `/mannschaften/damen`, `/mannschaften/jugend`
[ ] 10b. Newsletter-Screens portieren
- [ ] `/newsletter/subscribe`
- [ ] `/newsletter/unsubscribe`
- [ ] `/newsletter/confirm`, `/newsletter/confirmed`, `/newsletter/unsubscribed`
[x] 10a. Weitere öffentliche bzw. bestehende Web-Routen prüfen und portieren
- [x] `/impressum`
- [x] Legacy-/Doppelrouten geklärt: `/galerie`, `/geschichte`, `/satzung`, `/ueber-uns`, `/spielplan`, `/verein/tt-regeln`, `/mannschaft/[slug]`, `/mannschaften/herren`, `/mannschaften/damen`, `/mannschaften/jugend`
[x] 10b. Newsletter-Screens portieren
- [x] `/newsletter/subscribe`
- [x] `/newsletter/unsubscribe`
- [x] `/newsletter/confirm`, `/newsletter/confirmed`, `/newsletter/unsubscribed`
[x] 10c. Auth-Screens portieren
- [x] `/login`: Passwort-Login und Logout in der laufenden Sitzung
- [x] `/registrieren`
- [x] `/passwort-vergessen`
[ ] 10d. Mitgliederbereich portieren
- [ ] `/mitgliederbereich`: Übersicht
- [ ] `/mitgliederbereich/mitglieder`
- [ ] `/mitgliederbereich/news`
- [ ] `/mitgliederbereich/profil`
- [ ] `/mitgliederbereich/api`
[ ] 10e. CMS-Screens nach Rollenberechtigung portieren
- [ ] `/cms`, `/cms/startseite`, `/cms/inhalte`, `/cms/vereinsmeisterschaften`
- [ ] `/cms/sportbetrieb`, `/cms/mitgliederverwaltung`, `/cms/kontaktanfragen`
- [ ] `/cms/newsletter`, `/cms/einstellungen`, `/cms/benutzer`
[ ] 11. API-Client: Retrofit/Ktor-Client implementieren, Auth-Interceptor (Token Refresh)
[x] 10d. Mitgliederbereich portieren
- [x] `/mitgliederbereich`: Übersicht
- [x] `/mitgliederbereich/mitglieder`
- [x] `/mitgliederbereich/news`
- [x] `/mitgliederbereich/profil`
- [x] `/mitgliederbereich/api`
[x] 10e. CMS-Screens nach Rollenberechtigung portieren
- [x] `/cms`, `/cms/startseite`, `/cms/inhalte`, `/cms/vereinsmeisterschaften`
- [x] `/cms/sportbetrieb`, `/cms/mitgliederverwaltung`, `/cms/kontaktanfragen`
- [x] `/cms/newsletter`, `/cms/einstellungen`, `/cms/benutzer`
[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
- [ ] `POST /api/auth/refresh` anbinden und Access-Token bei Ablauf automatisch erneuern
- [ ] OkHttp-`Authenticator` mit genau einem synchronisierten Refresh-Versuch pro fehlgeschlagenem Request ergänzen
- [x] 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)
- [x] Login/Logout und verschlüsselte Token-Speicherung
- [x] Registrierung und Passwort-Reset
- [ ] Backend: JWT-Access-Token von aktuell 7 Tagen auf kurze Laufzeit (Ziel: ca. 15 Minuten) reduzieren
- [ ] Backend: langlebige, zufällige Refresh-Token pro Gerätesitzung mit serverseitig gespeichertem Token-Hash einführen
- [ ] Backend: Refresh-Token bei jeder Erneuerung rotieren und Wiederverwendung eines verbrauchten Tokens als Sitzungsdiebstahl behandeln
- [ ] Backend: Logout, Kontodeaktivierung und Passwortänderung widerrufen betroffene Refresh-Sitzungen
- [ ] App: Access- und Refresh-Token verschlüsselt speichern, Sitzung beim App-Start durch Refresh wiederherstellen
- [x] Backend: Android-JWT-Access-Token auf ca. 15 Minuten reduzieren; bestehende Web-Cookie-Sitzungen bis zur Web-Refresh-Integration kompatibel weiterführen
- [x] Backend: langlebige, zufällige Refresh-Token pro Gerätesitzung mit serverseitig gespeichertem Token-Hash einführen
- [x] Backend: Refresh-Token bei jeder Erneuerung rotieren und Wiederverwendung eines verbrauchten Tokens als Sitzungsdiebstahl behandeln
- [x] Backend: Logout, Kontodeaktivierung und Passwortänderung widerrufen betroffene Refresh-Sitzungen
- [x] App: Access- und Refresh-Token verschlüsselt speichern, Sitzung beim App-Start durch Refresh wiederherstellen
- [ ] Optional nach MVP: pro Installation ein nicht exportierbares Keystore-Schlüsselpaar erzeugen und Refresh-Requests daran binden
[ ] 13. Passkeys: Integration prüfen (FIDO2 / Passkeys) und Fallback auf Passwort
[ ] 14. Image-Upload: Multipart-Upload + Coil für Anzeige + Bildkompression (u. a. Sharp-Äquivalent evtl. serverseitig)
[ ] 15. Rich-Text: Anzeige von HTML (Compose + WebView) und ggf. Editor via WebView-bridge
[ ] 16. Formulare: Validierung (clientseitig) und Fehlerdarstellung
[ ] 17. Offline & Caching: Room für persistente Daten, Response-Caching, Sync-Strategie
[ ] 18. Lokalisierung: `strings.xml` (DE + EN) und i18n-Check
[ ] 19. Accessibility: ContentDescription, Focus, Farben/Kontrast prüfen
[x] 13. Passkeys: Integration prüfen (FIDO2 / Passkeys) und Fallback auf Passwort
- [x] Passkey-Login über Android Credential Manager an bestehende WebAuthn-Endpunkte anbinden
- [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
[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
- [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
[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 (TestBindings 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)
- [x] A. Auth (Login/Logout)
- [x] Passwort-Login und Logout in der aktuellen App-Sitzung
- [x] Persistente Statuswiederherstellung/Logout für die Auth-Endpunkte
- [ ] Dauerhaftes Eingeloggtbleiben durch rotierendes Refresh-Token pro Android-Gerätesitzung
- [x] 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)
- Dauerhaftes Android-Login umsetzen: Backend-Refresh-Sitzungen, Token-Rotation, serverseitigen Widerruf und App-Refresh-Flow ergänzen.
- Passkey-Anmeldung über Android Credential Manager anbinden.
- Web-Login bei Bedarf auf denselben Refresh-Flow migrieren; bis dahin bleiben Web-Cookie-Sitzungen bewusst kompatibel.
- 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.
- Mitgliederbereich/CMS-Screens portieren; dabei rollenabhängige `Intern`-Navigation und Bearer-Unterstützung der 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.
@@ -158,6 +185,21 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
- 2026-05-27: Tabellenraster in den Mannschaftsdetails mit gemeinsamen Spaltenbreiten für Tablet und Smartphone ausgerichtet; die Zustandswiederherstellung dynamischer Mannschaftslinks korrigiert.
- 2026-05-27: Die verbleibenden öffentlichen Screens aus Punkt 10 portiert: Verein/CMS-Inhalte, Vorstand, Satzung/PDF, Links, Vereinsmeisterschaften mit Personenbild-Dialog, Spielsysteme und TT-Regeln.
- 2026-05-27: Architektur für dauerhaftes Android-Login festgelegt: kein eingebetteter App-Key, sondern kurzlebige Access-Tokens und rotierende, widerrufbare Refresh-Tokens pro Gerätesitzung; optionale spätere Gerätebindung per Keystore-Schlüsselpaar.
- 2026-05-27: Dauerhaftes Android-Login umgesetzt: Android-Logins erhalten 15-Minuten-Access-Tokens und rotierende Refresh-Tokens; Token-Hashes, Wiederverwendungswiderruf, Logout-/Reset-/Deaktivierungswiderruf sowie verschlüsselte App-Speicherung und automatischer OkHttp-Refresh sind implementiert.
- 2026-05-27: Native Profilseite des Mitgliederbereichs umgesetzt; `/api/profile` akzeptiert nun Bearer-Tokens, die App kann Profil-/Sichtbarkeitsdaten bearbeiten und Passwortänderungen auslösen.
- 2026-05-27: Login leitet in der Android-App nun direkt zur Mitgliederbereich-Übersicht weiter; `/mitgliederbereich` ist nativ mit Kacheln und Geburtstagsliste umgesetzt, `/api/birthdays` akzeptiert Bearer-Tokens.
- 2026-05-28: 10d und 10e abgeschlossen: Mitgliederliste, interne News, API-Doku sowie alle CMS-Routen sind nativ angebunden; relevante Mitglieder-/News-/CMS-Endpunkte akzeptieren Bearer-Tokens.
- 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`.
@@ -166,8 +208,29 @@ 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 TestToDos:**
- 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
- Ausgangslage: Das Backend gibt derzeit ein sieben Tage gültiges JWT aus. Die App speichert es bereits verschlüsselt und sendet es als Bearer-Token. Die vorhandene serverseitige Sessiondatei wird beim Authentifizieren geschützter Requests derzeit nicht zur Widerrufsprüfung herangezogen.
- 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.
- Token-Modell:
- Access-Token: JWT mit kurzer Laufzeit, Zielwert ca. 15 Minuten; wird für normale API-Requests als Bearer-Token verwendet.
@@ -194,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: 710 Arbeitstage
- Hardening + Tests: 35 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
- WebStatus: Die WebUI bietet bereits umfassende CMSUIs für `cms/startseite`, `cms/vereinsmeisterschaften`, `cms/sportbetrieb` und `cms/einstellungen` (Drag&Drop, CSVImport/Export, TabbedUIs, ImageUpload, nativelike Modals). `cms/startseite` speichert `homepage.sections` via `PUT /api/config`, `vereinsmeisterschaften` arbeitet mit CSVExport/Import, `sportbetrieb` kapselt Termine/Mannschaften/Spielpläne in Tabs, `einstellungen` ist ein umfangreicher ConfigEditor.
- AndroidStatus: Implementiert — die AndroidApp enthält native CMSScreens (`CmsStartseiteScreen`, `CmsVereinsmeisterschaftenScreen`, `CmsSportbetriebScreen`, `CmsEinstellungenScreen`) mit Save/LoadFlows via `CmsViewModel`.
- Umsetzung (B5.x):
- [x] B5.1: `cms/startseite` (StartseitenLayout) — Reorderable/Visibility + Save → `PUT /api/config` (via `CmsViewModel`).
- [x] B5.2: `cms/vereinsmeisterschaften` — CSVParser/CSVSave 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)
- WebStatus: `cms/passwort-reset-diagnose` zeigt vollständige DiagnoseUI mit Suche, Maskierung, Filter (nur Auffälligkeiten) und listbaren ResetVersuchen; Backend: `/api/cms/password-reset-diagnostics` liefert `matchingUsers`, `attempts`, `retentionHours`.
- AndroidStatus: umgesetzt — native DiagnoseUI mit Suche (`email`/Name), Filter `Nur Auffälligkeiten`, `matchingUsers`Liste mit Schnellfilter, detaillierter Schrittansicht je Versuch (Zeit/Schritt/Status/Grund), Refresh und ShareExport der maskierten Logs.
- Konkrete AndroidToDos (B6.x):
- [x] B6.1: Implementieren Suche + Filter UI, Rendering der `attempts` mit Zeitstempeln, StatusBadges und Details.
- [x] B6.2: Logs exportieren / share (falls API Export unterstützt) und Datenschutz: EMail 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.

View 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

View File

@@ -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 {
@@ -94,9 +196,16 @@ dependencies {
implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
implementation("com.squareup.moshi:moshi-kotlin:1.15.1")
// Passkeys / Credential Manager
implementation("androidx.credentials:credentials:1.6.0")
implementation("androidx.credentials:credentials-play-services-auth:1.6.0")
// 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")
@@ -109,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")
}

Binary file not shown.

2
android-app/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,2 @@
# Project-specific R8/ProGuard rules for release builds.
# Keep this file intentionally minimal and add rules only when needed.

View 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>

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -0,0 +1,5 @@
package de.harheimertc.ui
import androidx.activity.ComponentActivity
class TestActivity : ComponentActivity()

View File

@@ -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
}
}
}

View File

@@ -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))
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -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())
}
}

View File

@@ -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
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View 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>

View File

@@ -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"

View File

@@ -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()
}

View File

@@ -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)
}
}

View File

@@ -0,0 +1,39 @@
package de.harheimertc.data
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AccessTokenAuthenticator @Inject constructor(
private val sessionRefresher: SessionRefresher,
) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
if (responseCount(response) >= 2) return null
if (response.request.url.encodedPath in setOf("/api/auth/login", "/api/auth/logout", "/api/auth/refresh")) {
return null
}
val currentAccessToken = response.request.header("Authorization")
?.removePrefix("Bearer ")
?.takeIf(String::isNotBlank)
val refreshedToken = sessionRefresher.refreshAccessTokenBlocking(currentAccessToken) ?: return null
return response.request.newBuilder()
.header("Authorization", "Bearer $refreshedToken")
.build()
}
private fun responseCount(response: Response): Int {
var current: Response? = response
var count = 0
while (current != null) {
count += 1
current = current.priorResponse
}
return count
}
}

View File

@@ -3,15 +3,23 @@ package de.harheimertc.data
import com.squareup.moshi.Json
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 = "",
@@ -77,11 +85,57 @@ data class NewsDto(
val title: String = "",
val content: String = "",
val created: String? = null,
val updated: String? = null,
val author: String? = null,
val isPublic: Boolean = false,
val isHidden: Boolean = false,
val expiresAt: String? = null,
)
data class NewsResponse(
val success: Boolean = false,
val news: List<NewsDto> = emptyList(),
)
data class NewsSaveRequest(
val id: Int? = null,
val title: String,
val content: String,
val isPublic: Boolean = false,
val isHidden: Boolean = false,
val expiresAt: String? = null,
)
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,
@@ -106,7 +160,12 @@ data class MembershipResponse(
val message: String? = null,
val downloadUrl: String? = null,
)
data class LoginRequest(val email: String, val password: String)
data class LoginRequest(
val email: String,
val password: String,
val client: String = "android",
val deviceName: String = "Harheimer TC Android-App",
)
data class AuthUserDto(
val id: String? = null,
val email: String = "",
@@ -116,9 +175,14 @@ data class AuthUserDto(
data class LoginResponse(
val success: Boolean = false,
val token: String? = null,
val accessToken: String? = null,
val refreshToken: String? = null,
val sessionId: String? = null,
val user: AuthUserDto? = null,
val role: String? = null,
)
data class RefreshRequest(val refreshToken: String)
data class LogoutRequest(val refreshToken: String? = null)
data class AuthStatusResponse(
val isLoggedIn: Boolean = false,
val user: AuthUserDto? = null,
@@ -127,6 +191,97 @@ 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",
)
data class PasskeyRegistrationOptionsRequest(
val preferredAuthenticatorType: String? = null,
val client: String = "android",
)
data class PasskeyDto(
val id: String = "",
val credentialId: String = "",
val createdAt: String? = null,
val lastUsedAt: String? = null,
val name: String = "",
)
data class PasskeysResponse(
val success: Boolean = false,
val passkeys: List<PasskeyDto> = emptyList(),
)
data class RemovePasskeyRequest(val credentialId: String)
data class ProfileVisibilityDto(
val showEmail: Boolean = true,
val showPhone: Boolean = true,
val showAddress: Boolean = false,
val showBirthday: Boolean = true,
)
data class ProfileUserDto(
val id: String? = null,
val name: String = "",
val email: String = "",
val phone: String = "",
val geburtsdatum: String = "",
val visibility: ProfileVisibilityDto = ProfileVisibilityDto(),
val roles: List<String> = emptyList(),
val role: String? = null,
)
data class ProfileResponse(
val success: Boolean = false,
val message: String? = null,
val user: ProfileUserDto? = null,
)
data class ProfileUpdateRequest(
val name: String,
val email: String,
val phone: String? = null,
val geburtsdatum: String? = null,
val visibility: ProfileVisibilityDto,
val currentPassword: String? = null,
val newPassword: String? = null,
)
data class BirthdayDto(
val name: String = "",
val dayMonth: String = "",
val inDays: Int = 0,
)
data class BirthdaysResponse(
val success: Boolean = false,
val birthdays: List<BirthdayDto> = emptyList(),
)
data class MemberDto(
val id: String? = null,
val name: String = "",
val firstName: String = "",
val lastName: String = "",
val email: String? = null,
val phone: String? = null,
val address: String? = null,
val birthday: String? = null,
val geburtsdatum: String? = null,
val source: String = "",
val notes: String = "",
val hasLogin: Boolean = false,
val editable: Boolean = false,
val isMannschaftsspieler: Boolean = false,
val hasHallKey: Boolean = false,
val loginRoles: List<String> = emptyList(),
)
data class MembersResponse(
val success: Boolean = false,
val members: List<MemberDto> = emptyList(),
)
data class RegistrationVisibility(val showBirthday: Boolean)
data class RegistrationRequest(
val name: String,
@@ -180,18 +335,57 @@ data class VorstandDto(
val sportwart: BoardMemberDto = BoardMemberDto(),
val jugendwart: BoardMemberDto = BoardMemberDto(),
)
data class VereinDto(
val name: String = "",
val strasse: String = "",
val plz: String = "",
val ort: String = "",
val useVorsitzenderAddress: Boolean = false,
)
data class WebsiteResponsibleDto(
val vorname: String = "",
val nachname: String = "",
val email: String = "",
)
data class WebsiteDto(
val verantwortlicher: WebsiteResponsibleDto = WebsiteResponsibleDto(),
)
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 = "",
@@ -204,8 +398,110 @@ 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 = "",
val email: String? = null,
val name: String = "",
val roles: List<String> = emptyList(),
val role: String? = null,
val phone: String = "",
val active: Boolean = true,
val created: String? = null,
val lastLogin: String? = null,
)
data class CmsUsersResponse(val users: List<CmsUserDto> = emptyList())
data class ContactRequestDto(
val id: String = "",
val name: String = "",
val email: String = "",
val phone: String? = null,
val message: String = "",
val status: String = "",
val createdAt: String? = null,
val repliedAt: String? = null,
)
data class NewsletterDto(
val id: String = "",
val subject: String = "",
val title: String = "",
val createdAt: String? = null,
val sentAt: String? = null,
val status: String? = null,
)
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 = "",
val description: String = "",
val subscribers: List<String> = emptyList(),
val createdAt: String? = null,
)
data class NewsletterGroupsResponse(
val success: Boolean = false,
val groups: List<NewsletterGroupDto> = emptyList(),
)
data class NewsletterSubscriptionRequest(
val groupId: String,
val email: String,
val name: String? = null,
)
data class PasswordResetStepDto(
val ts: String? = null,
val step: String = "",
val status: String = "",
val reason: String? = null,
val errorCode: String? = null,
val errorMessage: String? = null,
)
data class PasswordResetAttemptDto(
val requestId: String = "",
val startedAt: String? = null,
val emailMasked: String? = null,
val ip: String? = null,
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(),
)
interface ApiService {
@@ -213,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>>
@@ -233,18 +541,33 @@ interface ApiService {
@GET("/api/news-public")
suspend fun publicNews(): Response<NewsPublicResponse>
@GET("/api/news")
suspend fun memberNews(): Response<NewsResponse>
@POST("/api/news")
suspend fun saveNews(@Body request: NewsSaveRequest): Response<AuthMessageResponse>
@DELETE("/api/news")
suspend fun deleteNews(@Query("id") id: Int): Response<AuthMessageResponse>
@GET("/api/mannschaften")
suspend fun mannschaften(@Query("season") season: String? = null): Response<ResponseBody>
@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>
@@ -256,7 +579,10 @@ interface ApiService {
suspend fun login(@Body request: LoginRequest): Response<LoginResponse>
@POST("/api/auth/logout")
suspend fun logout(): Response<Unit>
suspend fun logout(@Body request: LogoutRequest): Response<Unit>
@POST("/api/auth/refresh")
suspend fun refresh(@Body request: RefreshRequest): Response<LoginResponse>
@GET("/api/auth/status")
suspend fun authStatus(): Response<AuthStatusResponse>
@@ -266,4 +592,133 @@ interface ApiService {
@POST("/api/auth/register")
suspend fun register(@Body request: RegistrationRequest): Response<AuthMessageResponse>
@POST("/api/auth/passkeys/authentication-options")
suspend fun passkeyAuthenticationOptions(@Body request: PasskeyAuthenticationOptionsRequest): Response<ResponseBody>
@POST("/api/auth/passkeys/login")
suspend fun passkeyLogin(@Body request: RequestBody): Response<LoginResponse>
@GET("/api/auth/passkeys/list")
suspend fun passkeys(): Response<PasskeysResponse>
@POST("/api/auth/passkeys/registration-options")
suspend fun passkeyRegistrationOptions(@Body request: PasskeyRegistrationOptionsRequest): Response<ResponseBody>
@POST("/api/auth/passkeys/register")
suspend fun registerPasskey(@Body request: RequestBody): Response<AuthMessageResponse>
@POST("/api/auth/passkeys/remove")
suspend fun removePasskey(@Body request: RemovePasskeyRequest): Response<AuthMessageResponse>
@GET("/api/profile")
suspend fun profile(): Response<ProfileResponse>
@retrofit2.http.PUT("/api/profile")
suspend fun updateProfile(@Body request: ProfileUpdateRequest): Response<ProfileResponse>
@GET("/api/birthdays")
suspend fun birthdays(): Response<BirthdaysResponse>
@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>
@POST("/api/newsletter/subscribe")
suspend fun subscribeNewsletter(@Body request: NewsletterSubscriptionRequest): Response<AuthMessageResponse>
@POST("/api/newsletter/unsubscribe-by-email")
suspend fun unsubscribeNewsletter(@Body request: NewsletterSubscriptionRequest): Response<AuthMessageResponse>
@GET("/api/newsletter/confirm")
suspend fun confirmNewsletter(@Query("token") token: String): Response<AuthMessageResponse>
@GET("/api/cms/password-reset-diagnostics")
suspend fun passwordResetDiagnostics(
@Query("email") email: String? = null,
@Query("failedOnly") failedOnly: Boolean = true,
): Response<PasswordResetDiagnosticsResponse>
}

View File

@@ -0,0 +1,7 @@
package de.harheimertc.data
import okhttp3.MediaType.Companion.toMediaType
object MediaTypes {
val json = "application/json; charset=utf-8".toMediaType()
}

View File

@@ -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,49 @@ object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(authInterceptor: AuthInterceptor): 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()
}
@@ -43,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()
@@ -53,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)
}
}

View File

@@ -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
}

View File

@@ -0,0 +1,64 @@
package de.harheimertc.data
import com.squareup.moshi.Moshi
import de.harheimertc.BuildConfig
import de.harheimertc.repositories.AuthRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SessionRefresher @Inject constructor(
private val authRepository: AuthRepository,
moshi: Moshi,
) {
private val lock = Any()
private val client = OkHttpClient.Builder().build()
private val requestAdapter = moshi.adapter(RefreshRequest::class.java)
private val responseAdapter = moshi.adapter(LoginResponse::class.java)
suspend fun refreshAccessToken(): Boolean = withContext(Dispatchers.IO) {
refreshAccessTokenBlocking() != null
}
fun refreshAccessTokenBlocking(requestToken: String? = null): String? = synchronized(lock) {
val currentToken = authRepository.getToken()
if (!requestToken.isNullOrBlank() && !currentToken.isNullOrBlank() && currentToken != requestToken) {
return@synchronized currentToken
}
val refreshToken = authRepository.getRefreshToken()?.takeIf(String::isNotBlank)
?: return@synchronized null
val payload = requestAdapter.toJson(RefreshRequest(refreshToken))
val request = Request.Builder()
.url(BuildConfig.API_BASE_URL + "api/auth/refresh")
.post(payload.toRequestBody("application/json".toMediaType()))
.build()
try {
client.newCall(request).execute().use { response ->
if (response.code == 401 || response.code == 403) {
authRepository.clearSession()
return@synchronized null
}
if (!response.isSuccessful) return@synchronized null
val tokens = response.body?.string()?.let(responseAdapter::fromJson)
?: return@synchronized null
val accessToken = (tokens.accessToken ?: tokens.token)?.takeIf(String::isNotBlank)
?: return@synchronized null
val nextRefreshToken = tokens.refreshToken?.takeIf(String::isNotBlank)
?: return@synchronized null
authRepository.setSession(accessToken, nextRefreshToken, tokens.sessionId)
accessToken
}
} catch (_: Exception) {
null
}
}
}

View File

@@ -1,8 +1,13 @@
package de.harheimertc.repositories
import kotlinx.coroutines.flow.StateFlow
interface AuthRepository {
fun getToken(): String?
fun setToken(token: String?)
fun getRefreshToken(): String?
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?
}

View File

@@ -4,12 +4,18 @@ 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"
private val preferences by lazy {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
@@ -25,9 +31,42 @@ class AuthRepositoryImpl @Inject constructor(@param:ApplicationContext private v
override fun getToken(): String? = preferences.getString(tokenKey, null)
override fun setToken(token: String?) {
override fun getRefreshToken(): String? = preferences.getString(refreshTokenKey, null)
override fun getSessionId(): String? = preferences.getString(sessionIdKey, null)
override fun setSession(accessToken: String?, refreshToken: String?, sessionId: String?) {
preferences.edit().apply {
if (token == null) remove(tokenKey) else putString(tokenKey, token)
if (accessToken == null) remove(tokenKey) else putString(tokenKey, accessToken)
if (refreshToken == null) remove(refreshTokenKey) else putString(refreshTokenKey, refreshToken)
if (sessionId == null) remove(sessionIdKey) else putString(sessionIdKey, sessionId)
}.apply()
}
override fun clearSession() {
preferences.edit()
.remove(tokenKey)
.remove(refreshTokenKey)
.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
}
}

View File

@@ -0,0 +1,319 @@
package de.harheimertc.repositories
import de.harheimertc.data.ApiService
import de.harheimertc.data.CmsUsersResponse
import de.harheimertc.data.ConfigResponse
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,
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 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 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 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> =
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(
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
}

View File

@@ -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,
)

View File

@@ -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"
}
}

View File

@@ -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")
}
}

View File

@@ -5,8 +5,10 @@ import de.harheimertc.data.LoginRequest
import de.harheimertc.data.LoginResponse
import de.harheimertc.data.AuthStatusResponse
import de.harheimertc.data.AuthMessageResponse
import de.harheimertc.data.LogoutRequest
import de.harheimertc.data.RegistrationRequest
import de.harheimertc.data.ResetPasswordRequest
import de.harheimertc.data.SessionRefresher
import javax.inject.Inject
import javax.inject.Singleton
@@ -14,31 +16,40 @@ import javax.inject.Singleton
class LoginRepository @Inject constructor(
private val api: ApiService,
private val authRepository: AuthRepository,
private val sessionRefresher: SessionRefresher,
) {
suspend fun login(email: String, password: String): Result<LoginResponse> = runCatching {
val response = api.login(LoginRequest(email.trim(), password))
if (!response.isSuccessful) error("Anmeldung fehlgeschlagen. Bitte prüfen Sie Ihre Zugangsdaten.")
val body = response.body() ?: error("Leere Antwort")
val token = body.token?.takeIf(String::isNotBlank)
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
?: error("Der Server hat kein Zugriffstoken geliefert.")
authRepository.setToken(token)
authRepository.setSession(token, body.refreshToken, body.sessionId)
body
}
suspend fun logout(): Result<Unit> = runCatching {
try {
api.logout()
api.logout(LogoutRequest(authRepository.getRefreshToken()))
} finally {
authRepository.setToken(null)
authRepository.clearSession()
}
}
suspend fun status(): Result<AuthStatusResponse> = runCatching {
if (authRepository.getToken().isNullOrBlank()) return@runCatching AuthStatusResponse()
val response = api.authStatus()
if (authRepository.getToken().isNullOrBlank() && !sessionRefresher.refreshAccessToken()) {
return@runCatching AuthStatusResponse()
}
var response = api.authStatus()
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
val status = response.body() ?: AuthStatusResponse()
if (!status.isLoggedIn) authRepository.setToken(null)
var status = response.body() ?: AuthStatusResponse()
if (!status.isLoggedIn && authRepository.getRefreshToken() != null && sessionRefresher.refreshAccessToken()) {
response = api.authStatus()
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
status = response.body() ?: AuthStatusResponse()
}
if (!status.isLoggedIn && authRepository.getRefreshToken() == null) authRepository.clearSession()
status
}

View File

@@ -0,0 +1,108 @@
package de.harheimertc.repositories
import de.harheimertc.data.ApiService
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,
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> =
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> =
fetchEncryptedFallback(
load = {
val response = api.memberNews()
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)
}
}
}

View File

@@ -0,0 +1,33 @@
package de.harheimertc.repositories
import de.harheimertc.data.ApiService
import de.harheimertc.data.AuthMessageResponse
import de.harheimertc.data.NewsletterGroupsResponse
import de.harheimertc.data.NewsletterSubscriptionRequest
import javax.inject.Inject
class NewsletterRepository @Inject constructor(private val api: ApiService) {
suspend fun groups(): Result<NewsletterGroupsResponse> = runCatching {
val response = api.publicNewsletterGroups()
if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.")
response.body() ?: error("Leere Antwort vom Server.")
}
suspend fun subscribe(groupId: String, email: String, name: String?): Result<AuthMessageResponse> = runCatching {
val response = api.subscribeNewsletter(NewsletterSubscriptionRequest(groupId, email.trim(), name?.trim().orEmpty()))
if (!response.isSuccessful) error("Newsletter-Anmeldung fehlgeschlagen.")
response.body() ?: AuthMessageResponse(success = true, message = "Eine Bestätigungsmail wurde versendet.")
}
suspend fun unsubscribe(groupId: String, email: String): Result<AuthMessageResponse> = runCatching {
val response = api.unsubscribeNewsletter(NewsletterSubscriptionRequest(groupId, email.trim()))
if (!response.isSuccessful) error("Newsletter-Abmeldung fehlgeschlagen.")
response.body() ?: AuthMessageResponse(success = true, message = "Sie wurden abgemeldet.")
}
suspend fun confirm(token: String): Result<AuthMessageResponse> = runCatching {
val response = api.confirmNewsletter(token)
if (!response.isSuccessful) error("Newsletter-Bestätigung fehlgeschlagen.")
response.body() ?: AuthMessageResponse(success = true, message = "Newsletter-Anmeldung bestätigt.")
}
}

View File

@@ -0,0 +1,114 @@
package de.harheimertc.repositories
import android.content.Context
import androidx.credentials.CreatePublicKeyCredentialRequest
import androidx.credentials.CreatePublicKeyCredentialResponse
import androidx.credentials.CredentialManager
import androidx.credentials.GetCredentialRequest
import androidx.credentials.GetPublicKeyCredentialOption
import androidx.credentials.PublicKeyCredential
import androidx.credentials.exceptions.CreateCredentialCancellationException
import androidx.credentials.exceptions.GetCredentialCancellationException
import de.harheimertc.data.ApiService
import de.harheimertc.data.AuthMessageResponse
import de.harheimertc.data.LoginResponse
import de.harheimertc.data.MediaTypes
import de.harheimertc.data.PasskeyAuthenticationOptionsRequest
import de.harheimertc.data.PasskeyRegistrationOptionsRequest
import de.harheimertc.data.PasskeysResponse
import de.harheimertc.data.RemovePasskeyRequest
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PasskeyRepository @Inject constructor(
private val api: ApiService,
private val authRepository: AuthRepository,
) {
suspend fun login(context: Context, email: String?): Result<LoginResponse> = runCatching {
val optionsResponse = api.passkeyAuthenticationOptions(
PasskeyAuthenticationOptionsRequest(email = email?.trim()?.takeIf(String::isNotBlank)),
)
if (!optionsResponse.isSuccessful) error("Passkey-Anmeldung konnte nicht gestartet werden.")
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
?: error("Der Server hat keine Passkey-Optionen geliefert.")
val credentialManager = CredentialManager.create(context)
val credentialResponse = credentialManager.getCredential(
context = context,
request = GetCredentialRequest(
credentialOptions = listOf(GetPublicKeyCredentialOption(optionsJson)),
),
)
val credential = credentialResponse.credential as? PublicKeyCredential
?: error("Der ausgewählte Zugang ist kein Passkey.")
val response = api.passkeyLogin(
JSONObject()
.put("credential", JSONObject(credential.authenticationResponseJson))
.put("client", "android")
.put("deviceName", "Harheimer TC Android-App")
.toString()
.toRequestBody(MediaTypes.json),
)
if (!response.isSuccessful) error("Passkey-Anmeldung fehlgeschlagen.")
val body = response.body() ?: error("Leere Antwort")
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
?: error("Der Server hat kein Zugriffstoken geliefert.")
authRepository.setSession(token, body.refreshToken, body.sessionId)
body
}.recoverCredentialCancellation("Passkey-Anmeldung abgebrochen.")
suspend fun list(): Result<PasskeysResponse> = runCatching {
val response = api.passkeys()
if (!response.isSuccessful) error("Passkeys konnten nicht geladen werden.")
response.body() ?: error("Leere Antwort")
}
suspend fun add(context: Context, name: String = "Android-App"): Result<AuthMessageResponse> = runCatching {
val optionsResponse = api.passkeyRegistrationOptions(PasskeyRegistrationOptionsRequest())
if (!optionsResponse.isSuccessful) error("Passkey-Erstellung konnte nicht gestartet werden.")
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
?: error("Der Server hat keine Passkey-Optionen geliefert.")
val credentialManager = CredentialManager.create(context)
val credentialResponse = credentialManager.createCredential(
context = context,
request = CreatePublicKeyCredentialRequest(optionsJson),
) as? CreatePublicKeyCredentialResponse
?: error("Der erstellte Zugang ist kein Passkey.")
val response = api.registerPasskey(
JSONObject()
.put("credential", JSONObject(credentialResponse.registrationResponseJson))
.put("name", name)
.put("client", "android")
.toString()
.toRequestBody(MediaTypes.json),
)
if (!response.isSuccessful) error("Passkey konnte nicht hinzugefügt werden.")
response.body() ?: error("Leere Antwort")
}.recoverCredentialCancellation("Passkey-Erstellung abgebrochen.")
suspend fun remove(credentialId: String): Result<AuthMessageResponse> = runCatching {
val response = api.removePasskey(RemovePasskeyRequest(credentialId))
if (!response.isSuccessful) error("Passkey konnte nicht entfernt werden.")
response.body() ?: error("Leere Antwort")
}
private fun String.extractJsonObject(key: String): String? {
val root = JSONObject(this)
return root.optJSONObject(key)?.toString()
}
private fun <T> Result<T>.recoverCredentialCancellation(message: String): Result<T> =
recoverCatching { error ->
when (error) {
is GetCredentialCancellationException,
is CreateCredentialCancellationException -> throw IllegalStateException(message)
else -> throw error
}
}
}

View File

@@ -0,0 +1,22 @@
package de.harheimertc.repositories
import de.harheimertc.data.ApiService
import de.harheimertc.data.ProfileResponse
import de.harheimertc.data.ProfileUpdateRequest
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ProfileRepository @Inject constructor(private val api: ApiService) {
suspend fun load(): Result<ProfileResponse> = runCatching {
val response = api.profile()
if (!response.isSuccessful) error("Profil konnte nicht geladen werden.")
response.body() ?: error("Leere Antwort")
}
suspend fun save(request: ProfileUpdateRequest): Result<ProfileResponse> = runCatching {
val response = api.updateProfile(request)
if (!response.isSuccessful) error("Profil konnte nicht gespeichert werden.")
response.body() ?: error("Leere Antwort")
}
}

View File

@@ -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) {
}
}
}

View File

@@ -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) }
}
}
}
}
@@ -228,6 +270,7 @@ private fun menuSection(route: String?): MenuSection? = when (route) {
Destinations.Satzung.route,
Destinations.Vereinsmeisterschaften.route,
Destinations.Links.route,
Destinations.Impressum.route,
Destinations.Gallery.route -> MenuSection.VEREIN
Destinations.Mannschaften.route,
@@ -240,14 +283,25 @@ private fun menuSection(route: String?): MenuSection? = when (route) {
Destinations.Regeln.route -> MenuSection.TRAINING
Destinations.NewsletterSubscribe.route,
Destinations.NewsletterUnsubscribe.route -> MenuSection.NEWSLETTER
Destinations.NewsletterUnsubscribe.route,
Destinations.NewsletterConfirm.route,
Destinations.NewsletterConfirmed.route,
Destinations.NewsletterUnsubscribed.route -> MenuSection.NEWSLETTER
Destinations.MemberArea.route,
Destinations.Members.route,
Destinations.MemberNews.route,
Destinations.Profile.route,
Destinations.MemberApi.route,
Destinations.CmsStartseite.route,
Destinations.CmsInhalte.route,
Destinations.CmsVereinsmeisterschaften.route,
Destinations.CmsSportbetrieb.route,
Destinations.CmsMitgliederverwaltung.route,
Destinations.CmsNewsletter.route,
Destinations.CmsContactRequests.route,
Destinations.CmsEinstellungen.route,
Destinations.CmsBenutzer.route,
Destinations.CmsPasswordResetDiagnostics.route,
Destinations.Cms.route -> MenuSection.INTERN
else -> null
}.let { section ->
@@ -263,6 +317,7 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuT
MenuTarget("Vereinsmeisterschaften", Destinations.Vereinsmeisterschaften.route),
MenuTarget("Galerie", Destinations.Gallery.route),
MenuTarget("Links", Destinations.Links.route),
MenuTarget("Impressum", Destinations.Impressum.route),
)
MenuSection.MANNSCHAFTEN -> listOf(
MenuTarget("Übersicht", Destinations.Mannschaften.route),
@@ -279,6 +334,7 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuT
MenuSection.NEWSLETTER -> listOf(
MenuTarget("Abonnieren", Destinations.NewsletterSubscribe.route),
MenuTarget("Abmelden", Destinations.NewsletterUnsubscribe.route),
MenuTarget("Bestätigt", Destinations.NewsletterConfirmed.route),
)
MenuSection.INTERN -> buildList {
add(MenuTarget("Übersicht", Destinations.MemberArea.route))
@@ -286,9 +342,19 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuT
add(MenuTarget("News", Destinations.MemberNews.route))
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))
if (state.isAdmin) add(MenuTarget("Sportbetrieb", Destinations.CmsSportbetrieb.route))
if (state.isAdmin) add(MenuTarget("Mitgliederverwaltung", Destinations.CmsMitgliederverwaltung.route))
if (state.canAccessNewsletter) add(MenuTarget("Newsletter", Destinations.CmsNewsletter.route))
if (state.canAccessContactRequests) add(MenuTarget("Kontaktanfragen", Destinations.CmsContactRequests.route))
if (state.isAdmin) add(MenuTarget("CMS", Destinations.Cms.route))
if (state.isAdmin) add(MenuTarget("Einstellungen", Destinations.CmsEinstellungen.route))
if (state.isAdmin) add(MenuTarget("Benutzer", Destinations.CmsBenutzer.route))
if (state.isAdmin) add(MenuTarget("Reset-Diagnose", Destinations.CmsPasswordResetDiagnostics.route))
}
null -> emptyList()
}

View File

@@ -0,0 +1,56 @@
package de.harheimertc.ui.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.VisualTransformation
@Composable
fun ValidatedTextField(
value: String,
onValueChange: (String) -> Unit,
label: String,
modifier: Modifier = Modifier.fillMaxWidth(),
error: String? = null,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
visualTransformation: VisualTransformation = VisualTransformation.None,
singleLine: Boolean = true,
minLines: Int = 1,
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
isError = error != null,
supportingText = error?.let { { Text(it) } },
keyboardOptions = keyboardOptions,
visualTransformation = visualTransformation,
singleLine = singleLine,
minLines = minLines,
modifier = modifier,
)
}
@Composable
fun FormMessages(error: String?, message: String?) {
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
message?.let { Text(it, color = Color(0xFF166534)) }
}
internal fun isValidEmail(value: String): Boolean {
val trimmed = value.trim()
return trimmed.length in 5..254 &&
trimmed.count { it == '@' } == 1 &&
trimmed.substringBefore('@').isNotBlank() &&
trimmed.substringAfter('@').contains('.') &&
!trimmed.any(Char::isWhitespace)
}
internal fun isValidIsoDate(value: String): Boolean =
value.trim().matches(Regex("\\d{4}-\\d{2}-\\d{2}"))

View File

@@ -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))
}
}
}

View File

@@ -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

View File

@@ -0,0 +1,31 @@
package de.harheimertc.ui.components
import android.text.method.LinkMovementMethod
import android.widget.TextView
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.text.HtmlCompat
@Composable
fun RichText(
html: String,
modifier: Modifier = Modifier.fillMaxWidth(),
) {
AndroidView(
modifier = modifier,
factory = { context ->
TextView(context).apply {
textSize = 17f
setTextColor(android.graphics.Color.rgb(63, 63, 70))
movementMethod = LinkMovementMethod.getInstance()
setLineSpacing(0f, 1.2f)
}
},
update = { textView ->
textView.text = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT)
},
)
}

View File

@@ -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("&nbsp;", " ")
.trim()
fun escapeHtml(value: String): String = value
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")

View File

@@ -8,10 +8,14 @@ sealed class Destinations(val route: String) {
object Satzung : Destinations("verein/satzung")
object Vereinsmeisterschaften : Destinations("verein/vereinsmeisterschaften")
object Links : Destinations("verein/links")
object Impressum : Destinations("impressum")
object Mannschaften : Destinations("mannschaften")
object MannschaftDetail : Destinations("mannschaften/{slug}") {
fun create(slug: String): String = "mannschaften/$slug"
}
object MannschaftLegacyDetail : Destinations("mannschaft/{slug}") {
fun create(slug: String): String = "mannschaft/$slug"
}
object Termine : Destinations("termine")
object Spielplan : Destinations("spielplan")
object Spielsysteme : Destinations("mannschaften/spielsysteme")
@@ -22,6 +26,9 @@ sealed class Destinations(val route: String) {
object Gallery : Destinations("gallery")
object NewsletterSubscribe : Destinations("newsletter/subscribe")
object NewsletterUnsubscribe : Destinations("newsletter/unsubscribe")
object NewsletterConfirm : Destinations("newsletter/confirm")
object NewsletterConfirmed : Destinations("newsletter/confirmed")
object NewsletterUnsubscribed : Destinations("newsletter/unsubscribed")
object Contact : Destinations("contact")
object Membership : Destinations("membership")
object Login : Destinations("login")
@@ -32,7 +39,15 @@ sealed class Destinations(val route: String) {
object MemberNews : Destinations("intern/news")
object Profile : Destinations("intern/profil")
object MemberApi : Destinations("intern/api")
object CmsStartseite : Destinations("cms/startseite")
object CmsInhalte : Destinations("cms/inhalte")
object CmsVereinsmeisterschaften : Destinations("cms/vereinsmeisterschaften")
object CmsSportbetrieb : Destinations("cms/sportbetrieb")
object CmsMitgliederverwaltung : Destinations("cms/mitgliederverwaltung")
object CmsNewsletter : Destinations("cms/newsletter")
object CmsContactRequests : Destinations("cms/kontaktanfragen")
object CmsEinstellungen : Destinations("cms/einstellungen")
object CmsBenutzer : Destinations("cms/benutzer")
object CmsPasswordResetDiagnostics : Destinations("cms/passwort-reset-diagnose")
object Cms : Destinations("cms")
}

View File

@@ -15,14 +15,14 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import de.harheimertc.ui.components.AppNavigationHeader
import de.harheimertc.ui.components.PendingPage
@Composable
fun NavGraph(
navController: NavHostController,
startDestination: String = Destinations.Home.route,
navigationViewModel: NavigationViewModel = hiltViewModel(),
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) {
@@ -90,12 +90,27 @@ fun NavGraph(
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Impressum.route) {
de.harheimertc.ui.screens.publicpages.ImpressumScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Mannschaften.route) {
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable("mannschaften/herren") {
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(navController, !persistentNavigation)
}
composable("mannschaften/damen") {
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(navController, !persistentNavigation)
}
composable("mannschaften/jugend") {
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(navController, !persistentNavigation)
}
composable(Destinations.MannschaftDetail.route) { entry ->
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
slug = entry.arguments?.getString("slug").orEmpty(),
@@ -103,6 +118,13 @@ fun NavGraph(
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.MannschaftLegacyDetail.route) { entry ->
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
slug = entry.arguments?.getString("slug").orEmpty(),
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Termine.route) {
de.harheimertc.ui.screens.termine.TermineScreen(
navController = navController,
@@ -121,6 +143,9 @@ fun NavGraph(
showBackNavigation = !persistentNavigation,
)
}
composable("spielsysteme") {
de.harheimertc.ui.screens.publicpages.SpielsystemeScreen(navController, !persistentNavigation)
}
composable(Destinations.Training.route) {
de.harheimertc.ui.screens.training.TrainingScreen(
navController = navController,
@@ -145,14 +170,48 @@ fun NavGraph(
showBackNavigation = !persistentNavigation,
)
}
composable("tt-regeln") {
de.harheimertc.ui.screens.publicpages.RegelnScreen(navController, !persistentNavigation)
}
composable("verein/tt-regeln") {
de.harheimertc.ui.screens.publicpages.RegelnScreen(navController, !persistentNavigation)
}
composable(Destinations.Gallery.route) {
de.harheimertc.ui.screens.gallery.GalleryScreen()
}
composable("galerie") {
de.harheimertc.ui.screens.gallery.GalleryScreen()
}
composable(Destinations.NewsletterSubscribe.route) {
PendingPage(navController, "Newsletter abonnieren", "/newsletter/subscribe", !persistentNavigation)
de.harheimertc.ui.screens.newsletter.NewsletterSubscribeScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.NewsletterUnsubscribe.route) {
PendingPage(navController, "Newsletter abmelden", "/newsletter/unsubscribe", !persistentNavigation)
de.harheimertc.ui.screens.newsletter.NewsletterUnsubscribeScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.NewsletterConfirm.route) {
de.harheimertc.ui.screens.newsletter.NewsletterConfirmScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
token = null,
)
}
composable(Destinations.NewsletterConfirmed.route) {
de.harheimertc.ui.screens.newsletter.NewsletterConfirmedScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.NewsletterUnsubscribed.route) {
de.harheimertc.ui.screens.newsletter.NewsletterUnsubscribedScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Contact.route) {
de.harheimertc.ui.screens.contact.ContactScreen()
@@ -181,29 +240,77 @@ fun NavGraph(
showBackNavigation = !persistentNavigation,
)
}
composable("ueber-uns") {
de.harheimertc.ui.screens.publicpages.AboutScreen(navController, !persistentNavigation)
}
composable("geschichte") {
de.harheimertc.ui.screens.publicpages.GeschichteScreen(navController, !persistentNavigation)
}
composable("satzung") {
de.harheimertc.ui.screens.publicpages.SatzungScreen(navController, !persistentNavigation)
}
composable(Destinations.MemberArea.route) {
PendingPage(navController, "Intern", "/mitgliederbereich", !persistentNavigation)
de.harheimertc.ui.screens.memberarea.MemberAreaScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Members.route) {
PendingPage(navController, "Mitgliederliste", "/mitgliederbereich/mitglieder", !persistentNavigation)
de.harheimertc.ui.screens.memberarea.MembersScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.MemberNews.route) {
PendingPage(navController, "News", "/mitgliederbereich/news", !persistentNavigation)
de.harheimertc.ui.screens.memberarea.MemberNewsScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Profile.route) {
PendingPage(navController, "Mein Profil", "/mitgliederbereich/profil", !persistentNavigation)
de.harheimertc.ui.screens.profile.ProfileScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.MemberApi.route) {
PendingPage(navController, "API-Dokumentation", "/mitgliederbereich/api", !persistentNavigation)
de.harheimertc.ui.screens.memberarea.MemberApiScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.CmsStartseite.route) {
de.harheimertc.ui.screens.cms.CmsStartseiteScreen(navController, !persistentNavigation)
}
composable(Destinations.CmsInhalte.route) {
de.harheimertc.ui.screens.cms.CmsInhalteScreen(navController, !persistentNavigation)
}
composable(Destinations.CmsVereinsmeisterschaften.route) {
de.harheimertc.ui.screens.cms.CmsVereinsmeisterschaftenScreen(navController, !persistentNavigation)
}
composable(Destinations.CmsSportbetrieb.route) {
de.harheimertc.ui.screens.cms.CmsSportbetriebScreen(navController, !persistentNavigation)
}
composable(Destinations.CmsMitgliederverwaltung.route) {
de.harheimertc.ui.screens.cms.CmsMitgliederverwaltungScreen(navController, !persistentNavigation)
}
composable(Destinations.CmsNewsletter.route) {
PendingPage(navController, "Newsletter", "/cms/newsletter", !persistentNavigation)
de.harheimertc.ui.screens.cms.CmsNewsletterScreen(navController, !persistentNavigation)
}
composable(Destinations.CmsContactRequests.route) {
PendingPage(navController, "Kontaktanfragen", "/cms/kontaktanfragen", !persistentNavigation)
de.harheimertc.ui.screens.cms.CmsContactRequestsScreen(navController, !persistentNavigation)
}
composable(Destinations.CmsEinstellungen.route) {
de.harheimertc.ui.screens.cms.CmsEinstellungenScreen(navController, !persistentNavigation)
}
composable(Destinations.CmsBenutzer.route) {
de.harheimertc.ui.screens.cms.CmsBenutzerScreen(navController, !persistentNavigation)
}
composable(Destinations.CmsPasswordResetDiagnostics.route) {
de.harheimertc.ui.screens.cms.CmsPasswordResetDiagnosticsScreen(navController, !persistentNavigation)
}
composable(Destinations.Cms.route) {
PendingPage(navController, "CMS", "/cms", !persistentNavigation)
de.harheimertc.ui.screens.cms.CmsDashboardScreen(navController, !persistentNavigation)
}
}
}

View File

@@ -0,0 +1,3 @@
package de.harheimertc.ui.screens.cms
// Placeholder: functionality moved to CmsScreens.kt (CmsUserListPage / UserCard)

View File

@@ -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") }
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,453 @@
package de.harheimertc.ui.screens.cms
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.CmsUserDto
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
class CmsViewModel @Inject constructor(
private val repository: CmsRepository,
) : ViewModel() {
private val _state = MutableStateFlow(CmsUiState())
val state: StateFlow<CmsUiState> = _state
init {
load()
}
fun load() {
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = null)
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,
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.")
}
}
}
}

View File

@@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -13,6 +12,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import de.harheimertc.ui.components.ValidatedTextField
@Composable
fun ContactScreen(viewModel: ContactViewModel = hiltViewModel()) {
@@ -21,12 +21,20 @@ fun ContactScreen(viewModel: ContactViewModel = hiltViewModel()) {
val message by viewModel.message.collectAsState()
val sending by viewModel.sending.collectAsState()
val result by viewModel.result.collectAsState()
val fieldErrors by viewModel.fieldErrors.collectAsState()
Surface(modifier = Modifier.padding(16.dp)) {
Column {
OutlinedTextField(value = name, onValueChange = { viewModel.onName(it) }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = email, onValueChange = { viewModel.onEmail(it) }, label = { Text("E-Mail") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = message, onValueChange = { viewModel.onMessage(it) }, label = { Text("Nachricht") }, modifier = Modifier.fillMaxWidth())
ValidatedTextField(name, viewModel::onName, "Name", error = fieldErrors["name"])
ValidatedTextField(email, viewModel::onEmail, "E-Mail", error = fieldErrors["email"])
ValidatedTextField(
value = message,
onValueChange = viewModel::onMessage,
label = "Nachricht",
error = fieldErrors["message"],
singleLine = false,
minLines = 4,
)
Button(onClick = { viewModel.send() }, enabled = !sending, modifier = Modifier.padding(top = 8.dp)) {
Text(if (sending) "Sende…" else "Absenden")
}

View File

@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.ContactRequest
import de.harheimertc.repositories.ContactRepository
import de.harheimertc.ui.components.isValidEmail
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@@ -27,16 +28,38 @@ class ContactViewModel @Inject constructor(private val repo: ContactRepository)
private val _result = MutableStateFlow<String?>(null)
val result: StateFlow<String?> = _result
fun onName(v: String) { _name.value = v }
fun onEmail(v: String) { _email.value = v }
fun onMessage(v: String) { _message.value = v }
private val _fieldErrors = MutableStateFlow<Map<String, String>>(emptyMap())
val fieldErrors: StateFlow<Map<String, String>> = _fieldErrors
fun onName(v: String) {
_name.value = v
clearFieldError("name")
}
fun onEmail(v: String) {
_email.value = v
clearFieldError("email")
}
fun onMessage(v: String) {
_message.value = v
clearFieldError("message")
}
fun send() {
val n = _name.value.trim()
val e = _email.value.trim()
val m = _message.value.trim()
if (n.isEmpty() || e.isEmpty() || m.isEmpty()) {
_result.value = "Bitte alle Felder ausfüllen"
val errors = buildMap {
if (n.isEmpty()) put("name", "Bitte geben Sie Ihren Namen ein.")
if (e.isEmpty()) put("email", "Bitte geben Sie Ihre E-Mail-Adresse ein.")
else if (!isValidEmail(e)) put("email", "Bitte geben Sie eine gültige E-Mail-Adresse ein.")
if (m.isEmpty()) put("message", "Bitte geben Sie eine Nachricht ein.")
else if (m.length < 10) put("message", "Die Nachricht sollte mindestens 10 Zeichen haben.")
}
if (errors.isNotEmpty()) {
_fieldErrors.value = errors
_result.value = "Bitte prüfen Sie die markierten Felder."
return
}
viewModelScope.launch {
@@ -45,6 +68,7 @@ class ContactViewModel @Inject constructor(private val repo: ContactRepository)
val resp = repo.sendContact(ContactRequest(n, e, m))
if (resp.isSuccessful) {
_result.value = "Nachricht gesendet"
_fieldErrors.value = emptyMap()
_name.value = ""
_email.value = ""
_message.value = ""
@@ -58,4 +82,11 @@ class ContactViewModel @Inject constructor(private val repo: ContactRepository)
}
}
}
private fun clearFieldError(field: String) {
if (_fieldErrors.value.containsKey(field)) {
_fieldErrors.value = _fieldErrors.value - field
}
_result.value = null
}
}

View File

@@ -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) {
if (loading) {
CircularProgressIndicator()
} else if (error != null) {
Text(text = "Fehler: $error")
} else {
ImageGrid(images = images)
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 (images.isEmpty()) {
Text(text = stringResource(R.string.gallery_empty))
} else {
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()
}
}

View File

@@ -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
}
}
}

View File

@@ -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,13 +49,17 @@ 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
import de.harheimertc.ui.components.RichText
import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.ui.theme.Accent100
import de.harheimertc.ui.theme.Accent200
@@ -71,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(
@@ -83,7 +94,7 @@ fun HomeScreen(
Text(item.title, style = MaterialTheme.typography.titleLarge)
}
},
text = { Text(item.content, style = MaterialTheme.typography.bodyMedium, color = Accent700) },
text = { RichText(item.content) },
confirmButton = { TextButton(onClick = { selectedNews = null }) { Text("Schließen") } },
)
}
@@ -96,42 +107,352 @@ fun HomeScreen(
AppNavigationHeader(
selectedRoute = Destinations.Home.route,
onNavigate = navController::navigate,
)
}
}
item { WebHero() }
item {
HomeTermineSection(
termine = state.termine,
loading = state.loading,
onAll = { navController.navigate(Destinations.Termine.route) },
)
}
item {
HomeGamesSection(
spiele = state.spiele,
loading = state.loading,
onAll = { navController.navigate(Destinations.Spielplan.route) },
)
}
if (state.news.isNotEmpty()) {
item {
HomeNewsSection(
news = state.news,
onOpen = { selectedNews = it },
navigationState = navigationState,
)
}
}
item {
HomeActionSection(
onMembership = { navController.navigate(Destinations.Membership.route) },
onContact = { navController.navigate(Destinations.Contact.route) },
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) },
)
}
"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(key = "home_section_${sectionKey}_$index") {
HomeNewsSection(
news = state.news,
onOpen = { selectedNews = it },
)
}
}
}
"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
@@ -221,13 +542,7 @@ private fun HomeNewsSection(news: List<NewsDto>, onOpen: (NewsDto) -> Unit) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) {
Text(formatNewsDate(item.created), style = MaterialTheme.typography.labelSmall, color = Accent500)
Text(item.title, style = MaterialTheme.typography.titleLarge, color = Accent900)
Text(
item.content,
style = MaterialTheme.typography.bodyMedium,
color = Accent700,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
RichText(item.content)
}
}
}
@@ -252,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?,
@@ -428,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",
)

View File

@@ -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"

View File

@@ -19,11 +19,13 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -32,6 +34,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.components.ValidatedTextField
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600
@@ -45,6 +48,18 @@ fun LoginScreen(
viewModel: LoginViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
LaunchedEffect(state.loggedIn, state.restoring) {
if (state.loggedIn && !state.restoring) {
navController.navigate(Destinations.MemberArea.route) {
launchSingleTop = true
popUpTo(Destinations.Login.route) {
inclusive = true
}
}
}
}
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
@@ -80,26 +95,33 @@ fun LoginScreen(
CircularProgressIndicator(color = Primary600, modifier = Modifier.size(28.dp))
Text("Sitzung wird geprüft...", color = Accent500)
} else if (!state.loggedIn) {
OutlinedTextField(
ValidatedTextField(
value = state.email,
onValueChange = viewModel::setEmail,
label = { Text("E-Mail-Adresse") },
label = "E-Mail-Adresse",
error = state.fieldErrors["email"],
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
ValidatedTextField(
value = state.password,
onValueChange = viewModel::setPassword,
label = { Text("Passwort") },
label = "Passwort",
error = state.fieldErrors["password"],
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Button(onClick = viewModel::login, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) {
if (state.loading) CircularProgressIndicator(color = Color.White, strokeWidth = 2.dp, modifier = Modifier.size(18.dp).padding(end = 4.dp))
Text(if (state.loading) "Anmeldung läuft..." else "Anmelden")
}
OutlinedButton(
onClick = { viewModel.passkeyLogin(context) },
enabled = !state.loading,
modifier = Modifier.fillMaxWidth(),
) {
Text("Mit Passkey anmelden")
}
TextButton(onClick = { navController.navigate(Destinations.PasswordReset.route) }, modifier = Modifier.fillMaxWidth()) {
Text("Passwort vergessen?")
}

View File

@@ -1,9 +1,12 @@
package de.harheimertc.ui.screens.login
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.repositories.LoginRepository
import de.harheimertc.repositories.PasskeyRepository
import de.harheimertc.ui.components.isValidEmail
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@@ -12,6 +15,7 @@ import javax.inject.Inject
data class LoginUiState(
val email: String = "",
val password: String = "",
val fieldErrors: Map<String, String> = emptyMap(),
val loading: Boolean = false,
val restoring: Boolean = true,
val loggedIn: Boolean = false,
@@ -22,7 +26,10 @@ data class LoginUiState(
)
@HiltViewModel
class LoginViewModel @Inject constructor(private val repository: LoginRepository) : ViewModel() {
class LoginViewModel @Inject constructor(
private val repository: LoginRepository,
private val passkeyRepository: PasskeyRepository,
) : ViewModel() {
private val _state = MutableStateFlow(LoginUiState())
val state: StateFlow<LoginUiState> = _state
@@ -42,17 +49,21 @@ class LoginViewModel @Inject constructor(private val repository: LoginRepository
}
fun setEmail(value: String) {
_state.value = _state.value.copy(email = value, error = null)
_state.value = _state.value.copy(email = value, fieldErrors = _state.value.fieldErrors - "email", error = null)
}
fun setPassword(value: String) {
_state.value = _state.value.copy(password = value, error = null)
_state.value = _state.value.copy(password = value, fieldErrors = _state.value.fieldErrors - "password", error = null)
}
fun login() {
val current = _state.value
if (current.email.isBlank() || current.password.isBlank()) {
_state.value = current.copy(error = "Bitte E-Mail-Adresse und Passwort eingeben.")
val fieldErrors = buildMap {
if (!isValidEmail(current.email)) put("email", "Bitte geben Sie eine gültige E-Mail-Adresse ein.")
if (current.password.isBlank()) put("password", "Bitte geben Sie Ihr Passwort ein.")
}
if (fieldErrors.isNotEmpty()) {
_state.value = current.copy(fieldErrors = fieldErrors, error = "Bitte prüfen Sie die markierten Felder.")
return
}
viewModelScope.launch {
@@ -75,6 +86,32 @@ class LoginViewModel @Inject constructor(private val repository: LoginRepository
}
}
fun passkeyLogin(context: Context) {
val current = _state.value
viewModelScope.launch {
_state.value = current.copy(loading = true, error = null, message = null)
passkeyRepository.login(context, current.email)
.onSuccess { response ->
_state.value = current.copy(
password = "",
loading = false,
restoring = false,
loggedIn = true,
userName = response.user?.name ?: response.user?.email,
roles = response.user?.roles.orEmpty(),
message = "Passkey-Anmeldung erfolgreich.",
)
}
.onFailure {
_state.value = current.copy(
loading = false,
restoring = false,
error = it.message ?: "Passkey-Anmeldung fehlgeschlagen.",
)
}
}
}
fun logout() {
viewModelScope.launch {
repository.logout()

View File

@@ -33,6 +33,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.ui.components.ValidatedTextField
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary100
@@ -52,13 +53,13 @@ fun PasswordResetScreen(
onBack = { navController.navigate(Destinations.Login.route) },
showBackNavigation = showBackNavigation,
) {
OutlinedTextField(
state.email,
viewModel::setEmail,
label = { Text("E-Mail-Adresse") },
ValidatedTextField(
value = state.email,
onValueChange = viewModel::setEmail,
label = "E-Mail-Adresse",
error = state.fieldErrors["email"],
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
MessageLines(state.error, state.message)
Button(onClick = viewModel::submit, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) {
@@ -85,40 +86,39 @@ fun RegisterScreen(
onBack = { navController.navigate(Destinations.Login.route) },
showBackNavigation = showBackNavigation,
) {
OutlinedTextField(form.name, { viewModel.update(form.copy(name = it)) }, label = { Text("Name *") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(
form.email,
{ viewModel.update(form.copy(email = it)) },
label = { Text("E-Mail-Adresse *") },
ValidatedTextField(form.name, { viewModel.update(form.copy(name = it)) }, "Name *", error = state.fieldErrors["name"])
ValidatedTextField(
value = form.email,
onValueChange = { viewModel.update(form.copy(email = it)) },
label = "E-Mail-Adresse *",
error = state.fieldErrors["email"],
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
form.phone,
{ viewModel.update(form.copy(phone = it)) },
label = { Text("Telefon") },
ValidatedTextField(
value = form.phone,
onValueChange = { viewModel.update(form.copy(phone = it)) },
label = "Telefon",
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
form.birthDate,
{ viewModel.update(form.copy(birthDate = it)) },
label = { Text("Geburtsdatum * (JJJJ-MM-TT)") },
modifier = Modifier.fillMaxWidth(),
ValidatedTextField(
value = form.birthDate,
onValueChange = { viewModel.update(form.copy(birthDate = it)) },
label = "Geburtsdatum * (JJJJ-MM-TT)",
error = state.fieldErrors["birthDate"],
)
OutlinedTextField(
form.password,
{ viewModel.update(form.copy(password = it)) },
label = { Text("Passwort *") },
ValidatedTextField(
value = form.password,
onValueChange = { viewModel.update(form.copy(password = it)) },
label = "Passwort *",
error = state.fieldErrors["password"],
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
form.passwordRepeat,
{ viewModel.update(form.copy(passwordRepeat = it)) },
label = { Text("Passwort wiederholen *") },
ValidatedTextField(
value = form.passwordRepeat,
onValueChange = { viewModel.update(form.copy(passwordRepeat = it)) },
label = "Passwort wiederholen *",
error = state.fieldErrors["passwordRepeat"],
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
)
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(form.showBirthday, { viewModel.update(form.copy(showBirthday = it)) })

View File

@@ -6,6 +6,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.RegistrationRequest
import de.harheimertc.data.RegistrationVisibility
import de.harheimertc.repositories.LoginRepository
import de.harheimertc.ui.components.isValidEmail
import de.harheimertc.ui.components.isValidIsoDate
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@@ -13,6 +15,7 @@ import javax.inject.Inject
data class PasswordResetUiState(
val email: String = "",
val fieldErrors: Map<String, String> = emptyMap(),
val loading: Boolean = false,
val error: String? = null,
val message: String? = null,
@@ -24,13 +27,16 @@ class PasswordResetViewModel @Inject constructor(private val repository: LoginRe
val state: StateFlow<PasswordResetUiState> = _state
fun setEmail(value: String) {
_state.value = _state.value.copy(email = value, error = null)
_state.value = _state.value.copy(email = value, fieldErrors = _state.value.fieldErrors - "email", error = null)
}
fun submit() {
val email = _state.value.email.trim()
if (!email.contains("@")) {
_state.value = _state.value.copy(error = "Bitte eine gültige E-Mail-Adresse eingeben.")
if (!isValidEmail(email)) {
_state.value = _state.value.copy(
fieldErrors = mapOf("email" to "Bitte eine gültige E-Mail-Adresse eingeben."),
error = "Bitte prüfen Sie die markierten Felder.",
)
return
}
viewModelScope.launch {
@@ -58,6 +64,7 @@ data class RegisterFormState(
data class RegisterUiState(
val form: RegisterFormState = RegisterFormState(),
val fieldErrors: Map<String, String> = emptyMap(),
val loading: Boolean = false,
val error: String? = null,
val message: String? = null,
@@ -69,21 +76,14 @@ class RegisterViewModel @Inject constructor(private val repository: LoginReposit
val state: StateFlow<RegisterUiState> = _state
fun update(form: RegisterFormState) {
_state.value = _state.value.copy(form = form, error = null)
_state.value = _state.value.copy(form = form, fieldErrors = validateFields(form, onlyTouched = true), error = null)
}
fun submit() {
val form = _state.value.form
val error = when {
form.name.isBlank() || form.email.isBlank() || form.birthDate.isBlank() || form.password.isBlank() ->
"Bitte alle Pflichtfelder ausfüllen."
!form.email.contains("@") -> "Bitte eine gültige E-Mail-Adresse eingeben."
form.password.length < 8 -> "Das Passwort muss mindestens 8 Zeichen lang sein."
form.password != form.passwordRepeat -> "Die Passwörter stimmen nicht überein."
else -> null
}
if (error != null) {
_state.value = _state.value.copy(error = error)
val fieldErrors = validateFields(form)
if (fieldErrors.isNotEmpty()) {
_state.value = _state.value.copy(fieldErrors = fieldErrors, error = "Bitte prüfen Sie die markierten Felder.")
return
}
viewModelScope.launch {
@@ -104,4 +104,14 @@ class RegisterViewModel @Inject constructor(private val repository: LoginReposit
}
}
}
private fun validateFields(form: RegisterFormState, onlyTouched: Boolean = false): Map<String, String> = buildMap {
fun shouldValidate(value: String) = !onlyTouched || value.isNotBlank()
if (!onlyTouched && form.name.isBlank()) put("name", "Bitte geben Sie Ihren Namen ein.")
if (shouldValidate(form.email) && !isValidEmail(form.email)) put("email", "Bitte geben Sie eine gültige E-Mail-Adresse ein.")
if (shouldValidate(form.birthDate) && !isValidIsoDate(form.birthDate)) put("birthDate", "Bitte verwenden Sie das Format JJJJ-MM-TT.")
if (shouldValidate(form.password) && form.password.length < 8) put("password", "Das Passwort muss mindestens 8 Zeichen lang sein.")
if (form.passwordRepeat.isNotBlank() && form.password != form.passwordRepeat) put("passwordRepeat", "Die Passwörter stimmen nicht überein.")
if (!onlyTouched && form.passwordRepeat.isBlank()) put("passwordRepeat", "Bitte wiederholen Sie das Passwort.")
}
}

View File

@@ -0,0 +1,364 @@
package de.harheimertc.ui.screens.memberarea
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.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
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.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
import androidx.navigation.NavController
import de.harheimertc.data.MemberDto
import de.harheimertc.data.NewsDto
import de.harheimertc.ui.components.RichText
import de.harheimertc.ui.theme.Accent100
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent700
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600
import de.harheimertc.ui.theme.Primary900
@Composable
fun MembersScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: MembersViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
val query = state.query.trim()
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)
}
// 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 {
OutlinedTextField(
value = state.query,
onValueChange = viewModel::updateQuery,
label = { Text("Suchen") },
singleLine = true,
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) }
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]) }
}
}
}
}
@Composable
fun MemberNewsScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: MemberNewsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
MemberAreaPage(navController, showBackNavigation, "News", "Neuigkeiten und Ankündigungen im Mitgliederbereich") {
when {
state.loading -> item { CircularProgressIndicator(color = Primary600) }
state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) }
state.news.isEmpty() -> item { Text("Noch keine News vorhanden.", color = Accent700) }
else -> items(state.news.size) { index -> NewsCard(state.news[index]) }
}
}
}
@Composable
fun MemberApiScreen(navController: NavController, showBackNavigation: Boolean) {
val groups = listOf(
"Authentifizierung" to listOf("POST /api/auth/login", "POST /api/auth/refresh", "GET /api/auth/status", "POST /api/auth/logout"),
"Mitgliederbereich" to listOf("GET /api/members", "GET /api/news", "GET /api/profile", "PUT /api/profile"),
"CMS" to listOf("GET /api/cms/users/list", "GET /api/cms/contact-requests", "GET /api/newsletter/list", "GET /api/config"),
)
MemberAreaPage(navController, showBackNavigation, "API-Dokumentation", "Kurzüberblick der wichtigsten App-Endpunkte") {
item {
Surface(color = Primary100, shape = RoundedCornerShape(12.dp)) {
Text(
"Android nutzt Authorization: Bearer <Access-Token>. Abgelaufene Tokens werden über /api/auth/refresh automatisch erneuert.",
color = Primary900,
modifier = Modifier.fillMaxWidth().padding(16.dp),
)
}
}
items(groups.size) { index ->
val (title, endpoints) = groups[index]
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
endpoints.forEach { endpoint ->
Surface(color = Accent100, shape = RoundedCornerShape(8.dp)) {
Text(endpoint, color = Accent900, modifier = Modifier.fillMaxWidth().padding(10.dp))
}
}
}
}
}
}
}
@Composable
private fun MemberAreaPage(
navController: NavController,
showBackNavigation: Boolean,
title: String,
subtitle: String,
content: androidx.compose.foundation.lazy.LazyListScope.() -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
item {
if (showBackNavigation) {
TextButton(onClick = { navController.popBackStack() }) {
Text("< Intern", color = Primary600, fontWeight = FontWeight.SemiBold)
}
}
Text(title, style = MaterialTheme.typography.headlineMedium, color = Accent900)
Text(subtitle, color = Accent500, modifier = Modifier.padding(top = 8.dp))
}
content()
}
}
@Composable
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()) {
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")
if (member.hasHallKey) Badge("Hallenschlüssel")
}
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") }
}
}
}
}
}
@Composable
private fun NewsCard(item: NewsDto) {
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(item.title, style = MaterialTheme.typography.titleLarge, color = Accent900)
Text(listOfNotNull(item.author, item.created).joinToString(" | "), color = Accent500)
if (item.isPublic || item.isHidden) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (item.isPublic) Badge("Öffentlich")
if (item.isHidden) Badge("Ausgeblendet")
}
}
RichText(item.content)
}
}
}
@Composable
private fun Badge(label: String) {
Surface(color = Primary100, shape = RoundedCornerShape(20.dp)) {
Text(label, color = Primary600, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 9.dp, vertical = 5.dp))
}
}
@Composable
private fun ErrorCard(message: String, onRetry: () -> Unit) {
Surface(color = Color(0xFFFEE2E2), shape = RoundedCornerShape(12.dp)) {
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(message, color = Color(0xFF991B1B))
Button(onClick = onRetry) { Text("Erneut laden") }
}
}
}
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 }
}

View File

@@ -0,0 +1,103 @@
package de.harheimertc.ui.screens.memberarea
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.MemberDto
import de.harheimertc.data.NewsDto
import de.harheimertc.repositories.MemberAreaRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class MembersUiState(
val members: List<MemberDto> = emptyList(),
val loading: Boolean = true,
val error: String? = null,
val query: String = "",
)
@HiltViewModel
class MembersViewModel @Inject constructor(
private val repository: MemberAreaRepository,
) : ViewModel() {
private val _state = MutableStateFlow(MembersUiState())
val state: StateFlow<MembersUiState> = _state
init {
load()
}
fun updateQuery(query: String) {
_state.value = _state.value.copy(query = query)
}
fun load() {
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = null)
repository.members()
.onSuccess { response -> _state.value = _state.value.copy(members = response.members, loading = false) }
.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(
val news: List<NewsDto> = emptyList(),
val loading: Boolean = true,
val error: String? = null,
)
@HiltViewModel
class MemberNewsViewModel @Inject constructor(
private val repository: MemberAreaRepository,
) : ViewModel() {
private val _state = MutableStateFlow(MemberNewsUiState())
val state: StateFlow<MemberNewsUiState> = _state
init {
load()
}
fun load() {
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = null)
repository.news()
.onSuccess { response -> _state.value = _state.value.copy(news = response.news, loading = false) }
.onFailure { _state.value = _state.value.copy(loading = false, error = it.message ?: "News konnten nicht geladen werden.") }
}
}
}

View File

@@ -0,0 +1,190 @@
package de.harheimertc.ui.screens.memberarea
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import de.harheimertc.data.BirthdayDto
import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.ui.theme.Accent100
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent700
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600
@Composable
fun MemberAreaScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: MemberAreaViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
item {
if (showBackNavigation) {
TextButton(onClick = { navController.popBackStack() }) {
Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold)
}
}
Text("Mitgliederbereich", style = MaterialTheme.typography.displayLarge, color = Accent900)
Text("Alles Wichtige für Vereinsmitglieder.", color = Accent500, modifier = Modifier.padding(top = 8.dp))
}
item {
MemberAreaCardGrid(navController)
}
item {
BirthdayCard(
birthdays = state.birthdays,
loading = state.loadingBirthdays,
error = state.birthdayError,
onRetry = viewModel::loadBirthdays,
)
}
}
}
@Composable
private fun MemberAreaCardGrid(navController: NavController) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
MemberAreaCard(
title = "Mein Profil",
description = "Persönliche Daten und Passwort verwalten",
marker = "P",
onClick = { navController.navigate(Destinations.Profile.route) },
)
MemberAreaCard(
title = "Mitglieder",
description = "Kontaktdaten der Vereinsmitglieder",
marker = "M",
onClick = { navController.navigate(Destinations.Members.route) },
)
MemberAreaCard(
title = "News",
description = "Neuigkeiten und Ankündigungen",
marker = "N",
onClick = { navController.navigate(Destinations.MemberNews.route) },
)
}
}
@Composable
private fun MemberAreaCard(title: String, description: String, marker: String, onClick: () -> Unit) {
Surface(
color = Color.White,
shape = RoundedCornerShape(14.dp),
shadowElevation = 3.dp,
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(18.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(color = Primary100, shape = RoundedCornerShape(10.dp), modifier = Modifier.size(48.dp)) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(marker, color = Primary600, fontWeight = FontWeight.Bold)
}
}
Column(modifier = Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
Text(description, color = Accent700, modifier = Modifier.padding(top = 4.dp))
}
}
}
}
@Composable
private fun BirthdayCard(
birthdays: List<BirthdayDto>,
loading: Boolean,
error: String?,
onRetry: () -> Unit,
) {
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically) {
Surface(color = Color(0xFFFCE7F3), shape = RoundedCornerShape(10.dp), modifier = Modifier.size(48.dp)) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text("G", color = Color(0xFFDB2777), fontWeight = FontWeight.Bold)
}
}
Text("Geburtstage (nächste 4 Wochen)", style = MaterialTheme.typography.titleLarge, color = Accent900)
}
when {
loading -> {
CircularProgressIndicator(color = Primary600, modifier = Modifier.size(28.dp))
Text("Lade...", color = Accent500)
}
error != null -> {
Text(error, color = MaterialTheme.colorScheme.error)
TextButton(onClick = onRetry) { Text("Erneut laden") }
}
birthdays.isEmpty() -> Text("Keine Geburtstage in den nächsten 4 Wochen.", color = Accent700)
else -> birthdays.forEach { birthday -> BirthdayRow(birthday) }
}
}
}
}
@Composable
private fun BirthdayRow(birthday: BirthdayDto) {
Surface(color = Accent100, shape = RoundedCornerShape(9.dp)) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(birthday.name, color = Accent900, fontWeight = FontWeight.SemiBold)
Text(birthday.dayMonth, color = Accent500, style = MaterialTheme.typography.labelSmall)
}
Text(relativeBirthdayLabel(birthday.inDays), color = Accent500)
}
}
}
private fun relativeBirthdayLabel(inDays: Int): String = when (inDays) {
0 -> "Heute"
1 -> "Morgen"
else -> "in $inDays Tagen"
}

View File

@@ -0,0 +1,48 @@
package de.harheimertc.ui.screens.memberarea
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.BirthdayDto
import de.harheimertc.repositories.MemberAreaRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class MemberAreaUiState(
val birthdays: List<BirthdayDto> = emptyList(),
val loadingBirthdays: Boolean = true,
val birthdayError: String? = null,
)
@HiltViewModel
class MemberAreaViewModel @Inject constructor(
private val repository: MemberAreaRepository,
) : ViewModel() {
private val _state = MutableStateFlow(MemberAreaUiState())
val state: StateFlow<MemberAreaUiState> = _state
init {
loadBirthdays()
}
fun loadBirthdays() {
viewModelScope.launch {
_state.value = _state.value.copy(loadingBirthdays = true, birthdayError = null)
repository.birthdays()
.onSuccess { response ->
_state.value = _state.value.copy(
birthdays = response.birthdays,
loadingBirthdays = false,
)
}
.onFailure {
_state.value = _state.value.copy(
loadingBirthdays = false,
birthdayError = it.message ?: "Geburtstage konnten nicht geladen werden.",
)
}
}
}
}

View File

@@ -21,7 +21,6 @@ import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -39,6 +38,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.ui.components.ValidatedTextField
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent700
import de.harheimertc.ui.theme.Accent900
@@ -85,33 +85,33 @@ fun MembershipScreen(
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(13.dp)) {
Text("Beitrittserklärung", style = MaterialTheme.typography.titleLarge, color = Accent900)
FormHeading("Persönliche Daten")
TextInput("Vorname *", form.vorname) { viewModel.update(form.copy(vorname = it)) }
TextInput("Nachname *", form.nachname) { viewModel.update(form.copy(nachname = it)) }
TextInput("Straße und Hausnummer *", form.strasse) { viewModel.update(form.copy(strasse = it)) }
TextInput("Vorname *", form.vorname, error = state.fieldErrors["vorname"]) { viewModel.update(form.copy(vorname = it)) }
TextInput("Nachname *", form.nachname, error = state.fieldErrors["nachname"]) { viewModel.update(form.copy(nachname = it)) }
TextInput("Straße und Hausnummer *", form.strasse, error = state.fieldErrors["strasse"]) { viewModel.update(form.copy(strasse = it)) }
Row(horizontalArrangement = Arrangement.spacedBy(9.dp)) {
TextInput("PLZ *", form.plz, Modifier.weight(0.38f), KeyboardType.Number) { viewModel.update(form.copy(plz = it)) }
TextInput("Wohnort *", form.ort, Modifier.weight(0.62f)) { viewModel.update(form.copy(ort = it)) }
TextInput("PLZ *", form.plz, Modifier.weight(0.38f), KeyboardType.Number, state.fieldErrors["plz"]) { viewModel.update(form.copy(plz = it)) }
TextInput("Wohnort *", form.ort, Modifier.weight(0.62f), error = state.fieldErrors["ort"]) { viewModel.update(form.copy(ort = it)) }
}
TextInput("Geburtsdatum * (JJJJ-MM-TT)", form.geburtsdatum) { viewModel.update(form.copy(geburtsdatum = it)) }
TextInput("E-Mail *", form.email, keyboard = KeyboardType.Email) { viewModel.update(form.copy(email = it)) }
TextInput("Geburtsdatum * (JJJJ-MM-TT)", form.geburtsdatum, error = state.fieldErrors["geburtsdatum"]) { viewModel.update(form.copy(geburtsdatum = it)) }
TextInput("E-Mail *", form.email, keyboard = KeyboardType.Email, error = state.fieldErrors["email"]) { viewModel.update(form.copy(email = it)) }
TextInput("Telefon (Mobil)", form.telefon, keyboard = KeyboardType.Phone) { viewModel.update(form.copy(telefon = it)) }
FormHeading("Mitgliedschaftsart")
ChoiceRow("Aktives Mitglied", form.art == "aktiv") { viewModel.update(form.copy(art = "aktiv")) }
ChoiceRow("Passives Mitglied", form.art == "passiv") { viewModel.update(form.copy(art = "passiv")) }
FeeInfo()
AgreementRow("Hierzu erteile ich das SEPA-Lastschriftmandat. *", form.lastschrift) {
AgreementRow("Hierzu erteile ich das SEPA-Lastschriftmandat. *", form.lastschrift, state.fieldErrors["lastschrift"]) {
viewModel.update(form.copy(lastschrift = it))
}
FormHeading("Bankdaten für SEPA-Lastschrift")
TextInput("Kontoinhaber *", form.kontoinhaber) { viewModel.update(form.copy(kontoinhaber = it)) }
TextInput("IBAN *", form.iban) { viewModel.update(form.copy(iban = it)) }
TextInput("Kontoinhaber *", form.kontoinhaber, error = state.fieldErrors["kontoinhaber"]) { viewModel.update(form.copy(kontoinhaber = it)) }
TextInput("IBAN *", form.iban, error = state.fieldErrors["iban"]) { viewModel.update(form.copy(iban = it)) }
TextInput("BIC", form.bic) { viewModel.update(form.copy(bic = it)) }
TextInput("Kreditinstitut", form.bank) { viewModel.update(form.copy(bank = it)) }
FormHeading("Datenschutz und Vereinssatzung")
AgreementRow("Ich willige in die erforderliche Verarbeitung und Veröffentlichung gemäß Antrag ein. *", form.datenschutz) {
AgreementRow("Ich willige in die erforderliche Verarbeitung und Veröffentlichung gemäß Antrag ein. *", form.datenschutz, state.fieldErrors["datenschutz"]) {
viewModel.update(form.copy(datenschutz = it))
}
AgreementRow("Ich erkenne die Vereinssatzung an. *", form.satzung) {
AgreementRow("Ich erkenne die Vereinssatzung an. *", form.satzung, state.fieldErrors["satzung"]) {
viewModel.update(form.copy(satzung = it))
}
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
@@ -164,12 +164,14 @@ private fun TextInput(
value: String,
modifier: Modifier = Modifier.fillMaxWidth(),
keyboard: KeyboardType = KeyboardType.Text,
error: String? = null,
onChange: (String) -> Unit,
) {
OutlinedTextField(
ValidatedTextField(
value = value,
onValueChange = onChange,
label = { Text(label) },
label = label,
error = error,
keyboardOptions = KeyboardOptions(keyboardType = keyboard),
modifier = modifier,
singleLine = true,
@@ -185,10 +187,13 @@ private fun ChoiceRow(label: String, selected: Boolean, onClick: () -> Unit) {
}
@Composable
private fun AgreementRow(label: String, selected: Boolean, onChange: (Boolean) -> Unit) {
Row(verticalAlignment = Alignment.Top) {
Checkbox(checked = selected, onCheckedChange = onChange)
Text(label, color = Accent700, modifier = Modifier.padding(top = 12.dp))
private fun AgreementRow(label: String, selected: Boolean, error: String? = null, onChange: (Boolean) -> Unit) {
Column {
Row(verticalAlignment = Alignment.Top) {
Checkbox(checked = selected, onCheckedChange = onChange)
Text(label, color = Accent700, modifier = Modifier.padding(top = 12.dp))
}
error?.let { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(start = 48.dp)) }
}
}

View File

@@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.MembershipRequest
import de.harheimertc.repositories.MembershipRepository
import de.harheimertc.ui.components.isValidEmail
import de.harheimertc.ui.components.isValidIsoDate
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@@ -31,6 +33,7 @@ data class MembershipFormState(
data class MembershipUiState(
val form: MembershipFormState = MembershipFormState(),
val fieldErrors: Map<String, String> = emptyMap(),
val sending: Boolean = false,
val message: String? = null,
val error: String? = null,
@@ -43,13 +46,14 @@ class MembershipViewModel @Inject constructor(private val repository: Membership
val state: StateFlow<MembershipUiState> = _state
fun update(form: MembershipFormState) {
_state.value = _state.value.copy(form = form, error = null)
_state.value = _state.value.copy(form = form, fieldErrors = _state.value.fieldErrors - changedKeys(_state.value.form, form), error = null)
}
fun submit() {
val form = _state.value.form
validate(form)?.let {
_state.value = _state.value.copy(error = it)
val fieldErrors = validateFields(form)
if (fieldErrors.isNotEmpty()) {
_state.value = _state.value.copy(fieldErrors = fieldErrors, error = "Bitte prüfen Sie die markierten Felder.")
return
}
viewModelScope.launch {
@@ -74,7 +78,7 @@ class MembershipViewModel @Inject constructor(private val repository: Membership
)
repository.submit(request)
.onSuccess { document ->
_state.value = _state.value.copy(sending = false, message = document.message, pdfUri = document.uri)
_state.value = _state.value.copy(sending = false, fieldErrors = emptyMap(), message = document.message, pdfUri = document.uri)
}
.onFailure {
_state.value = _state.value.copy(sending = false, error = "Beitrittsformular konnte nicht erstellt werden.")
@@ -82,12 +86,33 @@ class MembershipViewModel @Inject constructor(private val repository: Membership
}
}
private fun validate(form: MembershipFormState): String? = when {
listOf(form.vorname, form.nachname, form.strasse, form.plz, form.ort, form.geburtsdatum, form.email, form.kontoinhaber, form.iban)
.any { it.isBlank() } -> "Bitte alle Pflichtfelder ausfüllen."
!form.plz.matches(Regex("\\d{5}")) -> "Bitte eine gültige fünfstellige PLZ eingeben."
!form.email.contains("@") -> "Bitte eine gültige E-Mail-Adresse eingeben."
!form.lastschrift || !form.datenschutz || !form.satzung -> "Bitte die erforderlichen Einwilligungen bestätigen."
else -> null
private fun validateFields(form: MembershipFormState): Map<String, String> = buildMap {
if (form.vorname.isBlank()) put("vorname", "Bitte geben Sie den Vornamen ein.")
if (form.nachname.isBlank()) put("nachname", "Bitte geben Sie den Nachnamen ein.")
if (form.strasse.isBlank()) put("strasse", "Bitte geben Sie Straße und Hausnummer ein.")
if (!form.plz.matches(Regex("\\d{5}"))) put("plz", "Bitte eine gültige fünfstellige PLZ eingeben.")
if (form.ort.isBlank()) put("ort", "Bitte geben Sie den Wohnort ein.")
if (!isValidIsoDate(form.geburtsdatum)) put("geburtsdatum", "Bitte verwenden Sie das Format JJJJ-MM-TT.")
if (!isValidEmail(form.email)) put("email", "Bitte eine gültige E-Mail-Adresse eingeben.")
if (form.kontoinhaber.isBlank()) put("kontoinhaber", "Bitte geben Sie den Kontoinhaber ein.")
if (form.iban.filterNot(Char::isWhitespace).length < 15) put("iban", "Bitte geben Sie eine gültige IBAN ein.")
if (!form.lastschrift) put("lastschrift", "Das SEPA-Lastschriftmandat ist erforderlich.")
if (!form.datenschutz) put("datenschutz", "Die Datenschutzeinwilligung ist erforderlich.")
if (!form.satzung) put("satzung", "Die Anerkennung der Satzung ist erforderlich.")
}
private fun changedKeys(previous: MembershipFormState, next: MembershipFormState): Set<String> = buildSet {
if (previous.vorname != next.vorname) add("vorname")
if (previous.nachname != next.nachname) add("nachname")
if (previous.strasse != next.strasse) add("strasse")
if (previous.plz != next.plz) add("plz")
if (previous.ort != next.ort) add("ort")
if (previous.geburtsdatum != next.geburtsdatum) add("geburtsdatum")
if (previous.email != next.email) add("email")
if (previous.kontoinhaber != next.kontoinhaber) add("kontoinhaber")
if (previous.iban != next.iban) add("iban")
if (previous.lastschrift != next.lastschrift) add("lastschrift")
if (previous.datenschutz != next.datenschutz) add("datenschutz")
if (previous.satzung != next.satzung) add("satzung")
}
}

View File

@@ -0,0 +1,247 @@
package de.harheimertc.ui.screens.newsletter
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import de.harheimertc.data.NewsletterGroupDto
import de.harheimertc.ui.components.ValidatedTextField
import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.ui.theme.Accent100
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent700
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600
import de.harheimertc.ui.theme.Primary900
@Composable
fun NewsletterSubscribeScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: NewsletterViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
NewsletterFormScreen(
navController = navController,
showBackNavigation = showBackNavigation,
title = "Newsletter abonnieren",
buttonLabel = "Newsletter abonnieren",
state = state,
onUpdate = viewModel::update,
onSubmit = viewModel::subscribe,
)
}
@Composable
fun NewsletterUnsubscribeScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: NewsletterViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
NewsletterFormScreen(
navController = navController,
showBackNavigation = showBackNavigation,
title = "Newsletter abmelden",
buttonLabel = "Newsletter abmelden",
state = state,
onUpdate = viewModel::update,
onSubmit = viewModel::unsubscribe,
showName = false,
)
}
@Composable
fun NewsletterConfirmScreen(
navController: NavController,
showBackNavigation: Boolean,
token: String?,
viewModel: NewsletterConfirmViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
LaunchedEffect(token) {
viewModel.confirm(token.orEmpty())
}
NewsletterStatusPage(navController, showBackNavigation, "Newsletter bestätigen") {
when {
state.loading -> {
CircularProgressIndicator(color = Primary600)
Text("Newsletter-Anmeldung wird bestätigt...", color = Accent700)
}
state.error != null -> {
Text("Fehler", style = MaterialTheme.typography.titleLarge, color = Accent900)
Text(state.error.orEmpty(), color = MaterialTheme.colorScheme.error, textAlign = TextAlign.Center)
Button(onClick = { navController.navigate(Destinations.NewsletterSubscribe.route) }) { Text("Zur Anmeldung") }
}
else -> {
Text("Anmeldung bestätigt", style = MaterialTheme.typography.titleLarge, color = Accent900)
Text(state.message ?: "Vielen Dank. Ihre Newsletter-Anmeldung wurde bestätigt.", color = Accent700, textAlign = TextAlign.Center)
Button(onClick = { navController.navigate(Destinations.Home.route) }) { Text("Zur Startseite") }
}
}
}
}
@Composable
fun NewsletterConfirmedScreen(navController: NavController, showBackNavigation: Boolean) {
NewsletterStatusPage(navController, showBackNavigation, "Newsletter bestätigt") {
Text("Anmeldung bestätigt", style = MaterialTheme.typography.titleLarge, color = Accent900)
Text("Vielen Dank. Sie erhalten ab sofort unseren Newsletter.", color = Accent700, textAlign = TextAlign.Center)
Button(onClick = { navController.navigate(Destinations.Home.route) }) { Text("Zur Startseite") }
}
}
@Composable
fun NewsletterUnsubscribedScreen(navController: NavController, showBackNavigation: Boolean) {
NewsletterStatusPage(navController, showBackNavigation, "Newsletter abgemeldet") {
Text("Erfolgreich abgemeldet", style = MaterialTheme.typography.titleLarge, color = Accent900)
Text("Sie erhalten keine weiteren Newsletter dieser Auswahl.", color = Accent700, textAlign = TextAlign.Center)
Button(onClick = { navController.navigate(Destinations.Home.route) }) { Text("Zur Startseite") }
}
}
@Composable
private fun NewsletterFormScreen(
navController: NavController,
showBackNavigation: Boolean,
title: String,
buttonLabel: String,
state: NewsletterUiState,
onUpdate: (NewsletterFormState) -> Unit,
onSubmit: () -> Unit,
showName: Boolean = true,
) {
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
item {
if (showBackNavigation) {
TextButton(onClick = { navController.popBackStack() }) {
Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold)
}
}
Text(title, style = MaterialTheme.typography.displayLarge, color = Accent900)
Text("Wählen Sie einen Newsletter und geben Sie Ihre E-Mail-Adresse ein.", color = Accent500, modifier = Modifier.padding(top = 8.dp))
}
if (state.loading) {
item { CircularProgressIndicator(color = Primary600) }
} else {
item {
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text("Newsletter auswählen", style = MaterialTheme.typography.titleLarge, color = Accent900)
state.groups.forEach { group ->
NewsletterGroupOption(group, selected = group.id == state.form.selectedGroupId) {
onUpdate(state.form.copy(selectedGroupId = group.id))
}
}
state.fieldErrors["selectedGroupId"]?.let { Text(it, color = MaterialTheme.colorScheme.error) }
ValidatedTextField(
value = state.form.email,
onValueChange = { onUpdate(state.form.copy(email = it)) },
label = "E-Mail-Adresse",
error = state.fieldErrors["email"],
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
singleLine = true,
)
if (showName) {
ValidatedTextField(
value = state.form.name,
onValueChange = { onUpdate(state.form.copy(name = it)) },
label = "Name (optional)",
singleLine = true,
)
}
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
state.message?.let { Text(it, color = Color(0xFF166534)) }
Button(onClick = onSubmit, enabled = !state.submitting && state.groups.isNotEmpty(), modifier = Modifier.fillMaxWidth()) {
Text(if (state.submitting) "Wird verarbeitet..." else buttonLabel)
}
}
}
}
}
}
}
@Composable
private fun NewsletterGroupOption(group: NewsletterGroupDto, selected: Boolean, onClick: () -> Unit) {
Surface(
color = if (selected) Primary100 else Accent100,
shape = RoundedCornerShape(10.dp),
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
) {
Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(if (selected) "" else "", color = Primary600)
Text(group.name, color = Accent900, fontWeight = FontWeight.SemiBold)
}
if (group.description.isNotBlank()) Text(group.description, color = Accent700)
}
}
}
@Composable
private fun NewsletterStatusPage(
navController: NavController,
showBackNavigation: Boolean,
title: String,
content: @Composable ColumnScope.() -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
item {
if (showBackNavigation) {
TextButton(onClick = { navController.popBackStack() }) {
Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold)
}
}
Text(title, style = MaterialTheme.typography.displayLarge, color = Accent900)
}
item {
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
Column(Modifier.fillMaxWidth().padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
content()
}
}
}
item {
Surface(color = Primary100, shape = RoundedCornerShape(9.dp)) {
Text("Harheimer TC Newsletter", color = Primary900, modifier = Modifier.fillMaxWidth().padding(16.dp), textAlign = TextAlign.Center)
}
}
}
}

View File

@@ -0,0 +1,123 @@
package de.harheimertc.ui.screens.newsletter
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.NewsletterGroupDto
import de.harheimertc.repositories.NewsletterRepository
import de.harheimertc.ui.components.isValidEmail
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class NewsletterFormState(
val selectedGroupId: String = "",
val email: String = "",
val name: String = "",
)
data class NewsletterUiState(
val groups: List<NewsletterGroupDto> = emptyList(),
val form: NewsletterFormState = NewsletterFormState(),
val fieldErrors: Map<String, String> = emptyMap(),
val loading: Boolean = true,
val submitting: Boolean = false,
val error: String? = null,
val message: String? = null,
)
@HiltViewModel
class NewsletterViewModel @Inject constructor(
private val repository: NewsletterRepository,
) : ViewModel() {
private val _state = MutableStateFlow(NewsletterUiState())
val state: StateFlow<NewsletterUiState> = _state
init {
loadGroups()
}
fun loadGroups() {
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = null)
repository.groups()
.onSuccess { response -> _state.value = _state.value.copy(groups = response.groups, loading = false) }
.onFailure { _state.value = _state.value.copy(loading = false, error = it.message ?: "Newsletter konnten nicht geladen werden.") }
}
}
fun update(form: NewsletterFormState) {
_state.value = _state.value.copy(
form = form,
fieldErrors = _state.value.fieldErrors.filterKeys { key ->
when (key) {
"selectedGroupId" -> form.selectedGroupId.isBlank()
"email" -> form.email.isBlank() || !isValidEmail(form.email)
else -> true
}
},
error = null,
message = null,
)
}
fun subscribe() = submit { groupId, email, name -> repository.subscribe(groupId, email, name) }
fun unsubscribe() = submit { groupId, email, _ -> repository.unsubscribe(groupId, email) }
private fun submit(action: suspend (String, String, String) -> Result<de.harheimertc.data.AuthMessageResponse>) {
val current = _state.value
val form = current.form
val fieldErrors = buildMap {
if (form.selectedGroupId.isBlank()) put("selectedGroupId", "Bitte Newsletter auswählen.")
if (!isValidEmail(form.email)) put("email", "Bitte gültige E-Mail-Adresse eingeben.")
}
if (fieldErrors.isNotEmpty()) {
_state.value = current.copy(fieldErrors = fieldErrors, error = "Bitte prüfen Sie die markierten Felder.")
return
}
viewModelScope.launch {
_state.value = current.copy(submitting = true, error = null, message = null)
action(form.selectedGroupId, form.email, form.name)
.onSuccess { response ->
_state.value = current.copy(
form = NewsletterFormState(),
fieldErrors = emptyMap(),
submitting = false,
loading = false,
groups = current.groups,
message = response.message ?: "Vorgang erfolgreich.",
)
}
.onFailure { _state.value = current.copy(submitting = false, error = it.message ?: "Vorgang fehlgeschlagen.") }
}
}
}
data class NewsletterConfirmUiState(
val loading: Boolean = false,
val message: String? = null,
val error: String? = null,
)
@HiltViewModel
class NewsletterConfirmViewModel @Inject constructor(
private val repository: NewsletterRepository,
) : ViewModel() {
private val _state = MutableStateFlow(NewsletterConfirmUiState())
val state: StateFlow<NewsletterConfirmUiState> = _state
fun confirm(token: String) {
if (token.isBlank()) {
_state.value = NewsletterConfirmUiState(error = "Bestätigungstoken fehlt.")
return
}
viewModelScope.launch {
_state.value = NewsletterConfirmUiState(loading = true)
repository.confirm(token)
.onSuccess { _state.value = NewsletterConfirmUiState(message = it.message ?: "Newsletter-Anmeldung bestätigt.") }
.onFailure { _state.value = NewsletterConfirmUiState(error = it.message ?: "Bestätigung fehlgeschlagen.") }
}
}
}

View File

@@ -0,0 +1,222 @@
package de.harheimertc.ui.screens.profile
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.components.ValidatedTextField
import de.harheimertc.ui.theme.Primary600
@Composable
fun ProfileScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: ProfileViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
val form = state.form
val context = LocalContext.current
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item {
if (showBackNavigation) {
TextButton(onClick = { navController.popBackStack() }) {
Text("< Intern", color = Primary600, fontWeight = FontWeight.SemiBold)
}
}
Text("Mein Profil", style = MaterialTheme.typography.displayLarge, color = Accent900)
Text("Kontaktdaten, Sichtbarkeit und Passwort verwalten.", color = Accent500, modifier = Modifier.padding(top = 8.dp))
}
if (state.loading) {
item {
Column(Modifier.fillMaxWidth().padding(28.dp), horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(color = Primary600)
}
}
} else {
item {
ProfileCard("Persönliche Daten") {
ValidatedTextField(
value = form.name,
onValueChange = { viewModel.update(form.copy(name = it)) },
label = "Name",
error = state.fieldErrors["name"],
singleLine = true,
)
ValidatedTextField(
value = form.email,
onValueChange = { viewModel.update(form.copy(email = it)) },
label = "E-Mail",
error = state.fieldErrors["email"],
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
singleLine = true,
)
ValidatedTextField(
value = form.phone,
onValueChange = { viewModel.update(form.copy(phone = it)) },
label = "Telefon",
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
singleLine = true,
)
ValidatedTextField(
value = form.birthDate,
onValueChange = { viewModel.update(form.copy(birthDate = it)) },
label = "Geburtsdatum (JJJJ-MM-TT)",
error = state.fieldErrors["birthDate"],
singleLine = true,
)
}
}
item {
ProfileCard("Sichtbarkeit im Mitgliederbereich") {
VisibilityRow("E-Mail anzeigen", form.showEmail) { viewModel.update(form.copy(showEmail = it)) }
VisibilityRow("Telefon anzeigen", form.showPhone) { viewModel.update(form.copy(showPhone = it)) }
VisibilityRow("Adresse anzeigen", form.showAddress) { viewModel.update(form.copy(showAddress = it)) }
VisibilityRow("Geburtstag anzeigen", form.showBirthday) { viewModel.update(form.copy(showBirthday = it)) }
}
}
item {
ProfileCard("Passwort ändern") {
ValidatedTextField(
value = form.currentPassword,
onValueChange = { viewModel.update(form.copy(currentPassword = it)) },
label = "Aktuelles Passwort",
error = state.fieldErrors["currentPassword"],
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
)
ValidatedTextField(
value = form.newPassword,
onValueChange = { viewModel.update(form.copy(newPassword = it)) },
label = "Neues Passwort",
error = state.fieldErrors["newPassword"],
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
)
ValidatedTextField(
value = form.confirmPassword,
onValueChange = { viewModel.update(form.copy(confirmPassword = it)) },
label = "Neues Passwort wiederholen",
error = state.fieldErrors["confirmPassword"],
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
)
Text("Leer lassen, wenn das Passwort unverändert bleiben soll.", color = Accent500)
}
}
item {
ProfileCard("Passkeys") {
Text(
"Passkeys ermöglichen eine Anmeldung ohne Passwort über den Android Credential Manager.",
color = Accent500,
)
Button(
onClick = { viewModel.addPasskey(context) },
enabled = !state.passkeyLoading,
modifier = Modifier.fillMaxWidth(),
) {
if (state.passkeyLoading) CircularProgressIndicator(color = Color.White, strokeWidth = 2.dp, modifier = Modifier.size(18.dp).padding(end = 8.dp))
Text(if (state.passkeyLoading) "Passkey wird erstellt..." else "Passkey hinzufügen")
}
if (state.passkeys.isEmpty()) {
Text("Noch kein Passkey hinterlegt.", color = Accent500)
} else {
state.passkeys.forEach { passkey ->
Surface(color = Color(0xFFF4F4F5), shape = RoundedCornerShape(9.dp)) {
Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(passkey.name.ifBlank { "Passkey" }, color = Accent900, fontWeight = FontWeight.SemiBold)
passkey.createdAt?.let { Text("Erstellt: $it", color = Accent500) }
passkey.lastUsedAt?.let { Text("Zuletzt genutzt: $it", color = Accent500) }
OutlinedButton(
onClick = { viewModel.removePasskey(passkey.credentialId) },
enabled = !state.passkeyLoading,
modifier = Modifier.fillMaxWidth(),
) {
Text("Entfernen")
}
}
}
}
}
}
}
item {
Button(
onClick = viewModel::save,
enabled = !state.saving,
modifier = Modifier.fillMaxWidth(),
) {
if (state.saving) CircularProgressIndicator(color = Color.White, strokeWidth = 2.dp, modifier = Modifier.size(18.dp).padding(end = 8.dp))
Text(if (state.saving) "Speichert..." else "Profil speichern")
}
}
}
state.error?.let { message ->
item { Text(message, color = MaterialTheme.colorScheme.error) }
}
state.message?.let { message ->
item { Text(message, color = Color(0xFF166534)) }
}
}
}
@Composable
private fun ProfileCard(title: String, content: @Composable ColumnScope.() -> Unit) {
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 2.dp) {
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
content()
}
}
}
@Composable
private fun VisibilityRow(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Checkbox(checked = checked, onCheckedChange = onCheckedChange)
Text(label, color = Accent900)
}
}

View File

@@ -0,0 +1,217 @@
package de.harheimertc.ui.screens.profile
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.harheimertc.data.PasskeyDto
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.ProfileUpdateRequest
import de.harheimertc.data.ProfileVisibilityDto
import de.harheimertc.repositories.ProfileRepository
import de.harheimertc.repositories.PasskeyRepository
import de.harheimertc.ui.components.isValidEmail
import de.harheimertc.ui.components.isValidIsoDate
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class ProfileFormState(
val name: String = "",
val email: String = "",
val phone: String = "",
val birthDate: String = "",
val showEmail: Boolean = true,
val showPhone: Boolean = true,
val showAddress: Boolean = false,
val showBirthday: Boolean = true,
val currentPassword: String = "",
val newPassword: String = "",
val confirmPassword: String = "",
)
data class ProfileUiState(
val form: ProfileFormState = ProfileFormState(),
val passkeys: List<PasskeyDto> = emptyList(),
val fieldErrors: Map<String, String> = emptyMap(),
val loading: Boolean = true,
val saving: Boolean = false,
val passkeyLoading: Boolean = false,
val error: String? = null,
val message: String? = null,
)
@HiltViewModel
class ProfileViewModel @Inject constructor(
private val repository: ProfileRepository,
private val passkeyRepository: PasskeyRepository,
) : ViewModel() {
private val _state = MutableStateFlow(ProfileUiState())
val state: StateFlow<ProfileUiState> = _state
init {
load()
}
fun load() {
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = null, message = null)
repository.load()
.onSuccess { response ->
val user = response.user
val visibility = user?.visibility ?: ProfileVisibilityDto()
_state.value = ProfileUiState(
loading = false,
form = ProfileFormState(
name = user?.name.orEmpty(),
email = user?.email.orEmpty(),
phone = user?.phone.orEmpty(),
birthDate = user?.geburtsdatum.orEmpty(),
showEmail = visibility.showEmail,
showPhone = visibility.showPhone,
showAddress = visibility.showAddress,
showBirthday = visibility.showBirthday,
),
)
loadPasskeys()
}
.onFailure {
_state.value = _state.value.copy(
loading = false,
error = it.message ?: "Profil konnte nicht geladen werden.",
)
}
}
}
fun loadPasskeys() {
viewModelScope.launch {
passkeyRepository.list()
.onSuccess { response ->
_state.value = _state.value.copy(passkeys = response.passkeys, passkeyLoading = false)
}
.onFailure {
_state.value = _state.value.copy(
passkeyLoading = false,
error = it.message ?: "Passkeys konnten nicht geladen werden.",
)
}
}
}
fun addPasskey(context: Context) {
viewModelScope.launch {
_state.value = _state.value.copy(passkeyLoading = true, error = null, message = null)
passkeyRepository.add(context)
.onSuccess { response ->
_state.value = _state.value.copy(
passkeyLoading = false,
message = response.message ?: "Passkey hinzugefügt.",
)
loadPasskeys()
}
.onFailure {
_state.value = _state.value.copy(
passkeyLoading = false,
error = it.message ?: "Passkey konnte nicht hinzugefügt werden.",
)
}
}
}
fun removePasskey(credentialId: String) {
viewModelScope.launch {
_state.value = _state.value.copy(passkeyLoading = true, error = null, message = null)
passkeyRepository.remove(credentialId)
.onSuccess { response ->
_state.value = _state.value.copy(
passkeyLoading = false,
message = response.message ?: "Passkey entfernt.",
)
loadPasskeys()
}
.onFailure {
_state.value = _state.value.copy(
passkeyLoading = false,
error = it.message ?: "Passkey konnte nicht entfernt werden.",
)
}
}
}
fun update(form: ProfileFormState) {
_state.value = _state.value.copy(form = form, fieldErrors = emptyMap(), error = null, message = null)
}
fun save() {
val form = _state.value.form
val fieldErrors = buildMap {
if (form.name.isBlank()) put("name", "Bitte geben Sie Ihren Namen ein.")
if (!isValidEmail(form.email)) put("email", "Bitte geben Sie eine gültige E-Mail-Adresse ein.")
if (form.birthDate.isNotBlank() && !isValidIsoDate(form.birthDate)) put("birthDate", "Bitte verwenden Sie das Format JJJJ-MM-TT.")
if (form.currentPassword.isNotBlank() || form.newPassword.isNotBlank() || form.confirmPassword.isNotBlank()) {
if (form.currentPassword.isBlank()) put("currentPassword", "Bitte geben Sie Ihr aktuelles Passwort ein.")
if (form.newPassword.isBlank()) put("newPassword", "Bitte geben Sie ein neues Passwort ein.")
else if (form.newPassword.length < 6) put("newPassword", "Das neue Passwort muss mindestens 6 Zeichen lang sein.")
if (form.newPassword != form.confirmPassword) put("confirmPassword", "Die neuen Passwörter stimmen nicht überein.")
}
}
if (fieldErrors.isNotEmpty()) {
_state.value = _state.value.copy(fieldErrors = fieldErrors, error = "Bitte prüfen Sie die markierten Felder.", message = null)
return
}
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
repository.save(
ProfileUpdateRequest(
name = form.name.trim(),
email = form.email.trim(),
phone = form.phone.trim().takeIf(String::isNotBlank),
geburtsdatum = form.birthDate.trim().takeIf(String::isNotBlank),
visibility = ProfileVisibilityDto(
showEmail = form.showEmail,
showPhone = form.showPhone,
showAddress = form.showAddress,
showBirthday = form.showBirthday,
),
currentPassword = form.currentPassword.takeIf(String::isNotBlank),
newPassword = form.newPassword.takeIf(String::isNotBlank),
),
).onSuccess { response ->
val current = _state.value
val next = response.user
val visibility = next?.visibility ?: ProfileVisibilityDto(
form.showEmail,
form.showPhone,
form.showAddress,
form.showBirthday,
)
_state.value = ProfileUiState(
loading = false,
message = response.message ?: "Profil erfolgreich aktualisiert.",
passkeys = current.passkeys,
fieldErrors = emptyMap(),
form = form.copy(
name = next?.name ?: form.name.trim(),
email = next?.email ?: form.email.trim(),
phone = next?.phone ?: form.phone.trim(),
birthDate = next?.geburtsdatum ?: form.birthDate.trim(),
showEmail = visibility.showEmail,
showPhone = visibility.showPhone,
showAddress = visibility.showAddress,
showBirthday = visibility.showBirthday,
currentPassword = "",
newPassword = "",
confirmPassword = "",
),
)
}.onFailure {
_state.value = _state.value.copy(
saving = false,
error = it.message ?: "Profil konnte nicht gespeichert werden.",
)
}
}
}
}

View File

@@ -0,0 +1,105 @@
package de.harheimertc.ui.screens.publicpages
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import de.harheimertc.data.ConfigResponse
import de.harheimertc.ui.theme.Accent700
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary600
@Composable
fun ImpressumScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: PublicConfigViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
PublicPage(navController, showBackNavigation, "Impressum") {
when {
state.loading -> item { PublicLoading() }
state.error != null -> item { PublicError(state.error.orEmpty(), viewModel::load) }
state.config != null -> {
val config = state.config
item { ImpressumContent(config!!, onOpenSatzung = { navController.navigate(de.harheimertc.ui.navigation.Destinations.Satzung.route) }, onMail = { context.openPublicUri("mailto:$it") }) }
}
}
}
}
@Composable
private fun ImpressumContent(config: ConfigResponse, onOpenSatzung: () -> Unit, onMail: (String) -> Unit) {
val address = if (config.verein.useVorsitzenderAddress) {
listOf(config.vorstand.vorsitzender.strasse, "${config.vorstand.vorsitzender.plz} ${config.vorstand.vorsitzender.ort}".trim())
} else {
listOf(config.verein.strasse, "${config.verein.plz} ${config.verein.ort}".trim())
}.filter(String::isNotBlank)
PublicCard("Angaben gemäß § 5 TMG") {
Text(config.verein.name.ifBlank { "Harheimer Tischtennis-Club 1954 e. V. (HTC)" }, color = Accent700)
address.forEach { Text(it, color = Accent700) }
}
PublicCard("Kontakt") {
config.vorstand.vorsitzender.telefon.takeIf(String::isNotBlank)?.let { Text("Telefon: $it", color = Accent700) }
config.vorstand.vorsitzender.email.takeIf(String::isNotBlank)?.let { email ->
Button(onClick = { onMail(email) }, colors = ButtonDefaults.buttonColors(containerColor = Primary600)) { Text(email) }
}
Text("Internet: www.harheimertc.de", color = Accent700)
}
PublicCard("Vertretungsberechtigter Vorstand") {
boardRows(config).forEach { Text(it, color = Accent700) }
}
PublicCard("Registereintrag") {
Text("lsb h-Vereinsnummer: 24091", color = Accent700)
Text("Registereintrag: Amtsgericht Frankfurt am Main, Registergericht", color = Accent700)
Text("Registernummer: VR 6835", color = Accent700)
}
PublicCard("Vereinssatzung") {
Text("Unsere aktuelle Vereinssatzung können Sie online einsehen.", color = Accent700)
Button(onClick = onOpenSatzung, colors = ButtonDefaults.buttonColors(containerColor = Primary600)) { Text("Satzung öffnen") }
}
PublicCard("Verantwortlich für die Website") {
val person = config.website.verantwortlicher
Text("${person.vorname} ${person.nachname}".trim().ifBlank { "-" }, color = Accent700)
person.email.takeIf(String::isNotBlank)?.let { email ->
Button(onClick = { onMail(email) }, colors = ButtonDefaults.buttonColors(containerColor = Primary600)) { Text(email) }
}
}
PublicCard("Haftungsausschluss und Datenschutz") {
SectionText("Haftung für Inhalte", "Als Diensteanbieter sind wir für eigene Inhalte nach den allgemeinen Gesetzen verantwortlich. Eine Haftung für externe oder fehlerhafte Informationen ist erst ab Kenntnis einer konkreten Rechtsverletzung möglich.")
SectionText("Haftung für Links", "Für Inhalte externer Websites sind deren Betreiber verantwortlich. Bei Bekanntwerden von Rechtsverletzungen entfernen wir entsprechende Links.")
SectionText("Urheberrecht", "Die Inhalte dieser App und Website unterliegen dem deutschen Urheberrecht.")
SectionText("Datenschutz", "Personenbezogene Daten werden vertraulich und entsprechend der Datenschutzvorschriften behandelt.")
}
}
@Composable
private fun SectionText(title: String, text: String) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(title, color = Accent900, fontWeight = FontWeight.SemiBold)
Text(text, color = Accent700)
}
}
private fun boardRows(config: ConfigResponse): List<String> = listOf(
"Vorsitzender" to config.vorstand.vorsitzender,
"Stellvertreter" to config.vorstand.stellvertreter,
"Kassenwart" to config.vorstand.kassenwart,
"Schriftführer" to config.vorstand.schriftfuehrer,
"Sportwart" to config.vorstand.sportwart,
"Jugendwart" to config.vorstand.jugendwart,
).mapNotNull { (role, member) ->
val name = "${member.vorname} ${member.nachname}".trim()
name.takeIf(String::isNotBlank)?.let { "$it, $role" }
}

View File

@@ -3,8 +3,6 @@ package de.harheimertc.ui.screens.publicpages
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.text.method.LinkMovementMethod
import android.widget.TextView
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -27,10 +25,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.text.HtmlCompat
import androidx.navigation.NavController
import de.harheimertc.BuildConfig
import de.harheimertc.ui.components.RichText
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary600
@@ -86,20 +83,7 @@ internal fun PublicError(message: String, retry: () -> Unit) {
@Composable
internal fun HtmlContent(html: String) {
AndroidView(
modifier = Modifier.fillMaxWidth(),
factory = { context ->
TextView(context).apply {
textSize = 17f
setTextColor(android.graphics.Color.rgb(63, 63, 70))
movementMethod = LinkMovementMethod.getInstance()
setLineSpacing(0f, 1.2f)
}
},
update = { textView ->
textView.text = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT)
},
)
RichText(html)
}
internal fun Context.openPublicUri(value: String) {

View File

@@ -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
}
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

View File

@@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

View 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>

View File

@@ -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>

View 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>

View File

@@ -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(""))
}
}

View File

@@ -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>&nbsp;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&amp;name=&quot;x&quot;", escaped)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Some files were not shown because too many files have changed in this diff Show More