@@ -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,35 +69,34 @@ 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`
|
||||
[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`
|
||||
[ ] 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
|
||||
- [ ] Bearer-Unterstützung aller später portierten geschützten Bereiche (`/api/profile`, `/api/birthdays`, `/api/members`, `/api/news`, CMS-Leseendpunkte erledigt)
|
||||
- [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)
|
||||
@@ -109,10 +108,19 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
|
||||
- [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
|
||||
[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
|
||||
[ ] 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
|
||||
[x] 15. Rich-Text: Anzeige von HTML (Compose + WebView) und ggf. Editor via WebView-bridge
|
||||
- [x] Gemeinsame native HTML/Rich-Text-Komponente für CMS- und News-Inhalte ergänzt
|
||||
- [x] Öffentliche CMS-Seiten, Startseiten-News und interne News rendern HTML-Inhalte nativ
|
||||
- [ ] Rich-Text-Editor für CMS-Bearbeitung als WebView-Bridge prüfen, falls CMS-Editieren nativ ausgebaut wird
|
||||
[x] 16. Formulare: Validierung (clientseitig) und Fehlerdarstellung
|
||||
- [x] Gemeinsame validierte Textfelder mit Inline-Fehlern ergänzt
|
||||
- [x] Kontakt, Login, Passwort-Reset, Registrierung, Newsletter, Profil und Mitgliedschaft zeigen Feldfehler direkt am Eingabefeld
|
||||
[ ] 17. Offline & Caching: Room für persistente Daten, Response-Caching, Sync-Strategie
|
||||
[ ] 18. Lokalisierung: `strings.xml` (DE + EN) und i18n-Check
|
||||
[ ] 19. Accessibility: ContentDescription, Focus, Farben/Kontrast prüfen
|
||||
@@ -135,10 +143,10 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
|
||||
|
||||
6) Nächste Aktionen (sofort)
|
||||
- Web-Login bei Bedarf auf denselben Refresh-Flow migrieren; bis dahin bleiben Web-Cookie-Sitzungen bewusst kompatibel.
|
||||
- Passkey-Anmeldung über Android Credential Manager anbinden.
|
||||
- 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 Mitgliederbereich/CMS-Screens portieren; dabei rollenabhängige `Intern`-Navigation und Bearer-Unterstützung der jeweils verwendeten Backend-Endpunkte ergänzen.
|
||||
|
||||
7) Umsetzungsprotokoll
|
||||
- 2026-05-27: Webnahe Startseite mit öffentlichen Live-Daten umgesetzt; Hilt/Moshi-App-Verdrahtung ergänzt.
|
||||
@@ -159,6 +167,12 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
|
||||
- 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.
|
||||
|
||||
8) Android-Testumgebungen
|
||||
- Lokal im Emulator: `./gradlew :app:installLocalDebug` verwendet `http://10.0.2.2:3100/` und die App-ID `de.harheimertc.local`.
|
||||
|
||||
@@ -94,6 +94,10 @@ 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")
|
||||
|
||||
|
||||
@@ -3,12 +3,14 @@ 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.POST
|
||||
import retrofit2.http.Query
|
||||
import retrofit2.http.Url
|
||||
import retrofit2.http.Streaming
|
||||
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)
|
||||
@@ -77,6 +79,23 @@ 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 = "",
|
||||
@@ -137,6 +156,87 @@ data class AuthStatusResponse(
|
||||
)
|
||||
data class ResetPasswordRequest(val email: String)
|
||||
data class AuthMessageResponse(val success: Boolean = false, val message: String? = null)
|
||||
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,
|
||||
@@ -190,6 +290,21 @@ 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 = "",
|
||||
@@ -214,9 +329,81 @@ data class SeitenDto(
|
||||
data class ConfigResponse(
|
||||
val training: TrainingDto = TrainingDto(),
|
||||
val trainer: List<TrainerDto> = emptyList(),
|
||||
val verein: VereinDto = VereinDto(),
|
||||
val vorstand: VorstandDto = VorstandDto(),
|
||||
val website: WebsiteDto = WebsiteDto(),
|
||||
val seiten: SeitenDto = SeitenDto(),
|
||||
)
|
||||
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 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 PasswordResetDiagnosticsResponse(
|
||||
val retentionHours: Int = 0,
|
||||
val attempts: List<PasswordResetAttemptDto> = emptyList(),
|
||||
)
|
||||
|
||||
interface ApiService {
|
||||
@POST("/api/contact")
|
||||
@@ -243,6 +430,15 @@ 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>
|
||||
|
||||
@@ -279,4 +475,61 @@ 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>
|
||||
|
||||
@GET("/api/cms/users/list")
|
||||
suspend fun cmsUsers(): Response<CmsUsersResponse>
|
||||
|
||||
@GET("/api/cms/contact-requests")
|
||||
suspend fun contactRequests(): Response<List<ContactRequestDto>>
|
||||
|
||||
@GET("/api/newsletter/list")
|
||||
suspend fun newsletters(): Response<NewsletterListResponse>
|
||||
|
||||
@GET("/api/newsletter/groups/list")
|
||||
suspend fun newsletterGroups(): Response<NewsletterGroupsResponse>
|
||||
|
||||
@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(): Response<PasswordResetDiagnosticsResponse>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.harheimertc.data
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
|
||||
object MediaTypes {
|
||||
val json = "application/json; charset=utf-8".toMediaType()
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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 javax.inject.Inject
|
||||
|
||||
class CmsRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun config(): Result<ConfigResponse> = runCatching {
|
||||
val response = api.config()
|
||||
if (!response.isSuccessful) error("Konfiguration konnte nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
}
|
||||
|
||||
suspend fun users(): Result<CmsUsersResponse> = runCatching {
|
||||
val response = api.cmsUsers()
|
||||
if (!response.isSuccessful) error("Benutzer konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
}
|
||||
|
||||
suspend fun contactRequests(): Result<List<ContactRequestDto>> = runCatching {
|
||||
val response = api.contactRequests()
|
||||
if (!response.isSuccessful) error("Kontaktanfragen konnten nicht geladen werden.")
|
||||
response.body() ?: emptyList()
|
||||
}
|
||||
|
||||
suspend fun newsletters(): Result<NewsletterListResponse> = runCatching {
|
||||
val response = api.newsletters()
|
||||
if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
}
|
||||
|
||||
suspend fun newsletterGroups(): Result<NewsletterGroupsResponse> = runCatching {
|
||||
val response = api.newsletterGroups()
|
||||
if (!response.isSuccessful) error("Newsletter-Gruppen konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
}
|
||||
|
||||
suspend fun passwordResetDiagnostics(): Result<PasswordResetDiagnosticsResponse> = runCatching {
|
||||
val response = api.passwordResetDiagnostics()
|
||||
if (!response.isSuccessful) error("Passwort-Reset-Diagnose konnte nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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 javax.inject.Inject
|
||||
|
||||
class MemberAreaRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun birthdays(): Result<BirthdaysResponse> = runCatching {
|
||||
val response = api.birthdays()
|
||||
if (!response.isSuccessful) error("Geburtstage konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
}
|
||||
|
||||
suspend fun members(): Result<MembersResponse> = runCatching {
|
||||
val response = api.members()
|
||||
if (!response.isSuccessful) error("Mitglieder konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
}
|
||||
|
||||
suspend fun news(): Result<NewsResponse> = runCatching {
|
||||
val response = api.memberNews()
|
||||
if (!response.isSuccessful) error("News konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
}
|
||||
|
||||
suspend fun saveNews(request: NewsSaveRequest): Result<Unit> = runCatching {
|
||||
val response = api.saveNews(request)
|
||||
if (!response.isSuccessful) error("News konnten nicht gespeichert werden.")
|
||||
}
|
||||
|
||||
suspend fun deleteNews(id: Int): Result<Unit> = runCatching {
|
||||
val response = api.deleteNews(id)
|
||||
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -228,6 +228,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 +241,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 +275,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 +292,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 +300,17 @@ 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))
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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}"))
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ 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(
|
||||
@@ -90,12 +89,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 +117,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 +142,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 +169,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 +239,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
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.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.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.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.PasswordResetAttemptDto
|
||||
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 CmsDashboardScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
CmsPage(navController, showBackNavigation, "CMS", "Verwaltung und interne Werkzeuge") {
|
||||
if (state.loading) item { CircularProgressIndicator(color = Primary600) }
|
||||
item { CmsSummaryGrid(navController, state) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CmsStartseiteScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
CmsConfigPage(navController, showBackNavigation, "Startseite", "Öffentliche Startseiteninhalte", state.config) {
|
||||
InfoRow("Öffentliche News", "${state.newsletters.count { it.sentAt != null }} versendete Newsletter")
|
||||
InfoRow("Kontaktanfragen", "${state.contactRequests.size} Einträge")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CmsInhalteScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
CmsConfigPage(navController, showBackNavigation, "Inhalte", "Vereinsseiten und strukturierte Inhalte", state.config) { config ->
|
||||
InfoRow("Über uns", textState(config.seiten.ueberUns))
|
||||
InfoRow("Geschichte", textState(config.seiten.geschichte))
|
||||
InfoRow("Satzung", if (config.seiten.satzung.pdfUrl.isNotBlank()) config.seiten.satzung.pdfUrl else textState(config.seiten.satzung.content))
|
||||
InfoRow("Links", "${config.seiten.linksStructured.size} strukturierte Gruppen")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CmsVereinsmeisterschaftenScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
CmsConfigPage(navController, showBackNavigation, "Vereinsmeisterschaften", "CSV- und Personenbild-Inhalte", state.config) {
|
||||
InfoRow("Datenquelle", "/api/vereinsmeisterschaften")
|
||||
InfoRow("Hinweis", "Die Ergebnisliste ist nativ im öffentlichen Bereich portiert.")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
CmsConfigPage(navController, showBackNavigation, "Sportbetrieb", "Training, Trainer und Mannschaftsinhalte", state.config) { config ->
|
||||
InfoRow("Trainingszeiten", "${config.training.zeiten.size} Einträge")
|
||||
InfoRow("Trainer", "${config.trainer.size} Personen")
|
||||
InfoRow("Spielsysteme", "/data/spielsysteme.csv")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CmsMitgliederverwaltungScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
CmsUserListPage(navController, showBackNavigation, "Mitgliederverwaltung", state.users)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CmsContactRequestsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
CmsPage(navController, showBackNavigation, "Kontaktanfragen", "Eingegangene Nachrichten") {
|
||||
if (state.loading) item { CircularProgressIndicator(color = Primary600) }
|
||||
if (!state.loading && state.contactRequests.isEmpty()) item { EmptyCard("Keine Kontaktanfragen gefunden.") }
|
||||
items(state.contactRequests.size) { index -> ContactRequestCard(state.contactRequests[index]) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CmsNewsletterScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
CmsPage(navController, showBackNavigation, "Newsletter", "Newsletter und Gruppen") {
|
||||
if (state.loading) item { CircularProgressIndicator(color = Primary600) }
|
||||
item { SectionTitle("Newsletter") }
|
||||
if (!state.loading && state.newsletters.isEmpty()) item { EmptyCard("Keine Newsletter gefunden.") }
|
||||
items(state.newsletters.size) { index -> NewsletterCard(state.newsletters[index]) }
|
||||
item { SectionTitle("Gruppen") }
|
||||
if (!state.loading && state.newsletterGroups.isEmpty()) item { EmptyCard("Keine Gruppen gefunden.") }
|
||||
items(state.newsletterGroups.size) { index -> NewsletterGroupCard(state.newsletterGroups[index]) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
CmsConfigPage(navController, showBackNavigation, "Einstellungen", "Konfiguration und Systemstatus", state.config) { config ->
|
||||
InfoRow("Vorstand", listOf(config.vorstand.vorsitzender, config.vorstand.stellvertreter, config.vorstand.kassenwart).count { it.email.isNotBlank() }.toString())
|
||||
InfoRow("Trainer", config.trainer.size.toString())
|
||||
InfoRow("Trainingsort", config.training.ort.name.ifBlank { "Nicht gesetzt" })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CmsBenutzerScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
CmsUserListPage(navController, showBackNavigation, "Benutzer", state.users)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CmsPasswordResetDiagnosticsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
CmsPage(navController, showBackNavigation, "Passwort-Reset-Diagnose", "Fehlgeschlagene Reset-Versuche") {
|
||||
if (state.loading) item { CircularProgressIndicator(color = Primary600) }
|
||||
if (!state.loading && state.passwordResetAttempts.isEmpty()) item { EmptyCard("Keine Diagnoseeinträge gefunden.") }
|
||||
items(state.passwordResetAttempts.size) { index -> PasswordAttemptCard(state.passwordResetAttempts[index]) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CmsSummaryGrid(navController: NavController, state: CmsUiState) {
|
||||
val cards = listOf(
|
||||
Triple("Startseite", "Öffentliche Startseite", Destinations.CmsStartseite.route),
|
||||
Triple("Inhalte", "Vereinsseiten", Destinations.CmsInhalte.route),
|
||||
Triple("Sportbetrieb", "Training und Sportdaten", Destinations.CmsSportbetrieb.route),
|
||||
Triple("Mitgliederverwaltung", "${state.users.size} Benutzer", Destinations.CmsMitgliederverwaltung.route),
|
||||
Triple("Kontaktanfragen", "${state.contactRequests.size} Anfragen", Destinations.CmsContactRequests.route),
|
||||
Triple("Newsletter", "${state.newsletters.size} Newsletter", Destinations.CmsNewsletter.route),
|
||||
Triple("Einstellungen", "Systemkonfiguration", Destinations.CmsEinstellungen.route),
|
||||
Triple("Benutzer", "Rollen und Zugänge", Destinations.CmsBenutzer.route),
|
||||
Triple("Vereinsmeisterschaften", "Ergebnisdaten", Destinations.CmsVereinsmeisterschaften.route),
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
cards.forEach { (title, subtitle, route) ->
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp, modifier = Modifier.fillMaxWidth().clickable { navController.navigate(route) }) {
|
||||
Column(Modifier.fillMaxWidth().padding(18.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
Text(subtitle, color = Accent500, modifier = Modifier.padding(top = 4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CmsPage(
|
||||
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("< CMS", color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
Text(title, style = MaterialTheme.typography.displayLarge, color = Accent900)
|
||||
Text(subtitle, color = Accent500, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CmsConfigPage(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
config: ConfigResponse?,
|
||||
content: @Composable (ConfigResponse) -> Unit,
|
||||
) {
|
||||
CmsPage(navController, showBackNavigation, title, subtitle) {
|
||||
if (config == null) {
|
||||
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(10.dp)) {
|
||||
content(config)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CmsUserListPage(navController: NavController, showBackNavigation: Boolean, title: String, users: List<CmsUserDto>) {
|
||||
CmsPage(navController, showBackNavigation, title, "Registrierte Zugänge und Rollen") {
|
||||
if (users.isEmpty()) item { EmptyCard("Keine Benutzer gefunden oder keine Berechtigung.") }
|
||||
items(users.size) { index -> UserCard(users[index]) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserCard(user: CmsUserDto) {
|
||||
DataCard(user.name.ifBlank { user.email.orEmpty() }) {
|
||||
InfoRow("E-Mail", user.email ?: "-")
|
||||
InfoRow("Rollen", user.roles.ifEmpty { listOfNotNull(user.role) }.joinToString(", ").ifBlank { "mitglied" })
|
||||
InfoRow("Status", if (user.active) "Aktiv" else "Inaktiv")
|
||||
InfoRow("Letzter Login", user.lastLogin ?: "-")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactRequestCard(request: ContactRequestDto) {
|
||||
DataCard(request.name.ifBlank { request.email }) {
|
||||
InfoRow("E-Mail", request.email)
|
||||
InfoRow("Status", request.status.ifBlank { "offen" })
|
||||
InfoRow("Nachricht", request.message)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NewsletterCard(newsletter: NewsletterDto) {
|
||||
DataCard(newsletter.subject.ifBlank { newsletter.title.ifBlank { newsletter.id } }) {
|
||||
InfoRow("Status", newsletter.status ?: if (newsletter.sentAt != null) "versendet" else "Entwurf")
|
||||
InfoRow("Erstellt", newsletter.createdAt ?: "-")
|
||||
InfoRow("Versendet", newsletter.sentAt ?: "-")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NewsletterGroupCard(group: NewsletterGroupDto) {
|
||||
DataCard(group.name.ifBlank { group.id }) {
|
||||
InfoRow("Beschreibung", group.description.ifBlank { "-" })
|
||||
InfoRow("Abonnenten", group.subscribers.size.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PasswordAttemptCard(attempt: PasswordResetAttemptDto) {
|
||||
DataCard(attempt.emailMasked ?: attempt.requestId) {
|
||||
InfoRow("Gestartet", attempt.startedAt ?: "-")
|
||||
InfoRow("Status", if (attempt.failed) "Fehler" else "OK")
|
||||
InfoRow("Schritte", attempt.steps.size.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DataCard(title: String, content: @Composable ColumnScope.() -> Unit) {
|
||||
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)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoRow(label: String, value: String) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(label, color = Accent500, modifier = Modifier.weight(0.8f))
|
||||
Text(value, color = Accent900, modifier = Modifier.weight(1.2f))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionTitle(title: String) {
|
||||
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyCard(message: String) {
|
||||
Surface(color = Accent100, shape = RoundedCornerShape(12.dp)) {
|
||||
Text(message, color = Accent700, modifier = Modifier.fillMaxWidth().padding(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
private fun textState(value: String): String = if (value.isBlank()) "Nicht gesetzt" else "${value.length} Zeichen"
|
||||
@@ -0,0 +1,62 @@
|
||||
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.PasswordResetAttemptDto
|
||||
import de.harheimertc.repositories.CmsRepository
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class CmsUiState(
|
||||
val loading: Boolean = true,
|
||||
val error: 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(),
|
||||
)
|
||||
|
||||
@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 config = async { repository.config().getOrNull() }
|
||||
val users = async { repository.users().getOrNull()?.users.orEmpty() }
|
||||
val requests = async { repository.contactRequests().getOrNull().orEmpty() }
|
||||
val newsletters = async { repository.newsletters().getOrNull()?.newsletters.orEmpty() }
|
||||
val groups = async { repository.newsletterGroups().getOrNull()?.groups.orEmpty() }
|
||||
val diagnostics = async { repository.passwordResetDiagnostics().getOrNull()?.attempts.orEmpty() }
|
||||
|
||||
_state.value = CmsUiState(
|
||||
loading = false,
|
||||
config = config.await(),
|
||||
users = users.await(),
|
||||
contactRequests = requests.await(),
|
||||
newsletters = newsletters.await(),
|
||||
newsletterGroups = groups.await(),
|
||||
passwordResetAttempts = diagnostics.await(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ import de.harheimertc.data.NewsDto
|
||||
import de.harheimertc.data.SpielDto
|
||||
import de.harheimertc.data.TerminDto
|
||||
import de.harheimertc.ui.components.AppNavigationHeader
|
||||
import de.harheimertc.ui.components.RichText
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import de.harheimertc.ui.theme.Accent100
|
||||
import de.harheimertc.ui.theme.Accent200
|
||||
@@ -83,7 +84,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") } },
|
||||
)
|
||||
}
|
||||
@@ -221,13 +222,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)) })
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
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.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.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.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()
|
||||
val members = state.members
|
||||
.filter { member ->
|
||||
query.isBlank() ||
|
||||
member.name.contains(query, ignoreCase = true) ||
|
||||
member.email.orEmpty().contains(query, ignoreCase = true)
|
||||
}
|
||||
.sortedWith(compareBy<MemberDto> { it.lastName.ifBlank { it.name } }.thenBy { it.firstName })
|
||||
|
||||
MemberAreaPage(navController, showBackNavigation, "Mitgliederliste", "Kontaktdaten der Vereinsmitglieder") {
|
||||
item {
|
||||
OutlinedTextField(
|
||||
value = state.query,
|
||||
onValueChange = viewModel::updateQuery,
|
||||
label = { Text("Suchen") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
when {
|
||||
state.loading -> item { CircularProgressIndicator(color = Primary600) }
|
||||
state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) }
|
||||
members.isEmpty() -> item { Text("Keine Mitglieder gefunden.", color = Accent700) }
|
||||
else -> items(members.size) { index -> MemberCard(members[index]) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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.displayLarge, color = Accent900)
|
||||
Text(subtitle, color = Accent500, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MemberCard(member: MemberDto) {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
Text(member.name.ifBlank { "${member.firstName} ${member.lastName}".trim() }, style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
if (!member.email.isNullOrBlank()) Text(member.email, color = Primary600)
|
||||
if (!member.phone.isNullOrBlank()) Text(member.phone, color = Accent700)
|
||||
if (!member.birthday.isNullOrBlank()) Text("Geburtstag: ${member.birthday}", color = Accent500)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,11 +187,14 @@ private fun ChoiceRow(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgreementRow(label: String, selected: Boolean, onChange: (Boolean) -> Unit) {
|
||||
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)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
<template>
|
||||
<section
|
||||
id="facilities"
|
||||
class="py-16 sm:py-20 bg-white"
|
||||
>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-4">
|
||||
Unsere Anlagen
|
||||
</h2>
|
||||
<div class="w-24 h-1 bg-primary-600 mx-auto mb-6" />
|
||||
<p class="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||
Moderne Ausstattung und erstklassige Einrichtungen für ein perfektes Tischtenniserlebnis
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-8 mb-16">
|
||||
<div
|
||||
v-for="facility in facilities"
|
||||
:key="facility.title"
|
||||
class="group relative bg-white rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 overflow-hidden border border-gray-100"
|
||||
>
|
||||
<div :class="['absolute top-0 left-0 right-0 h-1 bg-gradient-to-r opacity-0 group-hover:opacity-100 transition-opacity', facility.color]" />
|
||||
<div class="p-8">
|
||||
<div :class="['w-16 h-16 bg-gradient-to-br rounded-xl flex items-center justify-center mb-4 group-hover:scale-110 transition-transform', facility.color]">
|
||||
<component
|
||||
:is="facility.icon"
|
||||
:size="32"
|
||||
class="text-white"
|
||||
/>
|
||||
</div>
|
||||
<h3 class="text-2xl font-display font-bold text-gray-900 mb-3">
|
||||
{{ facility.title }}
|
||||
</h3>
|
||||
<p class="text-gray-600 leading-relaxed">
|
||||
{{ facility.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Gallery -->
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div class="relative h-[300px] rounded-2xl overflow-hidden shadow-xl group">
|
||||
<div
|
||||
class="w-full h-full bg-cover bg-center group-hover:scale-110 transition-transform duration-700"
|
||||
style="background-image: url('https://images.unsplash.com/photo-1534438097545-77fef53fe2e8?q=80&w=2070')"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent flex items-end">
|
||||
<p class="text-white font-semibold text-xl p-6">
|
||||
Hochwertige Wettkampftische
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative h-[300px] rounded-2xl overflow-hidden shadow-xl group">
|
||||
<div
|
||||
class="w-full h-full bg-cover bg-center group-hover:scale-110 transition-transform duration-700"
|
||||
style="background-image: url('https://images.unsplash.com/photo-1611004275469-8583ed5d7b8d?q=80&w=2070')"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent flex items-end">
|
||||
<p class="text-white font-semibold text-xl p-6">
|
||||
Moderne Tischtennishalle
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Sun, CloudRain, Dumbbell, Utensils, Wifi, Droplets } from 'lucide-vue-next'
|
||||
|
||||
const facilities = [
|
||||
{
|
||||
icon: Sun,
|
||||
title: '8 Tischtennisplatten',
|
||||
description: 'Hochwertige Wettkampftische für optimales Spielvergnügen',
|
||||
color: 'from-yellow-400 to-orange-500',
|
||||
},
|
||||
{
|
||||
icon: CloudRain,
|
||||
title: 'Klimatisierte Halle',
|
||||
description: 'Optimale Bedingungen bei jedem Wetter in unserer modernen Halle',
|
||||
color: 'from-blue-400 to-blue-600',
|
||||
},
|
||||
{
|
||||
icon: Dumbbell,
|
||||
title: 'Trainingsbereich',
|
||||
description: 'Ballmaschinen und Trainingsgeräte für gezieltes Training',
|
||||
color: 'from-red-400 to-red-600',
|
||||
},
|
||||
{
|
||||
icon: Utensils,
|
||||
title: 'Clubhaus',
|
||||
description: 'Gemütliches Clubhaus mit Aufenthaltsraum und Küche',
|
||||
color: 'from-green-400 to-green-600',
|
||||
},
|
||||
{
|
||||
icon: Wifi,
|
||||
title: 'Kostenloses WLAN',
|
||||
description: 'Schnelles Internet auf der gesamten Anlage',
|
||||
color: 'from-purple-400 to-purple-600',
|
||||
},
|
||||
{
|
||||
icon: Droplets,
|
||||
title: 'Umkleiden & Duschen',
|
||||
description: 'Moderne, saubere Umkleideräume mit Duschen',
|
||||
color: 'from-cyan-400 to-cyan-600',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
@@ -1,43 +1,14 @@
|
||||
<template>
|
||||
<!-- Success Modal -->
|
||||
<!-- Success Toast (bottom) -->
|
||||
<div
|
||||
v-if="showSuccess"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||
@click.self="closeSuccess"
|
||||
v-if="showSuccessToast"
|
||||
class="fixed left-0 right-0 mx-auto max-w-3xl z-50 pointer-events-none"
|
||||
style="bottom:72px;"
|
||||
>
|
||||
<div class="bg-white rounded-lg max-w-md w-full p-6">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="flex-shrink-0 w-10 h-10 mx-auto bg-green-100 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
class="w-6 h-6 text-green-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">
|
||||
{{ successTitle }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 mb-6">
|
||||
{{ successMessage }}
|
||||
</p>
|
||||
<div class="flex justify-center">
|
||||
<button
|
||||
class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg transition-colors"
|
||||
@click="closeSuccess"
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
<div class="pointer-events-auto mx-4 shadow-lg rounded-md overflow-hidden">
|
||||
<div class="px-4 py-3 bg-green-50 text-green-800 text-sm">
|
||||
<div class="font-medium">{{ toastTitle }}</div>
|
||||
<div class="mt-1">{{ toastMessage }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -138,14 +109,14 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
// Modal States
|
||||
const showSuccess = ref(false)
|
||||
// Modal / Toast States
|
||||
const showSuccessToast = ref(false)
|
||||
const showError = ref(false)
|
||||
const showConfirm = ref(false)
|
||||
|
||||
// Modal Content
|
||||
const successTitle = ref('')
|
||||
const successMessage = ref('')
|
||||
// Modal / Toast Content
|
||||
const toastTitle = ref('')
|
||||
const toastMessage = ref('')
|
||||
const errorTitle = ref('')
|
||||
const errorMessage = ref('')
|
||||
const confirmTitle = ref('')
|
||||
@@ -153,10 +124,14 @@ const confirmMessage = ref('')
|
||||
const confirmAction = ref(null)
|
||||
|
||||
// Modal Functions
|
||||
let toastTimeout = null
|
||||
const showSuccessModal = (title, message) => {
|
||||
successTitle.value = title
|
||||
successMessage.value = message
|
||||
showSuccess.value = true
|
||||
// Show non-blocking toast at bottom instead of modal dialog
|
||||
toastTitle.value = title || 'Erfolg'
|
||||
toastMessage.value = message || ''
|
||||
showSuccessToast.value = true
|
||||
if (toastTimeout) clearTimeout(toastTimeout)
|
||||
toastTimeout = setTimeout(() => { showSuccessToast.value = false; toastTimeout = null }, 3500)
|
||||
}
|
||||
|
||||
const showErrorModal = (title, message) => {
|
||||
@@ -173,7 +148,8 @@ const showConfirmModal = (title, message, action) => {
|
||||
}
|
||||
|
||||
const closeSuccess = () => {
|
||||
showSuccess.value = false
|
||||
showSuccessToast.value = false
|
||||
if (toastTimeout) { clearTimeout(toastTimeout); toastTimeout = null }
|
||||
}
|
||||
|
||||
const closeError = () => {
|
||||
|
||||
@@ -989,10 +989,17 @@ onMounted(() => {
|
||||
|
||||
// Close CMS dropdown when clicking outside
|
||||
document.addEventListener('click', handleDocumentClick)
|
||||
// Listen for global updates to mannschaften (e.g., CMS saved)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('mannschaften:changed', loadMannschaften)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleDocumentClick)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('mannschaften:changed', loadMannschaften)
|
||||
}
|
||||
})
|
||||
|
||||
const toggleSubmenu = (menu) => {
|
||||
|
||||
@@ -117,6 +117,22 @@
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium space-x-3">
|
||||
<button
|
||||
class="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-600 disabled:opacity-50 disabled:cursor-not-allowed mr-1"
|
||||
title="Nach oben"
|
||||
:disabled="isSaving || index === 0"
|
||||
@click="moveMannschaftUp(index)"
|
||||
>
|
||||
<ChevronUp :size="18" />
|
||||
</button>
|
||||
<button
|
||||
class="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-600 disabled:opacity-50 disabled:cursor-not-allowed mr-2"
|
||||
title="Nach unten"
|
||||
:disabled="isSaving || index === mannschaften.length - 1"
|
||||
@click="moveMannschaftDown(index)"
|
||||
>
|
||||
<ChevronDown :size="18" />
|
||||
</button>
|
||||
<button
|
||||
class="text-gray-600 hover:text-gray-900"
|
||||
title="Bearbeiten"
|
||||
@@ -388,7 +404,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, onMounted, computed, onUnmounted } from 'vue'
|
||||
import { Plus, Trash2, Loader2, AlertCircle, Pencil, Users, ChevronUp, ChevronDown, ArrowRight } from 'lucide-vue-next'
|
||||
|
||||
const isLoading = ref(true)
|
||||
@@ -575,7 +591,36 @@ const saveCSV = async () => {
|
||||
content: [header, ...rows].join('\n')
|
||||
}
|
||||
})
|
||||
// Notify other parts of the app that mannschaften changed
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('mannschaften:changed'))
|
||||
}
|
||||
} catch (e) { /* no-op */ }
|
||||
}
|
||||
|
||||
const moveMannschaft = async (index, delta) => {
|
||||
const to = index + delta
|
||||
if (index < 0 || to < 0 || to >= mannschaften.value.length) return
|
||||
isSaving.value = true
|
||||
try {
|
||||
const arr = mannschaften.value
|
||||
const item = arr[index]
|
||||
arr.splice(index, 1)
|
||||
arr.splice(to, 0, item)
|
||||
await saveCSV()
|
||||
await loadMannschaften().catch(() => {})
|
||||
if (window.showSuccessModal) window.showSuccessModal('Erfolg', 'Reihenfolge gespeichert')
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Verschieben der Mannschaft:', err)
|
||||
if (window.showErrorModal) window.showErrorModal('Fehler', 'Reihenfolge konnte nicht gespeichert werden')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const moveMannschaftUp = (index) => moveMannschaft(index, -1)
|
||||
const moveMannschaftDown = (index) => moveMannschaft(index, 1)
|
||||
|
||||
const createNextSeason = async () => {
|
||||
const baseSeason = selectedSeason.value || seasons.value[0]
|
||||
@@ -635,4 +680,30 @@ onMounted(async () => {
|
||||
await loadSeasons()
|
||||
await loadMannschaften().catch(() => {})
|
||||
})
|
||||
|
||||
// Expose load function to parent components
|
||||
try { defineExpose({ loadMannschaften }) } catch (e) { /* noop if not supported in SSR context */ }
|
||||
|
||||
// Reload when tab/window becomes visible or window gains focus
|
||||
const handleVisibilityOrFocus = () => {
|
||||
try {
|
||||
if (document.visibilityState === 'visible') {
|
||||
loadMannschaften().catch(() => {})
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('visibilitychange', handleVisibilityOrFocus)
|
||||
window.addEventListener('focus', handleVisibilityOrFocus)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('visibilitychange', handleVisibilityOrFocus)
|
||||
window.removeEventListener('focus', handleVisibilityOrFocus)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "harheimertc-website",
|
||||
"version": "1.6.1",
|
||||
"version": "1.6.2",
|
||||
"description": "Moderne Webseite für den Harheimer Tischtennis Club",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<template>
|
||||
<div class="min-h-screen">
|
||||
<Facilities />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Facilities from '~/components/Facilities.vue'
|
||||
|
||||
useHead({
|
||||
title: 'Anlagen - Harheimer TC',
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<!-- Tab Content -->
|
||||
<div>
|
||||
<CmsTermine v-if="activeTab === 'termine'" />
|
||||
<CmsMannschaften v-if="activeTab === 'mannschaften'" />
|
||||
<CmsMannschaften ref="cmsMannschaftenRef" v-if="activeTab === 'mannschaften'" />
|
||||
<CmsSpielplaene v-if="activeTab === 'spielplaene'" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,7 +41,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import CmsTermine from '~/components/cms/CmsTermine.vue'
|
||||
import CmsMannschaften from '~/components/cms/CmsMannschaften.vue'
|
||||
import CmsSpielplaene from '~/components/cms/CmsSpielplaene.vue'
|
||||
@@ -56,6 +56,13 @@ useHead({
|
||||
})
|
||||
|
||||
const activeTab = ref('termine')
|
||||
const cmsMannschaftenRef = ref(null)
|
||||
|
||||
watch(activeTab, (v) => {
|
||||
if (v === 'mannschaften' && cmsMannschaftenRef?.value?.loadMannschaften) {
|
||||
try { cmsMannschaftenRef.value.loadMannschaften() } catch (e) { /* no-op */ }
|
||||
}
|
||||
})
|
||||
|
||||
const tabs = [
|
||||
{ id: 'termine', label: 'Termine' },
|
||||
|
||||
@@ -155,10 +155,11 @@
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { AlertCircle, Check, Loader2, Lock } from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const formData = ref({
|
||||
@@ -199,16 +200,15 @@ const handleLogin = async () => {
|
||||
if (response.success) {
|
||||
successMessage.value = 'Anmeldung erfolgreich! Sie werden weitergeleitet...'
|
||||
|
||||
// Redirect based on role
|
||||
// Redirect: prefer `redirect` query (only same-origin paths), otherwise open the member area.
|
||||
setTimeout(() => {
|
||||
const roles = response.user.roles || (response.user.role ? [response.user.role] : [])
|
||||
if (roles.includes('trainer')) {
|
||||
router.push('/cms/kontaktanfragen')
|
||||
} else if (roles.includes('admin') || roles.includes('vorstand') || roles.includes('newsletter')) {
|
||||
router.push('/cms')
|
||||
} else {
|
||||
router.push('/mitgliederbereich')
|
||||
const requested = route.query.redirect
|
||||
if (typeof requested === 'string' && requested.startsWith('/')) {
|
||||
router.push(requested)
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/mitgliederbereich')
|
||||
}, 1000)
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -230,14 +230,13 @@ const handlePasskeyLogin = async () => {
|
||||
successMessage.value = 'Passkey-Anmeldung erfolgreich! Sie werden weitergeleitet...'
|
||||
|
||||
setTimeout(() => {
|
||||
const roles = response.user.roles || (response.user.role ? [response.user.role] : [])
|
||||
if (roles.includes('trainer')) {
|
||||
router.push('/cms/kontaktanfragen')
|
||||
} else if (roles.includes('admin') || roles.includes('vorstand') || roles.includes('newsletter')) {
|
||||
router.push('/cms')
|
||||
} else {
|
||||
router.push('/mitgliederbereich')
|
||||
const requested = route.query.redirect
|
||||
if (typeof requested === 'string' && requested.startsWith('/')) {
|
||||
router.push(requested)
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/mitgliederbereich')
|
||||
}, 1000)
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -256,4 +255,3 @@ useHead({
|
||||
title: 'Login - Harheimer TC',
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getUserFromToken } from '../../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
const user = token ? await getUserFromToken(token) : null
|
||||
|
||||
if (!user) {
|
||||
@@ -24,4 +24,3 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { verifyAuthenticationResponse } from '@simplewebauthn/server'
|
||||
import { createSession, generateToken, migrateUserRoles, readUsers, writeUsers } from '../../../utils/auth.js'
|
||||
import { createRefreshSession, createSession, generateAndroidAccessToken, generateToken, migrateUserRoles, readUsers, writeUsers } from '../../../utils/auth.js'
|
||||
import { getWebAuthnConfig } from '../../../utils/webauthn-config.js'
|
||||
import { consumeAuthChallenge } from '../../../utils/webauthn-challenges.js'
|
||||
import { fromBase64Url, parseClientDataJSON } from '../../../utils/webauthn-encoding.js'
|
||||
@@ -39,6 +39,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const ip = getClientIp(event)
|
||||
const body = await readBody(event)
|
||||
const isAndroidClient = body?.client === 'android'
|
||||
const response = body?.credential
|
||||
if (!response) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Credential fehlt' })
|
||||
@@ -105,10 +106,21 @@ export default defineEventHandler(async (event) => {
|
||||
passkey.lastUsedAt = new Date().toISOString()
|
||||
await writeUsers(users)
|
||||
|
||||
const token = generateToken(user)
|
||||
let token
|
||||
let refreshSession = null
|
||||
if (isAndroidClient) {
|
||||
refreshSession = await createRefreshSession(user.id, body?.deviceName || 'Harheimer TC Android-App')
|
||||
token = generateAndroidAccessToken(user, refreshSession.session.id)
|
||||
} else {
|
||||
token = generateToken(user)
|
||||
await createSession(user.id, token)
|
||||
}
|
||||
|
||||
if (isAndroidClient) {
|
||||
deleteCookie(event, 'auth_token')
|
||||
} else {
|
||||
setCookie(event, 'auth_token', token, { ...getAuthCookieOptions() })
|
||||
}
|
||||
|
||||
await writeAuditLog('auth.passkey.login.success', { ip, userId: user.id })
|
||||
|
||||
@@ -120,6 +132,9 @@ export default defineEventHandler(async (event) => {
|
||||
return {
|
||||
success: true,
|
||||
token,
|
||||
accessToken: isAndroidClient ? token : undefined,
|
||||
refreshToken: refreshSession?.refreshToken,
|
||||
sessionId: refreshSession?.session.id,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
@@ -129,4 +144,3 @@ export default defineEventHandler(async (event) => {
|
||||
role: roles[0] || 'mitglied'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ export default defineEventHandler(async (event) => {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
const user = token ? await getUserFromToken(token) : null
|
||||
|
||||
if (!user) {
|
||||
@@ -119,4 +119,3 @@ export default defineEventHandler(async (event) => {
|
||||
await writeAuditLog('auth.passkey.registered', { userId: user.id })
|
||||
return { success: true, message: 'Passkey hinzugefügt.' }
|
||||
})
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export default defineEventHandler(async (event) => {
|
||||
? body.preferredAuthenticatorType
|
||||
: undefined
|
||||
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
const user = token ? await getUserFromToken(token) : null
|
||||
|
||||
if (!user) {
|
||||
@@ -83,4 +83,3 @@ export default defineEventHandler(async (event) => {
|
||||
return { success: true, options }
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { getUserFromToken, readUsers, writeUsers } from '../../../utils/auth.js'
|
||||
import { writeAuditLog } from '../../../utils/audit-log.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
const currentUser = token ? await getUserFromToken(token) : null
|
||||
|
||||
if (!currentUser) {
|
||||
@@ -36,4 +36,3 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { readMembers, normalizeDate } from '../utils/members.js'
|
||||
import { readUsers, migrateUserRoles, getUserFromToken, verifyToken } from '../utils/auth.js'
|
||||
import { readMembers } from '../utils/members.js'
|
||||
import { readUsers, getUserFromToken, verifyToken } from '../utils/auth.js'
|
||||
|
||||
// Helper: returns array of upcoming birthdays within daysAhead (inclusive)
|
||||
function getUpcomingBirthdays(entries, daysAhead = 28) {
|
||||
@@ -41,7 +41,7 @@ function getUpcomingBirthdays(entries, daysAhead = 28) {
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// Determine viewer for visibility rules; token optional
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
let currentUser = null
|
||||
if (token) {
|
||||
const decoded = verifyToken(token)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { getUserFromToken, hasAnyRole } from '../../utils/auth.js'
|
||||
import { readContactRequests } from '../../utils/contact-requests.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
const currentUser = token ? await getUserFromToken(token) : null
|
||||
|
||||
if (!currentUser || !hasAnyRole(currentUser, 'admin', 'vorstand', 'trainer')) {
|
||||
|
||||
@@ -48,7 +48,7 @@ function summarizeAttempts(entries) {
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
const currentUser = token ? await getUserFromToken(token) : null
|
||||
|
||||
if (!currentUser || !hasRole(currentUser, 'admin')) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { getUserFromToken, readUsers, hasAnyRole, hasRole, migrateUserRoles } from '../../../utils/auth.js'
|
||||
import { getUserFromToken, readUsers, hasAnyRole, migrateUserRoles } from '../../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
const currentUser = await getUserFromToken(token)
|
||||
|
||||
// Seite darf von Admin ODER Vorstand genutzt werden
|
||||
@@ -15,8 +15,6 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const users = await readUsers()
|
||||
|
||||
const isVorstand = hasRole(currentUser, 'vorstand')
|
||||
|
||||
// Nur Admin oder Vorstand duerfen vollen Benutzer-Contact und Rollen sehen.
|
||||
const canSeePrivate = hasAnyRole(currentUser, 'admin', 'vorstand')
|
||||
|
||||
@@ -53,4 +51,3 @@ export default defineEventHandler(async (event) => {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ const getDataPath = (filename) => {
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
|
||||
if (!token) {
|
||||
throw createError({
|
||||
@@ -58,4 +58,3 @@ export default defineEventHandler(async (event) => {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { readUsers, migrateUserRoles } from '../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
|
||||
if (!token) {
|
||||
throw createError({
|
||||
@@ -203,7 +203,6 @@ export default defineEventHandler(async (event) => {
|
||||
mergedMembers.sort((a, b) => a.name.localeCompare(b.name))
|
||||
// Die Mitgliederliste ist nur für authentifizierte Nutzer sichtbar (siehe oben).
|
||||
// Respektiere individuelle Sichtbarkeitspräferenzen (user.visibility)
|
||||
const currentUserToken = token
|
||||
const isViewerAuthenticated = !!currentUser
|
||||
// Only 'vorstand' may override member visibility
|
||||
const isPrivilegedViewer = currentUser ? hasRole(currentUser, 'vorstand') : false
|
||||
@@ -218,16 +217,11 @@ export default defineEventHandler(async (event) => {
|
||||
const showPhone = visibility.showPhone === undefined ? true : Boolean(visibility.showPhone)
|
||||
const showAddress = visibility.showAddress === undefined ? false : Boolean(visibility.showAddress)
|
||||
|
||||
// Determine if contact info existed but was hidden to the viewer
|
||||
const hadEmail = !!member.email
|
||||
const hadPhone = !!member.phone
|
||||
const hadAddress = !!member.address
|
||||
const hadBirthday = !!member.geburtsdatum
|
||||
const emailVisible = (isPrivilegedViewer || (isViewerAuthenticated && showEmail))
|
||||
const phoneVisible = (isPrivilegedViewer || (isViewerAuthenticated && showPhone))
|
||||
const addressVisible = (isPrivilegedViewer || (isViewerAuthenticated && showAddress))
|
||||
const birthdayVisible = (isPrivilegedViewer || (isViewerAuthenticated && (member.visibility && member.visibility.showBirthday !== undefined ? Boolean(member.visibility.showBirthday) : true)))
|
||||
const contactHidden = (!emailVisible && hadEmail) || (!phoneVisible && hadPhone) || (!addressVisible && hadAddress)
|
||||
|
||||
return {
|
||||
id: member.id,
|
||||
@@ -260,7 +254,7 @@ export default defineEventHandler(async (event) => {
|
||||
const day = `${d.getDate()}`.padStart(2, '0')
|
||||
const month = `${d.getMonth()+1}`.padStart(2, '0')
|
||||
return `${day}.${month}`
|
||||
} catch (_e) {
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})() : undefined,
|
||||
@@ -277,4 +271,3 @@ export default defineEventHandler(async (event) => {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { deleteNews } from '../utils/news.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
|
||||
if (!token) {
|
||||
throw createError({
|
||||
@@ -53,4 +53,3 @@ export default defineEventHandler(async (event) => {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { readNews } from '../utils/news.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
|
||||
if (!token) {
|
||||
throw createError({
|
||||
@@ -35,4 +35,3 @@ export default defineEventHandler(async (event) => {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { saveNews } from '../utils/news.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
|
||||
if (!token) {
|
||||
throw createError({
|
||||
@@ -60,4 +60,3 @@ export default defineEventHandler(async (event) => {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { verifyToken, getUserFromToken } from '../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
|
||||
if (!token) {
|
||||
throw createError({ statusCode: 401, message: 'Nicht authentifiziert.' })
|
||||
@@ -35,4 +35,3 @@ export default defineEventHandler(async (event) => {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { verifyToken, readUsers, writeUsers, verifyPassword, hashPassword, migrateUserRoles, revokeRefreshSessionsForUser } from '../utils/auth.js'
|
||||
import { verifyToken, getUserFromToken, readUsers, writeUsers, verifyPassword, hashPassword, migrateUserRoles, revokeRefreshSessionsForUser } from '../utils/auth.js'
|
||||
import { assertPasswordNotPwned } from '../utils/hibp.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
|
||||
if (!token) {
|
||||
throw createError({
|
||||
@@ -21,6 +21,16 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
if (decoded.sid) {
|
||||
const sessionUser = await getUserFromToken(token)
|
||||
if (!sessionUser) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: 'Ungültige Sitzung.'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
const { name, email, phone, geburtsdatum, currentPassword, newPassword } = body
|
||||
|
||||
|
||||
@@ -141,6 +141,20 @@ describe('Config & Profil Endpoints', () => {
|
||||
expect(result.user.email).toBe('max@test.de')
|
||||
expect(result.user).not.toHaveProperty('password')
|
||||
})
|
||||
|
||||
it('akzeptiert Bearer-Token für Android-Clients', async () => {
|
||||
const event = createEvent({ headers: { authorization: 'Bearer android-token' } })
|
||||
authUtils.verifyToken.mockReturnValue({ id: '1' })
|
||||
authUtils.getUserFromToken.mockResolvedValue({
|
||||
id: '1', name: 'Android User', email: 'android@test.de', roles: ['mitglied']
|
||||
})
|
||||
|
||||
const result = await profileGetHandler(event)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(authUtils.verifyToken).toHaveBeenCalledWith('android-token')
|
||||
expect(result.user.email).toBe('android@test.de')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /api/profile', () => {
|
||||
@@ -206,6 +220,32 @@ describe('Config & Profil Endpoints', () => {
|
||||
expect(authUtils.revokeRefreshSessionsForUser).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('aktualisiert Profil per Bearer-Token für Android-Clients', async () => {
|
||||
const event = createEvent({ headers: { authorization: 'Bearer android-token' } })
|
||||
authUtils.verifyToken.mockReturnValue({ id: '1' })
|
||||
authUtils.readUsers.mockResolvedValue([
|
||||
{ id: '1', name: 'Alt', email: 'max@test.de', password: 'hash', roles: ['mitglied'] }
|
||||
])
|
||||
authUtils.writeUsers.mockResolvedValue(undefined)
|
||||
authUtils.migrateUserRoles.mockImplementation(u => ({ ...u, roles: u.roles || ['mitglied'] }))
|
||||
mockSuccessReadBody({ name: 'Android Neu', email: 'android@test.de', phone: '0987' })
|
||||
|
||||
const result = await profilePutHandler(event)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(authUtils.verifyToken).toHaveBeenCalledWith('android-token')
|
||||
expect(result.user.name).toBe('Android Neu')
|
||||
})
|
||||
|
||||
it('lehnt widerrufene Android-Sessions beim Profil-Update ab', async () => {
|
||||
const event = createEvent({ headers: { authorization: 'Bearer android-token' } })
|
||||
authUtils.verifyToken.mockReturnValue({ id: '1', sid: 'session-1' })
|
||||
authUtils.getUserFromToken.mockResolvedValue(null)
|
||||
mockSuccessReadBody({ name: 'Android Neu', email: 'android@test.de' })
|
||||
|
||||
await expect(profilePutHandler(event)).rejects.toMatchObject({ statusCode: 401 })
|
||||
})
|
||||
|
||||
it('prüft aktuelles Passwort bei Passwortänderung', async () => {
|
||||
const event = createEvent({ cookies: { auth_token: 'token' } })
|
||||
authUtils.verifyToken.mockReturnValue({ id: '1' })
|
||||
|
||||
@@ -233,6 +233,26 @@ describe('Spielplan, Mannschaften & öffentliche Endpoints', () => {
|
||||
expect(result.birthdays).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('akzeptiert Bearer-Token für Android-Clients', async () => {
|
||||
const event = createEvent({ headers: { authorization: 'Bearer android-token' } })
|
||||
const inDays = 7
|
||||
const targetDate = new Date()
|
||||
targetDate.setDate(targetDate.getDate() + inDays)
|
||||
const geburtsdatum = `${targetDate.getFullYear() - 30}-${String(targetDate.getMonth() + 1).padStart(2, '0')}-${String(targetDate.getDate()).padStart(2, '0')}`
|
||||
|
||||
authUtils.verifyToken.mockReturnValue({ id: 'v1' })
|
||||
authUtils.getUserFromToken.mockResolvedValue({ id: 'v1', roles: ['vorstand'], active: true })
|
||||
memberUtils.readMembers.mockResolvedValue([
|
||||
{ firstName: 'Android', lastName: 'Privat', geburtsdatum, visibility: { showBirthday: false } }
|
||||
])
|
||||
authUtils.readUsers.mockResolvedValue([])
|
||||
|
||||
const result = await birthdaysHandler(event)
|
||||
|
||||
expect(authUtils.verifyToken).toHaveBeenCalledWith('android-token')
|
||||
expect(result.birthdays).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('ignoriert Mitglieder ohne Geburtsdatum', async () => {
|
||||
const event = createEvent()
|
||||
memberUtils.readMembers.mockResolvedValue([
|
||||
|
||||
Reference in New Issue
Block a user