From 0528334eb44c630765e90dd9bb87605114e2ec12 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Thu, 28 May 2026 08:33:28 +0200 Subject: [PATCH] feat: replace success modal with non-blocking toast notification feat: add global event listener for mannschaften updates in Navigation component feat: notify app of mannschaften changes after CSV save and handle visibility changes refactor: remove unused anlagen page fix: update CmsMannschaften reference in sportbetrieb page for reactivity fix: enhance authentication token retrieval in passkey API endpoints feat: implement refresh session and access token generation for Android clients in passkey login fix: unify token retrieval method across passkey API endpoints feat: add MediaTypes utility for JSON content type in Android app feat: create PasskeyRepository for handling passkey authentication and registration in Android app feat: add validated text field and rich text components for Android UI feat: implement newsletter subscription and unsubscription screens in Android app feat: create public pages including Impressum with dynamic content loading --- ANDROID_KOTLIN_PLAN.md | 37 ++- android-app/app/build.gradle.kts | 4 + .../java/de/harheimertc/data/ApiService.kt | 39 +++ .../java/de/harheimertc/data/MediaTypes.kt | 7 + .../repositories/PasskeyRepository.kt | 114 ++++++++ .../ui/components/AppNavigationHeader.kt | 8 +- .../ui/components/FormComponents.kt | 56 ++++ .../de/harheimertc/ui/components/RichText.kt | 31 +++ .../harheimertc/ui/navigation/Destinations.kt | 7 + .../de/harheimertc/ui/navigation/NavGraph.kt | 73 +++++- .../ui/screens/contact/ContactScreen.kt | 16 +- .../ui/screens/contact/ContactViewModel.kt | 41 ++- .../harheimertc/ui/screens/home/HomeScreen.kt | 11 +- .../ui/screens/login/LoginScreen.kt | 22 +- .../ui/screens/login/LoginViewModel.kt | 47 +++- .../ui/screens/login/RegistrationScreens.kt | 62 ++--- .../screens/login/RegistrationViewModels.kt | 38 ++- .../memberarea/MemberAreaDetailScreens.kt | 3 +- .../ui/screens/membership/MembershipScreen.kt | 43 +-- .../screens/membership/MembershipViewModel.kt | 47 +++- .../screens/newsletter/NewsletterScreens.kt | 247 ++++++++++++++++++ .../newsletter/NewsletterViewModels.kt | 123 +++++++++ .../ui/screens/profile/ProfileScreen.kt | 82 ++++-- .../ui/screens/profile/ProfileViewModel.kt | 95 +++++-- .../publicpages/AdditionalPublicScreens.kt | 105 ++++++++ .../publicpages/PublicScreenComponents.kt | 20 +- components/Facilities.vue | 113 -------- components/ModalDialog.vue | 68 ++--- components/Navigation.vue | 7 + components/cms/CmsMannschaften.vue | 34 ++- pages/anlagen.vue | 14 - pages/cms/sportbetrieb.vue | 11 +- server/api/auth/passkeys/list.get.js | 3 +- server/api/auth/passkeys/login.post.js | 24 +- server/api/auth/passkeys/register.post.js | 3 +- .../passkeys/registration-options.post.js | 3 +- server/api/auth/passkeys/remove.post.js | 3 +- 37 files changed, 1297 insertions(+), 364 deletions(-) create mode 100644 android-app/app/src/main/java/de/harheimertc/data/MediaTypes.kt create mode 100644 android-app/app/src/main/java/de/harheimertc/repositories/PasskeyRepository.kt create mode 100644 android-app/app/src/main/java/de/harheimertc/ui/components/FormComponents.kt create mode 100644 android-app/app/src/main/java/de/harheimertc/ui/components/RichText.kt create mode 100644 android-app/app/src/main/java/de/harheimertc/ui/screens/newsletter/NewsletterScreens.kt create mode 100644 android-app/app/src/main/java/de/harheimertc/ui/screens/newsletter/NewsletterViewModels.kt create mode 100644 android-app/app/src/main/java/de/harheimertc/ui/screens/publicpages/AdditionalPublicScreens.kt delete mode 100644 components/Facilities.vue delete mode 100644 pages/anlagen.vue diff --git a/ANDROID_KOTLIN_PLAN.md b/ANDROID_KOTLIN_PLAN.md index b1221e5..618d047 100644 --- a/ANDROID_KOTLIN_PLAN.md +++ b/ANDROID_KOTLIN_PLAN.md @@ -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,14 +69,13 @@ 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` @@ -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,7 +143,7 @@ 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. - Weitere Mitgliederbereich/CMS-Screens portieren; dabei rollenabhängige `Intern`-Navigation und Bearer-Unterstützung der jeweils verwendeten Backend-Endpunkte ergänzen. @@ -162,6 +170,9 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web - 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`. diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts index 4871544..99fc463 100644 --- a/android-app/app/build.gradle.kts +++ b/android-app/app/build.gradle.kts @@ -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") diff --git a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt index 258d0be..ea77b1a 100644 --- a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt +++ b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt @@ -10,6 +10,7 @@ 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) @@ -155,6 +156,26 @@ 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 = emptyList(), +) +data class RemovePasskeyRequest(val credentialId: String) data class ProfileVisibilityDto( val showEmail: Boolean = true, val showPhone: Boolean = true, @@ -455,6 +476,24 @@ interface ApiService { @POST("/api/auth/register") suspend fun register(@Body request: RegistrationRequest): Response + @POST("/api/auth/passkeys/authentication-options") + suspend fun passkeyAuthenticationOptions(@Body request: PasskeyAuthenticationOptionsRequest): Response + + @POST("/api/auth/passkeys/login") + suspend fun passkeyLogin(@Body request: RequestBody): Response + + @GET("/api/auth/passkeys/list") + suspend fun passkeys(): Response + + @POST("/api/auth/passkeys/registration-options") + suspend fun passkeyRegistrationOptions(@Body request: PasskeyRegistrationOptionsRequest): Response + + @POST("/api/auth/passkeys/register") + suspend fun registerPasskey(@Body request: RequestBody): Response + + @POST("/api/auth/passkeys/remove") + suspend fun removePasskey(@Body request: RemovePasskeyRequest): Response + @GET("/api/profile") suspend fun profile(): Response diff --git a/android-app/app/src/main/java/de/harheimertc/data/MediaTypes.kt b/android-app/app/src/main/java/de/harheimertc/data/MediaTypes.kt new file mode 100644 index 0000000..4fd3e66 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/data/MediaTypes.kt @@ -0,0 +1,7 @@ +package de.harheimertc.data + +import okhttp3.MediaType.Companion.toMediaType + +object MediaTypes { + val json = "application/json; charset=utf-8".toMediaType() +} diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/PasskeyRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/PasskeyRepository.kt new file mode 100644 index 0000000..11d5a57 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/repositories/PasskeyRepository.kt @@ -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 = 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 = 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 = 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 = 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 Result.recoverCredentialCancellation(message: String): Result = + recoverCatching { error -> + when (error) { + is GetCredentialCancellationException, + is CreateCredentialCancellationException -> throw IllegalStateException(message) + else -> throw error + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt b/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt index 327c9b2..b60713b 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt @@ -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,7 +241,10 @@ 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, @@ -271,6 +275,7 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List listOf( MenuTarget("Übersicht", Destinations.Mannschaften.route), @@ -287,6 +292,7 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List 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)) diff --git a/android-app/app/src/main/java/de/harheimertc/ui/components/FormComponents.kt b/android-app/app/src/main/java/de/harheimertc/ui/components/FormComponents.kt new file mode 100644 index 0000000..3769bea --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/components/FormComponents.kt @@ -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}")) + diff --git a/android-app/app/src/main/java/de/harheimertc/ui/components/RichText.kt b/android-app/app/src/main/java/de/harheimertc/ui/components/RichText.kt new file mode 100644 index 0000000..30da07d --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/components/RichText.kt @@ -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) + }, + ) +} + diff --git a/android-app/app/src/main/java/de/harheimertc/ui/navigation/Destinations.kt b/android-app/app/src/main/java/de/harheimertc/ui/navigation/Destinations.kt index 310f287..918abbb 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/navigation/Destinations.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/navigation/Destinations.kt @@ -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") diff --git a/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt b/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt index c8bcade..7c6bd11 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt @@ -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,6 +239,15 @@ 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) { de.harheimertc.ui.screens.memberarea.MemberAreaScreen( navController = navController, diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/contact/ContactScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/contact/ContactScreen.kt index eedafb7..b0a736b 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/contact/ContactScreen.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/contact/ContactScreen.kt @@ -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") } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/contact/ContactViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/contact/ContactViewModel.kt index 4973777..4f34248 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/contact/ContactViewModel.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/contact/ContactViewModel.kt @@ -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(null) val result: StateFlow = _result - fun onName(v: String) { _name.value = v } - fun onEmail(v: String) { _email.value = v } - fun onMessage(v: String) { _message.value = v } + private val _fieldErrors = MutableStateFlow>(emptyMap()) + val fieldErrors: StateFlow> = _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 + } } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeScreen.kt index 4424807..4a0e5b9 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeScreen.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeScreen.kt @@ -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, 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) } } } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/login/LoginScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/login/LoginScreen.kt index 63f6a6b..af60c63 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/login/LoginScreen.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/login/LoginScreen.kt @@ -25,6 +25,7 @@ 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 @@ -33,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 @@ -46,6 +48,7 @@ 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) { @@ -92,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?") } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/login/LoginViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/login/LoginViewModel.kt index a69d2c6..e5cefbf 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/login/LoginViewModel.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/login/LoginViewModel.kt @@ -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 = 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 = _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() diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationScreens.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationScreens.kt index c5bcf91..0ca022d 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationScreens.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationScreens.kt @@ -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)) }) diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationViewModels.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationViewModels.kt index 895c4eb..6097153 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationViewModels.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationViewModels.kt @@ -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 = 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 = _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 = 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 = _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 = 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.") + } } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailScreens.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailScreens.kt index 2a29df5..378c410 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailScreens.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailScreens.kt @@ -28,6 +28,7 @@ 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 @@ -179,7 +180,7 @@ private fun NewsCard(item: NewsDto) { if (item.isHidden) Badge("Ausgeblendet") } } - Text(item.content, color = Accent700) + RichText(item.content) } } } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/membership/MembershipScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/membership/MembershipScreen.kt index 081d9d3..ee3b0b4 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/membership/MembershipScreen.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/membership/MembershipScreen.kt @@ -21,7 +21,6 @@ import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -39,6 +38,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import de.harheimertc.ui.navigation.Destinations +import de.harheimertc.ui.components.ValidatedTextField import de.harheimertc.ui.theme.Accent500 import de.harheimertc.ui.theme.Accent700 import de.harheimertc.ui.theme.Accent900 @@ -85,33 +85,33 @@ fun MembershipScreen( Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(13.dp)) { Text("Beitrittserklärung", style = MaterialTheme.typography.titleLarge, color = Accent900) FormHeading("Persönliche Daten") - TextInput("Vorname *", form.vorname) { viewModel.update(form.copy(vorname = it)) } - TextInput("Nachname *", form.nachname) { viewModel.update(form.copy(nachname = it)) } - TextInput("Straße und Hausnummer *", form.strasse) { viewModel.update(form.copy(strasse = it)) } + TextInput("Vorname *", form.vorname, error = state.fieldErrors["vorname"]) { viewModel.update(form.copy(vorname = it)) } + TextInput("Nachname *", form.nachname, error = state.fieldErrors["nachname"]) { viewModel.update(form.copy(nachname = it)) } + TextInput("Straße und Hausnummer *", form.strasse, error = state.fieldErrors["strasse"]) { viewModel.update(form.copy(strasse = it)) } Row(horizontalArrangement = Arrangement.spacedBy(9.dp)) { - TextInput("PLZ *", form.plz, Modifier.weight(0.38f), KeyboardType.Number) { viewModel.update(form.copy(plz = it)) } - TextInput("Wohnort *", form.ort, Modifier.weight(0.62f)) { viewModel.update(form.copy(ort = it)) } + TextInput("PLZ *", form.plz, Modifier.weight(0.38f), KeyboardType.Number, state.fieldErrors["plz"]) { viewModel.update(form.copy(plz = it)) } + TextInput("Wohnort *", form.ort, Modifier.weight(0.62f), error = state.fieldErrors["ort"]) { viewModel.update(form.copy(ort = it)) } } - TextInput("Geburtsdatum * (JJJJ-MM-TT)", form.geburtsdatum) { viewModel.update(form.copy(geburtsdatum = it)) } - TextInput("E-Mail *", form.email, keyboard = KeyboardType.Email) { viewModel.update(form.copy(email = it)) } + TextInput("Geburtsdatum * (JJJJ-MM-TT)", form.geburtsdatum, error = state.fieldErrors["geburtsdatum"]) { viewModel.update(form.copy(geburtsdatum = it)) } + TextInput("E-Mail *", form.email, keyboard = KeyboardType.Email, error = state.fieldErrors["email"]) { viewModel.update(form.copy(email = it)) } TextInput("Telefon (Mobil)", form.telefon, keyboard = KeyboardType.Phone) { viewModel.update(form.copy(telefon = it)) } FormHeading("Mitgliedschaftsart") ChoiceRow("Aktives Mitglied", form.art == "aktiv") { viewModel.update(form.copy(art = "aktiv")) } ChoiceRow("Passives Mitglied", form.art == "passiv") { viewModel.update(form.copy(art = "passiv")) } FeeInfo() - AgreementRow("Hierzu erteile ich das SEPA-Lastschriftmandat. *", form.lastschrift) { + AgreementRow("Hierzu erteile ich das SEPA-Lastschriftmandat. *", form.lastschrift, state.fieldErrors["lastschrift"]) { viewModel.update(form.copy(lastschrift = it)) } FormHeading("Bankdaten für SEPA-Lastschrift") - TextInput("Kontoinhaber *", form.kontoinhaber) { viewModel.update(form.copy(kontoinhaber = it)) } - TextInput("IBAN *", form.iban) { viewModel.update(form.copy(iban = it)) } + TextInput("Kontoinhaber *", form.kontoinhaber, error = state.fieldErrors["kontoinhaber"]) { viewModel.update(form.copy(kontoinhaber = it)) } + TextInput("IBAN *", form.iban, error = state.fieldErrors["iban"]) { viewModel.update(form.copy(iban = it)) } TextInput("BIC", form.bic) { viewModel.update(form.copy(bic = it)) } TextInput("Kreditinstitut", form.bank) { viewModel.update(form.copy(bank = it)) } FormHeading("Datenschutz und Vereinssatzung") - AgreementRow("Ich willige in die erforderliche Verarbeitung und Veröffentlichung gemäß Antrag ein. *", form.datenschutz) { + AgreementRow("Ich willige in die erforderliche Verarbeitung und Veröffentlichung gemäß Antrag ein. *", form.datenschutz, state.fieldErrors["datenschutz"]) { viewModel.update(form.copy(datenschutz = it)) } - AgreementRow("Ich erkenne die Vereinssatzung an. *", form.satzung) { + AgreementRow("Ich erkenne die Vereinssatzung an. *", form.satzung, state.fieldErrors["satzung"]) { viewModel.update(form.copy(satzung = it)) } state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } @@ -164,12 +164,14 @@ private fun TextInput( value: String, modifier: Modifier = Modifier.fillMaxWidth(), keyboard: KeyboardType = KeyboardType.Text, + error: String? = null, onChange: (String) -> Unit, ) { - OutlinedTextField( + ValidatedTextField( value = value, onValueChange = onChange, - label = { Text(label) }, + label = label, + error = error, keyboardOptions = KeyboardOptions(keyboardType = keyboard), modifier = modifier, singleLine = true, @@ -185,10 +187,13 @@ private fun ChoiceRow(label: String, selected: Boolean, onClick: () -> Unit) { } @Composable -private fun AgreementRow(label: String, selected: Boolean, onChange: (Boolean) -> Unit) { - Row(verticalAlignment = Alignment.Top) { - Checkbox(checked = selected, onCheckedChange = onChange) - Text(label, color = Accent700, modifier = Modifier.padding(top = 12.dp)) +private fun AgreementRow(label: String, selected: Boolean, error: String? = null, onChange: (Boolean) -> Unit) { + Column { + Row(verticalAlignment = Alignment.Top) { + Checkbox(checked = selected, onCheckedChange = onChange) + Text(label, color = Accent700, modifier = Modifier.padding(top = 12.dp)) + } + error?.let { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(start = 48.dp)) } } } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/membership/MembershipViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/membership/MembershipViewModel.kt index dee042a..c7700c7 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/membership/MembershipViewModel.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/membership/MembershipViewModel.kt @@ -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 = 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 = _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 = 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 = 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") } } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/newsletter/NewsletterScreens.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/newsletter/NewsletterScreens.kt new file mode 100644 index 0000000..d9f9ae6 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/newsletter/NewsletterScreens.kt @@ -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) + } + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/newsletter/NewsletterViewModels.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/newsletter/NewsletterViewModels.kt new file mode 100644 index 0000000..a153c14 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/newsletter/NewsletterViewModels.kt @@ -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 = emptyList(), + val form: NewsletterFormState = NewsletterFormState(), + val fieldErrors: Map = 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 = _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) { + 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 = _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.") } + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/profile/ProfileScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/profile/ProfileScreen.kt index c307d83..e4ef8fe 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/profile/ProfileScreen.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/profile/ProfileScreen.kt @@ -17,6 +17,7 @@ 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 @@ -27,6 +28,7 @@ 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 @@ -35,6 +37,7 @@ 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 @@ -45,6 +48,7 @@ fun ProfileScreen( ) { val state by viewModel.state.collectAsState() val form = state.form + val context = LocalContext.current LazyColumn( modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)), @@ -70,35 +74,34 @@ fun ProfileScreen( } else { item { ProfileCard("Persönliche Daten") { - OutlinedTextField( + ValidatedTextField( value = form.name, onValueChange = { viewModel.update(form.copy(name = it)) }, - label = { Text("Name") }, + label = "Name", + error = state.fieldErrors["name"], singleLine = true, - modifier = Modifier.fillMaxWidth(), ) - OutlinedTextField( + ValidatedTextField( value = form.email, onValueChange = { viewModel.update(form.copy(email = it)) }, - label = { Text("E-Mail") }, + label = "E-Mail", + error = state.fieldErrors["email"], keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), singleLine = true, - modifier = Modifier.fillMaxWidth(), ) - OutlinedTextField( + ValidatedTextField( value = form.phone, onValueChange = { viewModel.update(form.copy(phone = it)) }, - label = { Text("Telefon") }, + label = "Telefon", keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), singleLine = true, - modifier = Modifier.fillMaxWidth(), ) - OutlinedTextField( + ValidatedTextField( value = form.birthDate, onValueChange = { viewModel.update(form.copy(birthDate = it)) }, - label = { Text("Geburtsdatum (JJJJ-MM-TT)") }, + label = "Geburtsdatum (JJJJ-MM-TT)", + error = state.fieldErrors["birthDate"], singleLine = true, - modifier = Modifier.fillMaxWidth(), ) } } @@ -114,34 +117,71 @@ fun ProfileScreen( item { ProfileCard("Passwort ändern") { - OutlinedTextField( + ValidatedTextField( value = form.currentPassword, onValueChange = { viewModel.update(form.copy(currentPassword = it)) }, - label = { Text("Aktuelles Passwort") }, + label = "Aktuelles Passwort", + error = state.fieldErrors["currentPassword"], visualTransformation = PasswordVisualTransformation(), singleLine = true, - modifier = Modifier.fillMaxWidth(), ) - OutlinedTextField( + ValidatedTextField( value = form.newPassword, onValueChange = { viewModel.update(form.copy(newPassword = it)) }, - label = { Text("Neues Passwort") }, + label = "Neues Passwort", + error = state.fieldErrors["newPassword"], visualTransformation = PasswordVisualTransformation(), singleLine = true, - modifier = Modifier.fillMaxWidth(), ) - OutlinedTextField( + ValidatedTextField( value = form.confirmPassword, onValueChange = { viewModel.update(form.copy(confirmPassword = it)) }, - label = { Text("Neues Passwort wiederholen") }, + label = "Neues Passwort wiederholen", + error = state.fieldErrors["confirmPassword"], visualTransformation = PasswordVisualTransformation(), singleLine = true, - modifier = Modifier.fillMaxWidth(), ) 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, diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/profile/ProfileViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/profile/ProfileViewModel.kt index 45bf9ad..8c1f4ba 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/profile/ProfileViewModel.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/profile/ProfileViewModel.kt @@ -1,11 +1,16 @@ 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 @@ -27,8 +32,11 @@ data class ProfileFormState( data class ProfileUiState( val form: ProfileFormState = ProfileFormState(), + val passkeys: List = emptyList(), + val fieldErrors: Map = emptyMap(), val loading: Boolean = true, val saving: Boolean = false, + val passkeyLoading: Boolean = false, val error: String? = null, val message: String? = null, ) @@ -36,6 +44,7 @@ data class ProfileUiState( @HiltViewModel class ProfileViewModel @Inject constructor( private val repository: ProfileRepository, + private val passkeyRepository: PasskeyRepository, ) : ViewModel() { private val _state = MutableStateFlow(ProfileUiState()) val state: StateFlow = _state @@ -64,6 +73,7 @@ class ProfileViewModel @Inject constructor( showBirthday = visibility.showBirthday, ), ) + loadPasskeys() } .onFailure { _state.value = _state.value.copy( @@ -74,28 +84,80 @@ class ProfileViewModel @Inject constructor( } } + 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, error = null, message = null) + _state.value = _state.value.copy(form = form, fieldErrors = emptyMap(), error = null, message = null) } fun save() { val form = _state.value.form - val validationError = when { - form.name.isBlank() || form.email.isBlank() -> "Name und E-Mail sind erforderlich." - !form.email.contains("@") -> "Bitte eine gültige E-Mail-Adresse eingeben." - form.currentPassword.isNotBlank() || form.newPassword.isNotBlank() || form.confirmPassword.isNotBlank() -> { - when { - form.currentPassword.isBlank() -> "Bitte geben Sie Ihr aktuelles Passwort ein." - form.newPassword.isBlank() -> "Bitte geben Sie ein neues Passwort ein." - form.newPassword != form.confirmPassword -> "Die neuen Passwörter stimmen nicht überein." - form.newPassword.length < 6 -> "Das neue Passwort muss mindestens 6 Zeichen lang sein." - else -> null - } + 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.") } - else -> null } - if (validationError != null) { - _state.value = _state.value.copy(error = validationError, message = null) + if (fieldErrors.isNotEmpty()) { + _state.value = _state.value.copy(fieldErrors = fieldErrors, error = "Bitte prüfen Sie die markierten Felder.", message = null) return } @@ -117,6 +179,7 @@ class ProfileViewModel @Inject constructor( newPassword = form.newPassword.takeIf(String::isNotBlank), ), ).onSuccess { response -> + val current = _state.value val next = response.user val visibility = next?.visibility ?: ProfileVisibilityDto( form.showEmail, @@ -127,6 +190,8 @@ class ProfileViewModel @Inject constructor( _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(), diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/publicpages/AdditionalPublicScreens.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/publicpages/AdditionalPublicScreens.kt new file mode 100644 index 0000000..59b701e --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/publicpages/AdditionalPublicScreens.kt @@ -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 = 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" } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/publicpages/PublicScreenComponents.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/publicpages/PublicScreenComponents.kt index aa7a82c..1e8bee5 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/publicpages/PublicScreenComponents.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/publicpages/PublicScreenComponents.kt @@ -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) { diff --git a/components/Facilities.vue b/components/Facilities.vue deleted file mode 100644 index 9587bfd..0000000 --- a/components/Facilities.vue +++ /dev/null @@ -1,113 +0,0 @@ - - - - diff --git a/components/ModalDialog.vue b/components/ModalDialog.vue index 5998f8d..e9bc003 100644 --- a/components/ModalDialog.vue +++ b/components/ModalDialog.vue @@ -1,43 +1,14 @@