feat: replace success modal with non-blocking toast notification
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m10s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m14s

feat: add global event listener for mannschaften updates in Navigation component

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

refactor: remove unused anlagen page

fix: update CmsMannschaften reference in sportbetrieb page for reactivity

fix: enhance authentication token retrieval in passkey API endpoints

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

fix: unify token retrieval method across passkey API endpoints

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

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

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

feat: implement newsletter subscription and unsubscription screens in Android app

feat: create public pages including Impressum with dynamic content loading
This commit is contained in:
Torsten Schulz (local)
2026-05-28 08:33:28 +02:00
parent e033d716dd
commit 0528334eb4
37 changed files with 1297 additions and 364 deletions

View File

@@ -41,7 +41,7 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
[x] 4. CI-Build: Gradle-Config und GitHub Actions Skeleton [x] 4. CI-Build: Gradle-Config und GitHub Actions Skeleton
[x] 5. Theme: `Color.kt`, `Typography.kt`, `Theme.kt` erstellen und Tailwind-Farben mappen [x] 5. Theme: `Color.kt`, `Typography.kt`, `Theme.kt` erstellen und Tailwind-Farben mappen
[x] 6. Fonts: Inter + Montserrat einbinden (res/font oder GoogleFonts) [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] 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] 7b. Adaptive Navigation: Tablet mit persistentem Header/Hauptmenue, Smartphone mit bestehender screenbezogener Navigation
[x] 7c. Branding: vorhandenes Web-Logo als optimierte Android-Ressource in der App-Navigation verwenden [x] 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/trainer`
- [x] `/training/anfaenger` - [x] `/training/anfaenger`
- [x] `/tt-regeln`: Regelübersicht mit DTTB- und PDF-Aufruf - [x] `/tt-regeln`: Regelübersicht mit DTTB- und PDF-Aufruf
[ ] 10a. Weitere öffentliche bzw. bestehende Web-Routen prüfen und portieren [x] 10a. Weitere öffentliche bzw. bestehende Web-Routen prüfen und portieren
- [ ] `/anlagen` - [x] `/impressum`
- [ ] `/impressum` - [x] Legacy-/Doppelrouten geklärt: `/galerie`, `/geschichte`, `/satzung`, `/ueber-uns`, `/spielplan`, `/verein/tt-regeln`, `/mannschaft/[slug]`, `/mannschaften/herren`, `/mannschaften/damen`, `/mannschaften/jugend`
- [ ] Legacy-/Doppelrouten klären: `/galerie`, `/geschichte`, `/satzung`, `/ueber-uns`, `/spielplan`, `/verein/tt-regeln`, `/mannschaft/[slug]`, `/mannschaften/herren`, `/mannschaften/damen`, `/mannschaften/jugend` [x] 10b. Newsletter-Screens portieren
[ ] 10b. Newsletter-Screens portieren - [x] `/newsletter/subscribe`
- [ ] `/newsletter/subscribe` - [x] `/newsletter/unsubscribe`
- [ ] `/newsletter/unsubscribe` - [x] `/newsletter/confirm`, `/newsletter/confirmed`, `/newsletter/unsubscribed`
- [ ] `/newsletter/confirm`, `/newsletter/confirmed`, `/newsletter/unsubscribed`
[x] 10c. Auth-Screens portieren [x] 10c. Auth-Screens portieren
- [x] `/login`: Passwort-Login und Logout in der laufenden Sitzung - [x] `/login`: Passwort-Login und Logout in der laufenden Sitzung
- [x] `/registrieren` - [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] 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 - [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 - [ ] 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) [ ] 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 [x] 15. Rich-Text: Anzeige von HTML (Compose + WebView) und ggf. Editor via WebView-bridge
[ ] 16. Formulare: Validierung (clientseitig) und Fehlerdarstellung - [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 [ ] 17. Offline & Caching: Room für persistente Daten, Response-Caching, Sync-Strategie
[ ] 18. Lokalisierung: `strings.xml` (DE + EN) und i18n-Check [ ] 18. Lokalisierung: `strings.xml` (DE + EN) und i18n-Check
[ ] 19. Accessibility: ContentDescription, Focus, Farben/Kontrast prüfen [ ] 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) 6) Nächste Aktionen (sofort)
- Web-Login bei Bedarf auf denselben Refresh-Flow migrieren; bis dahin bleiben Web-Cookie-Sitzungen bewusst kompatibel. - 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. - 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. - Saisonwahl für Mannschaftsübersicht/-details wie in der Web-UI ergänzen.
- Weitere Mitgliederbereich/CMS-Screens portieren; dabei rollenabhängige `Intern`-Navigation und Bearer-Unterstützung der jeweils verwendeten Backend-Endpunkte ergänzen. - Weitere 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: 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-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: 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 8) Android-Testumgebungen
- Lokal im Emulator: `./gradlew :app:installLocalDebug` verwendet `http://10.0.2.2:3100/` und die App-ID `de.harheimertc.local`. - Lokal im Emulator: `./gradlew :app:installLocalDebug` verwendet `http://10.0.2.2:3100/` und die App-ID `de.harheimertc.local`.

View File

@@ -94,6 +94,10 @@ dependencies {
implementation("com.squareup.retrofit2:converter-moshi:2.9.0") implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
implementation("com.squareup.moshi:moshi-kotlin:1.15.1") 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 // Coil
implementation("io.coil-kt:coil-compose:2.4.0") implementation("io.coil-kt:coil-compose:2.4.0")

View File

@@ -10,6 +10,7 @@ import retrofit2.http.Query
import retrofit2.http.Url import retrofit2.http.Url
import retrofit2.http.Streaming import retrofit2.http.Streaming
import okhttp3.ResponseBody import okhttp3.ResponseBody
import okhttp3.RequestBody
data class ContactRequest(val name: String, val email: String, val message: String) data class ContactRequest(val name: String, val email: String, val message: String)
data class ContactResponse(val ok: Boolean, val id: String? = null) data class ContactResponse(val ok: Boolean, val id: String? = null)
@@ -155,6 +156,26 @@ data class AuthStatusResponse(
) )
data class ResetPasswordRequest(val email: String) data class ResetPasswordRequest(val email: String)
data class AuthMessageResponse(val success: Boolean = false, val message: String? = null) data class AuthMessageResponse(val success: Boolean = false, val message: String? = null)
data class PasskeyAuthenticationOptionsRequest(
val email: String? = null,
val client: String = "android",
)
data class PasskeyRegistrationOptionsRequest(
val preferredAuthenticatorType: String? = null,
val client: String = "android",
)
data class PasskeyDto(
val id: String = "",
val credentialId: String = "",
val createdAt: String? = null,
val lastUsedAt: String? = null,
val name: String = "",
)
data class PasskeysResponse(
val success: Boolean = false,
val passkeys: List<PasskeyDto> = emptyList(),
)
data class RemovePasskeyRequest(val credentialId: String)
data class ProfileVisibilityDto( data class ProfileVisibilityDto(
val showEmail: Boolean = true, val showEmail: Boolean = true,
val showPhone: Boolean = true, val showPhone: Boolean = true,
@@ -455,6 +476,24 @@ interface ApiService {
@POST("/api/auth/register") @POST("/api/auth/register")
suspend fun register(@Body request: RegistrationRequest): Response<AuthMessageResponse> suspend fun register(@Body request: RegistrationRequest): Response<AuthMessageResponse>
@POST("/api/auth/passkeys/authentication-options")
suspend fun passkeyAuthenticationOptions(@Body request: PasskeyAuthenticationOptionsRequest): Response<ResponseBody>
@POST("/api/auth/passkeys/login")
suspend fun passkeyLogin(@Body request: RequestBody): Response<LoginResponse>
@GET("/api/auth/passkeys/list")
suspend fun passkeys(): Response<PasskeysResponse>
@POST("/api/auth/passkeys/registration-options")
suspend fun passkeyRegistrationOptions(@Body request: PasskeyRegistrationOptionsRequest): Response<ResponseBody>
@POST("/api/auth/passkeys/register")
suspend fun registerPasskey(@Body request: RequestBody): Response<AuthMessageResponse>
@POST("/api/auth/passkeys/remove")
suspend fun removePasskey(@Body request: RemovePasskeyRequest): Response<AuthMessageResponse>
@GET("/api/profile") @GET("/api/profile")
suspend fun profile(): Response<ProfileResponse> suspend fun profile(): Response<ProfileResponse>

View File

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

View File

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

View File

@@ -228,6 +228,7 @@ private fun menuSection(route: String?): MenuSection? = when (route) {
Destinations.Satzung.route, Destinations.Satzung.route,
Destinations.Vereinsmeisterschaften.route, Destinations.Vereinsmeisterschaften.route,
Destinations.Links.route, Destinations.Links.route,
Destinations.Impressum.route,
Destinations.Gallery.route -> MenuSection.VEREIN Destinations.Gallery.route -> MenuSection.VEREIN
Destinations.Mannschaften.route, Destinations.Mannschaften.route,
@@ -240,7 +241,10 @@ private fun menuSection(route: String?): MenuSection? = when (route) {
Destinations.Regeln.route -> MenuSection.TRAINING Destinations.Regeln.route -> MenuSection.TRAINING
Destinations.NewsletterSubscribe.route, 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.MemberArea.route,
Destinations.Members.route, Destinations.Members.route,
Destinations.MemberNews.route, Destinations.MemberNews.route,
@@ -271,6 +275,7 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuT
MenuTarget("Vereinsmeisterschaften", Destinations.Vereinsmeisterschaften.route), MenuTarget("Vereinsmeisterschaften", Destinations.Vereinsmeisterschaften.route),
MenuTarget("Galerie", Destinations.Gallery.route), MenuTarget("Galerie", Destinations.Gallery.route),
MenuTarget("Links", Destinations.Links.route), MenuTarget("Links", Destinations.Links.route),
MenuTarget("Impressum", Destinations.Impressum.route),
) )
MenuSection.MANNSCHAFTEN -> listOf( MenuSection.MANNSCHAFTEN -> listOf(
MenuTarget("Übersicht", Destinations.Mannschaften.route), MenuTarget("Übersicht", Destinations.Mannschaften.route),
@@ -287,6 +292,7 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuT
MenuSection.NEWSLETTER -> listOf( MenuSection.NEWSLETTER -> listOf(
MenuTarget("Abonnieren", Destinations.NewsletterSubscribe.route), MenuTarget("Abonnieren", Destinations.NewsletterSubscribe.route),
MenuTarget("Abmelden", Destinations.NewsletterUnsubscribe.route), MenuTarget("Abmelden", Destinations.NewsletterUnsubscribe.route),
MenuTarget("Bestätigt", Destinations.NewsletterConfirmed.route),
) )
MenuSection.INTERN -> buildList { MenuSection.INTERN -> buildList {
add(MenuTarget("Übersicht", Destinations.MemberArea.route)) add(MenuTarget("Übersicht", Destinations.MemberArea.route))

View File

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

View File

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

View File

@@ -8,10 +8,14 @@ sealed class Destinations(val route: String) {
object Satzung : Destinations("verein/satzung") object Satzung : Destinations("verein/satzung")
object Vereinsmeisterschaften : Destinations("verein/vereinsmeisterschaften") object Vereinsmeisterschaften : Destinations("verein/vereinsmeisterschaften")
object Links : Destinations("verein/links") object Links : Destinations("verein/links")
object Impressum : Destinations("impressum")
object Mannschaften : Destinations("mannschaften") object Mannschaften : Destinations("mannschaften")
object MannschaftDetail : Destinations("mannschaften/{slug}") { object MannschaftDetail : Destinations("mannschaften/{slug}") {
fun create(slug: String): String = "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 Termine : Destinations("termine")
object Spielplan : Destinations("spielplan") object Spielplan : Destinations("spielplan")
object Spielsysteme : Destinations("mannschaften/spielsysteme") object Spielsysteme : Destinations("mannschaften/spielsysteme")
@@ -22,6 +26,9 @@ sealed class Destinations(val route: String) {
object Gallery : Destinations("gallery") object Gallery : Destinations("gallery")
object NewsletterSubscribe : Destinations("newsletter/subscribe") object NewsletterSubscribe : Destinations("newsletter/subscribe")
object NewsletterUnsubscribe : Destinations("newsletter/unsubscribe") 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 Contact : Destinations("contact")
object Membership : Destinations("membership") object Membership : Destinations("membership")
object Login : Destinations("login") object Login : Destinations("login")

View File

@@ -15,7 +15,6 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import de.harheimertc.ui.components.AppNavigationHeader import de.harheimertc.ui.components.AppNavigationHeader
import de.harheimertc.ui.components.PendingPage
@Composable @Composable
fun NavGraph( fun NavGraph(
@@ -90,12 +89,27 @@ fun NavGraph(
showBackNavigation = !persistentNavigation, showBackNavigation = !persistentNavigation,
) )
} }
composable(Destinations.Impressum.route) {
de.harheimertc.ui.screens.publicpages.ImpressumScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Mannschaften.route) { composable(Destinations.Mannschaften.route) {
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen( de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(
navController = navController, navController = navController,
showBackNavigation = !persistentNavigation, 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 -> composable(Destinations.MannschaftDetail.route) { entry ->
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen( de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
slug = entry.arguments?.getString("slug").orEmpty(), slug = entry.arguments?.getString("slug").orEmpty(),
@@ -103,6 +117,13 @@ fun NavGraph(
showBackNavigation = !persistentNavigation, 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) { composable(Destinations.Termine.route) {
de.harheimertc.ui.screens.termine.TermineScreen( de.harheimertc.ui.screens.termine.TermineScreen(
navController = navController, navController = navController,
@@ -121,6 +142,9 @@ fun NavGraph(
showBackNavigation = !persistentNavigation, showBackNavigation = !persistentNavigation,
) )
} }
composable("spielsysteme") {
de.harheimertc.ui.screens.publicpages.SpielsystemeScreen(navController, !persistentNavigation)
}
composable(Destinations.Training.route) { composable(Destinations.Training.route) {
de.harheimertc.ui.screens.training.TrainingScreen( de.harheimertc.ui.screens.training.TrainingScreen(
navController = navController, navController = navController,
@@ -145,14 +169,48 @@ fun NavGraph(
showBackNavigation = !persistentNavigation, 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) { composable(Destinations.Gallery.route) {
de.harheimertc.ui.screens.gallery.GalleryScreen() de.harheimertc.ui.screens.gallery.GalleryScreen()
} }
composable("galerie") {
de.harheimertc.ui.screens.gallery.GalleryScreen()
}
composable(Destinations.NewsletterSubscribe.route) { 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) { 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) { composable(Destinations.Contact.route) {
de.harheimertc.ui.screens.contact.ContactScreen() de.harheimertc.ui.screens.contact.ContactScreen()
@@ -181,6 +239,15 @@ fun NavGraph(
showBackNavigation = !persistentNavigation, 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) { composable(Destinations.MemberArea.route) {
de.harheimertc.ui.screens.memberarea.MemberAreaScreen( de.harheimertc.ui.screens.memberarea.MemberAreaScreen(
navController = navController, navController = navController,

View File

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

View File

@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.ContactRequest import de.harheimertc.data.ContactRequest
import de.harheimertc.repositories.ContactRepository import de.harheimertc.repositories.ContactRepository
import de.harheimertc.ui.components.isValidEmail
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -27,16 +28,38 @@ class ContactViewModel @Inject constructor(private val repo: ContactRepository)
private val _result = MutableStateFlow<String?>(null) private val _result = MutableStateFlow<String?>(null)
val result: StateFlow<String?> = _result val result: StateFlow<String?> = _result
fun onName(v: String) { _name.value = v } private val _fieldErrors = MutableStateFlow<Map<String, String>>(emptyMap())
fun onEmail(v: String) { _email.value = v } val fieldErrors: StateFlow<Map<String, String>> = _fieldErrors
fun onMessage(v: String) { _message.value = v }
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() { fun send() {
val n = _name.value.trim() val n = _name.value.trim()
val e = _email.value.trim() val e = _email.value.trim()
val m = _message.value.trim() val m = _message.value.trim()
if (n.isEmpty() || e.isEmpty() || m.isEmpty()) { val errors = buildMap {
_result.value = "Bitte alle Felder ausfüllen" 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 return
} }
viewModelScope.launch { viewModelScope.launch {
@@ -45,6 +68,7 @@ class ContactViewModel @Inject constructor(private val repo: ContactRepository)
val resp = repo.sendContact(ContactRequest(n, e, m)) val resp = repo.sendContact(ContactRequest(n, e, m))
if (resp.isSuccessful) { if (resp.isSuccessful) {
_result.value = "Nachricht gesendet" _result.value = "Nachricht gesendet"
_fieldErrors.value = emptyMap()
_name.value = "" _name.value = ""
_email.value = "" _email.value = ""
_message.value = "" _message.value = ""
@@ -58,4 +82,11 @@ class ContactViewModel @Inject constructor(private val repo: ContactRepository)
} }
} }
} }
private fun clearFieldError(field: String) {
if (_fieldErrors.value.containsKey(field)) {
_fieldErrors.value = _fieldErrors.value - field
}
_result.value = null
}
} }

View File

@@ -52,6 +52,7 @@ import de.harheimertc.data.NewsDto
import de.harheimertc.data.SpielDto import de.harheimertc.data.SpielDto
import de.harheimertc.data.TerminDto import de.harheimertc.data.TerminDto
import de.harheimertc.ui.components.AppNavigationHeader import de.harheimertc.ui.components.AppNavigationHeader
import de.harheimertc.ui.components.RichText
import de.harheimertc.ui.navigation.Destinations import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.ui.theme.Accent100 import de.harheimertc.ui.theme.Accent100
import de.harheimertc.ui.theme.Accent200 import de.harheimertc.ui.theme.Accent200
@@ -83,7 +84,7 @@ fun HomeScreen(
Text(item.title, style = MaterialTheme.typography.titleLarge) 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") } }, confirmButton = { TextButton(onClick = { selectedNews = null }) { Text("Schließen") } },
) )
} }
@@ -221,13 +222,7 @@ private fun HomeNewsSection(news: List<NewsDto>, onOpen: (NewsDto) -> Unit) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) { Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) {
Text(formatNewsDate(item.created), style = MaterialTheme.typography.labelSmall, color = Accent500) Text(formatNewsDate(item.created), style = MaterialTheme.typography.labelSmall, color = Accent500)
Text(item.title, style = MaterialTheme.typography.titleLarge, color = Accent900) Text(item.title, style = MaterialTheme.typography.titleLarge, color = Accent900)
Text( RichText(item.content)
item.content,
style = MaterialTheme.typography.bodyMedium,
color = Accent700,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
} }
} }
} }

View File

@@ -25,6 +25,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation 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.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import de.harheimertc.ui.theme.Accent500 import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.components.ValidatedTextField
import de.harheimertc.ui.theme.Accent900 import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary100 import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600 import de.harheimertc.ui.theme.Primary600
@@ -46,6 +48,7 @@ fun LoginScreen(
viewModel: LoginViewModel = hiltViewModel(), viewModel: LoginViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val context = LocalContext.current
LaunchedEffect(state.loggedIn, state.restoring) { LaunchedEffect(state.loggedIn, state.restoring) {
if (state.loggedIn && !state.restoring) { if (state.loggedIn && !state.restoring) {
@@ -92,26 +95,33 @@ fun LoginScreen(
CircularProgressIndicator(color = Primary600, modifier = Modifier.size(28.dp)) CircularProgressIndicator(color = Primary600, modifier = Modifier.size(28.dp))
Text("Sitzung wird geprüft...", color = Accent500) Text("Sitzung wird geprüft...", color = Accent500)
} else if (!state.loggedIn) { } else if (!state.loggedIn) {
OutlinedTextField( ValidatedTextField(
value = state.email, value = state.email,
onValueChange = viewModel::setEmail, onValueChange = viewModel::setEmail,
label = { Text("E-Mail-Adresse") }, label = "E-Mail-Adresse",
error = state.fieldErrors["email"],
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(),
) )
OutlinedTextField( ValidatedTextField(
value = state.password, value = state.password,
onValueChange = viewModel::setPassword, onValueChange = viewModel::setPassword,
label = { Text("Passwort") }, label = "Passwort",
error = state.fieldErrors["password"],
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(),
) )
Button(onClick = viewModel::login, enabled = !state.loading, 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)) 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") 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()) { TextButton(onClick = { navController.navigate(Destinations.PasswordReset.route) }, modifier = Modifier.fillMaxWidth()) {
Text("Passwort vergessen?") Text("Passwort vergessen?")
} }

View File

@@ -1,9 +1,12 @@
package de.harheimertc.ui.screens.login package de.harheimertc.ui.screens.login
import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.repositories.LoginRepository 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -12,6 +15,7 @@ import javax.inject.Inject
data class LoginUiState( data class LoginUiState(
val email: String = "", val email: String = "",
val password: String = "", val password: String = "",
val fieldErrors: Map<String, String> = emptyMap(),
val loading: Boolean = false, val loading: Boolean = false,
val restoring: Boolean = true, val restoring: Boolean = true,
val loggedIn: Boolean = false, val loggedIn: Boolean = false,
@@ -22,7 +26,10 @@ data class LoginUiState(
) )
@HiltViewModel @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()) private val _state = MutableStateFlow(LoginUiState())
val state: StateFlow<LoginUiState> = _state val state: StateFlow<LoginUiState> = _state
@@ -42,17 +49,21 @@ class LoginViewModel @Inject constructor(private val repository: LoginRepository
} }
fun setEmail(value: String) { 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) { 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() { fun login() {
val current = _state.value val current = _state.value
if (current.email.isBlank() || current.password.isBlank()) { val fieldErrors = buildMap {
_state.value = current.copy(error = "Bitte E-Mail-Adresse und Passwort eingeben.") 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 return
} }
viewModelScope.launch { 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() { fun logout() {
viewModelScope.launch { viewModelScope.launch {
repository.logout() repository.logout()

View File

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

View File

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

View File

@@ -28,6 +28,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import de.harheimertc.data.MemberDto import de.harheimertc.data.MemberDto
import de.harheimertc.data.NewsDto import de.harheimertc.data.NewsDto
import de.harheimertc.ui.components.RichText
import de.harheimertc.ui.theme.Accent100 import de.harheimertc.ui.theme.Accent100
import de.harheimertc.ui.theme.Accent500 import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent700 import de.harheimertc.ui.theme.Accent700
@@ -179,7 +180,7 @@ private fun NewsCard(item: NewsDto) {
if (item.isHidden) Badge("Ausgeblendet") if (item.isHidden) Badge("Ausgeblendet")
} }
} }
Text(item.content, color = Accent700) RichText(item.content)
} }
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -27,6 +28,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -35,6 +37,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import de.harheimertc.ui.theme.Accent500 import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent900 import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.components.ValidatedTextField
import de.harheimertc.ui.theme.Primary600 import de.harheimertc.ui.theme.Primary600
@Composable @Composable
@@ -45,6 +48,7 @@ fun ProfileScreen(
) { ) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val form = state.form val form = state.form
val context = LocalContext.current
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)), modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
@@ -70,35 +74,34 @@ fun ProfileScreen(
} else { } else {
item { item {
ProfileCard("Persönliche Daten") { ProfileCard("Persönliche Daten") {
OutlinedTextField( ValidatedTextField(
value = form.name, value = form.name,
onValueChange = { viewModel.update(form.copy(name = it)) }, onValueChange = { viewModel.update(form.copy(name = it)) },
label = { Text("Name") }, label = "Name",
error = state.fieldErrors["name"],
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(),
) )
OutlinedTextField( ValidatedTextField(
value = form.email, value = form.email,
onValueChange = { viewModel.update(form.copy(email = it)) }, onValueChange = { viewModel.update(form.copy(email = it)) },
label = { Text("E-Mail") }, label = "E-Mail",
error = state.fieldErrors["email"],
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(),
) )
OutlinedTextField( ValidatedTextField(
value = form.phone, value = form.phone,
onValueChange = { viewModel.update(form.copy(phone = it)) }, onValueChange = { viewModel.update(form.copy(phone = it)) },
label = { Text("Telefon") }, label = "Telefon",
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(),
) )
OutlinedTextField( ValidatedTextField(
value = form.birthDate, value = form.birthDate,
onValueChange = { viewModel.update(form.copy(birthDate = it)) }, onValueChange = { viewModel.update(form.copy(birthDate = it)) },
label = { Text("Geburtsdatum (JJJJ-MM-TT)") }, label = "Geburtsdatum (JJJJ-MM-TT)",
error = state.fieldErrors["birthDate"],
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(),
) )
} }
} }
@@ -114,34 +117,71 @@ fun ProfileScreen(
item { item {
ProfileCard("Passwort ändern") { ProfileCard("Passwort ändern") {
OutlinedTextField( ValidatedTextField(
value = form.currentPassword, value = form.currentPassword,
onValueChange = { viewModel.update(form.copy(currentPassword = it)) }, onValueChange = { viewModel.update(form.copy(currentPassword = it)) },
label = { Text("Aktuelles Passwort") }, label = "Aktuelles Passwort",
error = state.fieldErrors["currentPassword"],
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(),
) )
OutlinedTextField( ValidatedTextField(
value = form.newPassword, value = form.newPassword,
onValueChange = { viewModel.update(form.copy(newPassword = it)) }, onValueChange = { viewModel.update(form.copy(newPassword = it)) },
label = { Text("Neues Passwort") }, label = "Neues Passwort",
error = state.fieldErrors["newPassword"],
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(),
) )
OutlinedTextField( ValidatedTextField(
value = form.confirmPassword, value = form.confirmPassword,
onValueChange = { viewModel.update(form.copy(confirmPassword = it)) }, onValueChange = { viewModel.update(form.copy(confirmPassword = it)) },
label = { Text("Neues Passwort wiederholen") }, label = "Neues Passwort wiederholen",
error = state.fieldErrors["confirmPassword"],
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(),
) )
Text("Leer lassen, wenn das Passwort unverändert bleiben soll.", color = Accent500) 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 { item {
Button( Button(
onClick = viewModel::save, onClick = viewModel::save,

View File

@@ -1,11 +1,16 @@
package de.harheimertc.ui.screens.profile package de.harheimertc.ui.screens.profile
import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import de.harheimertc.data.PasskeyDto
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.ProfileUpdateRequest import de.harheimertc.data.ProfileUpdateRequest
import de.harheimertc.data.ProfileVisibilityDto import de.harheimertc.data.ProfileVisibilityDto
import de.harheimertc.repositories.ProfileRepository 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -27,8 +32,11 @@ data class ProfileFormState(
data class ProfileUiState( data class ProfileUiState(
val form: ProfileFormState = ProfileFormState(), val form: ProfileFormState = ProfileFormState(),
val passkeys: List<PasskeyDto> = emptyList(),
val fieldErrors: Map<String, String> = emptyMap(),
val loading: Boolean = true, val loading: Boolean = true,
val saving: Boolean = false, val saving: Boolean = false,
val passkeyLoading: Boolean = false,
val error: String? = null, val error: String? = null,
val message: String? = null, val message: String? = null,
) )
@@ -36,6 +44,7 @@ data class ProfileUiState(
@HiltViewModel @HiltViewModel
class ProfileViewModel @Inject constructor( class ProfileViewModel @Inject constructor(
private val repository: ProfileRepository, private val repository: ProfileRepository,
private val passkeyRepository: PasskeyRepository,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(ProfileUiState()) private val _state = MutableStateFlow(ProfileUiState())
val state: StateFlow<ProfileUiState> = _state val state: StateFlow<ProfileUiState> = _state
@@ -64,6 +73,7 @@ class ProfileViewModel @Inject constructor(
showBirthday = visibility.showBirthday, showBirthday = visibility.showBirthday,
), ),
) )
loadPasskeys()
} }
.onFailure { .onFailure {
_state.value = _state.value.copy( _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) { 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() { fun save() {
val form = _state.value.form val form = _state.value.form
val validationError = when { val fieldErrors = buildMap {
form.name.isBlank() || form.email.isBlank() -> "Name und E-Mail sind erforderlich." if (form.name.isBlank()) put("name", "Bitte geben Sie Ihren Namen ein.")
!form.email.contains("@") -> "Bitte eine gültige E-Mail-Adresse eingeben." if (!isValidEmail(form.email)) put("email", "Bitte geben Sie eine gültige E-Mail-Adresse ein.")
form.currentPassword.isNotBlank() || form.newPassword.isNotBlank() || form.confirmPassword.isNotBlank() -> { if (form.birthDate.isNotBlank() && !isValidIsoDate(form.birthDate)) put("birthDate", "Bitte verwenden Sie das Format JJJJ-MM-TT.")
when { if (form.currentPassword.isNotBlank() || form.newPassword.isNotBlank() || form.confirmPassword.isNotBlank()) {
form.currentPassword.isBlank() -> "Bitte geben Sie Ihr aktuelles Passwort ein." if (form.currentPassword.isBlank()) put("currentPassword", "Bitte geben Sie Ihr aktuelles Passwort ein.")
form.newPassword.isBlank() -> "Bitte geben Sie ein neues Passwort ein." if (form.newPassword.isBlank()) put("newPassword", "Bitte geben Sie ein neues Passwort ein.")
form.newPassword != form.confirmPassword -> "Die neuen Passwörter stimmen nicht überein." else if (form.newPassword.length < 6) put("newPassword", "Das neue Passwort muss mindestens 6 Zeichen lang sein.")
form.newPassword.length < 6 -> "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
} }
} }
else -> null if (fieldErrors.isNotEmpty()) {
} _state.value = _state.value.copy(fieldErrors = fieldErrors, error = "Bitte prüfen Sie die markierten Felder.", message = null)
if (validationError != null) {
_state.value = _state.value.copy(error = validationError, message = null)
return return
} }
@@ -117,6 +179,7 @@ class ProfileViewModel @Inject constructor(
newPassword = form.newPassword.takeIf(String::isNotBlank), newPassword = form.newPassword.takeIf(String::isNotBlank),
), ),
).onSuccess { response -> ).onSuccess { response ->
val current = _state.value
val next = response.user val next = response.user
val visibility = next?.visibility ?: ProfileVisibilityDto( val visibility = next?.visibility ?: ProfileVisibilityDto(
form.showEmail, form.showEmail,
@@ -127,6 +190,8 @@ class ProfileViewModel @Inject constructor(
_state.value = ProfileUiState( _state.value = ProfileUiState(
loading = false, loading = false,
message = response.message ?: "Profil erfolgreich aktualisiert.", message = response.message ?: "Profil erfolgreich aktualisiert.",
passkeys = current.passkeys,
fieldErrors = emptyMap(),
form = form.copy( form = form.copy(
name = next?.name ?: form.name.trim(), name = next?.name ?: form.name.trim(),
email = next?.email ?: form.email.trim(), email = next?.email ?: form.email.trim(),

View File

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

View File

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

View File

@@ -1,113 +0,0 @@
<template>
<section
id="facilities"
class="py-16 sm:py-20 bg-white"
>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-4">
Unsere Anlagen
</h2>
<div class="w-24 h-1 bg-primary-600 mx-auto mb-6" />
<p class="text-xl text-gray-600 max-w-3xl mx-auto">
Moderne Ausstattung und erstklassige Einrichtungen für ein perfektes Tischtenniserlebnis
</p>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-8 mb-16">
<div
v-for="facility in facilities"
:key="facility.title"
class="group relative bg-white rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 overflow-hidden border border-gray-100"
>
<div :class="['absolute top-0 left-0 right-0 h-1 bg-gradient-to-r opacity-0 group-hover:opacity-100 transition-opacity', facility.color]" />
<div class="p-8">
<div :class="['w-16 h-16 bg-gradient-to-br rounded-xl flex items-center justify-center mb-4 group-hover:scale-110 transition-transform', facility.color]">
<component
:is="facility.icon"
:size="32"
class="text-white"
/>
</div>
<h3 class="text-2xl font-display font-bold text-gray-900 mb-3">
{{ facility.title }}
</h3>
<p class="text-gray-600 leading-relaxed">
{{ facility.description }}
</p>
</div>
</div>
</div>
<!-- Image Gallery -->
<div class="grid md:grid-cols-2 gap-6">
<div class="relative h-[300px] rounded-2xl overflow-hidden shadow-xl group">
<div
class="w-full h-full bg-cover bg-center group-hover:scale-110 transition-transform duration-700"
style="background-image: url('https://images.unsplash.com/photo-1534438097545-77fef53fe2e8?q=80&w=2070')"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent flex items-end">
<p class="text-white font-semibold text-xl p-6">
Hochwertige Wettkampftische
</p>
</div>
</div>
<div class="relative h-[300px] rounded-2xl overflow-hidden shadow-xl group">
<div
class="w-full h-full bg-cover bg-center group-hover:scale-110 transition-transform duration-700"
style="background-image: url('https://images.unsplash.com/photo-1611004275469-8583ed5d7b8d?q=80&w=2070')"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent flex items-end">
<p class="text-white font-semibold text-xl p-6">
Moderne Tischtennishalle
</p>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { Sun, CloudRain, Dumbbell, Utensils, Wifi, Droplets } from 'lucide-vue-next'
const facilities = [
{
icon: Sun,
title: '8 Tischtennisplatten',
description: 'Hochwertige Wettkampftische für optimales Spielvergnügen',
color: 'from-yellow-400 to-orange-500',
},
{
icon: CloudRain,
title: 'Klimatisierte Halle',
description: 'Optimale Bedingungen bei jedem Wetter in unserer modernen Halle',
color: 'from-blue-400 to-blue-600',
},
{
icon: Dumbbell,
title: 'Trainingsbereich',
description: 'Ballmaschinen und Trainingsgeräte für gezieltes Training',
color: 'from-red-400 to-red-600',
},
{
icon: Utensils,
title: 'Clubhaus',
description: 'Gemütliches Clubhaus mit Aufenthaltsraum und Küche',
color: 'from-green-400 to-green-600',
},
{
icon: Wifi,
title: 'Kostenloses WLAN',
description: 'Schnelles Internet auf der gesamten Anlage',
color: 'from-purple-400 to-purple-600',
},
{
icon: Droplets,
title: 'Umkleiden & Duschen',
description: 'Moderne, saubere Umkleideräume mit Duschen',
color: 'from-cyan-400 to-cyan-600',
},
]
</script>

View File

@@ -1,43 +1,14 @@
<template> <template>
<!-- Success Modal --> <!-- Success Toast (bottom) -->
<div <div
v-if="showSuccess" v-if="showSuccessToast"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4" class="fixed left-0 right-0 mx-auto max-w-3xl z-50 pointer-events-none"
@click.self="closeSuccess" style="bottom:72px;"
> >
<div class="bg-white rounded-lg max-w-md w-full p-6"> <div class="pointer-events-auto mx-4 shadow-lg rounded-md overflow-hidden">
<div class="flex items-center mb-4"> <div class="px-4 py-3 bg-green-50 text-green-800 text-sm">
<div class="flex-shrink-0 w-10 h-10 mx-auto bg-green-100 rounded-full flex items-center justify-center"> <div class="font-medium">{{ toastTitle }}</div>
<svg <div class="mt-1">{{ toastMessage }}</div>
class="w-6 h-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
</div>
<div class="text-center">
<h3 class="text-lg font-medium text-gray-900 mb-2">
{{ successTitle }}
</h3>
<p class="text-sm text-gray-600 mb-6">
{{ successMessage }}
</p>
<div class="flex justify-center">
<button
class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg transition-colors"
@click="closeSuccess"
>
OK
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -138,14 +109,14 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
// Modal States // Modal / Toast States
const showSuccess = ref(false) const showSuccessToast = ref(false)
const showError = ref(false) const showError = ref(false)
const showConfirm = ref(false) const showConfirm = ref(false)
// Modal Content // Modal / Toast Content
const successTitle = ref('') const toastTitle = ref('')
const successMessage = ref('') const toastMessage = ref('')
const errorTitle = ref('') const errorTitle = ref('')
const errorMessage = ref('') const errorMessage = ref('')
const confirmTitle = ref('') const confirmTitle = ref('')
@@ -153,10 +124,14 @@ const confirmMessage = ref('')
const confirmAction = ref(null) const confirmAction = ref(null)
// Modal Functions // Modal Functions
let toastTimeout = null
const showSuccessModal = (title, message) => { const showSuccessModal = (title, message) => {
successTitle.value = title // Show non-blocking toast at bottom instead of modal dialog
successMessage.value = message toastTitle.value = title || 'Erfolg'
showSuccess.value = true toastMessage.value = message || ''
showSuccessToast.value = true
if (toastTimeout) clearTimeout(toastTimeout)
toastTimeout = setTimeout(() => { showSuccessToast.value = false; toastTimeout = null }, 3500)
} }
const showErrorModal = (title, message) => { const showErrorModal = (title, message) => {
@@ -173,7 +148,8 @@ const showConfirmModal = (title, message, action) => {
} }
const closeSuccess = () => { const closeSuccess = () => {
showSuccess.value = false showSuccessToast.value = false
if (toastTimeout) { clearTimeout(toastTimeout); toastTimeout = null }
} }
const closeError = () => { const closeError = () => {

View File

@@ -989,10 +989,17 @@ onMounted(() => {
// Close CMS dropdown when clicking outside // Close CMS dropdown when clicking outside
document.addEventListener('click', handleDocumentClick) document.addEventListener('click', handleDocumentClick)
// Listen for global updates to mannschaften (e.g., CMS saved)
if (typeof window !== 'undefined') {
window.addEventListener('mannschaften:changed', loadMannschaften)
}
}) })
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('click', handleDocumentClick) document.removeEventListener('click', handleDocumentClick)
if (typeof window !== 'undefined') {
window.removeEventListener('mannschaften:changed', loadMannschaften)
}
}) })
const toggleSubmenu = (menu) => { const toggleSubmenu = (menu) => {

View File

@@ -404,7 +404,7 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed, onUnmounted } from 'vue'
import { Plus, Trash2, Loader2, AlertCircle, Pencil, Users, ChevronUp, ChevronDown, ArrowRight } from 'lucide-vue-next' import { Plus, Trash2, Loader2, AlertCircle, Pencil, Users, ChevronUp, ChevronDown, ArrowRight } from 'lucide-vue-next'
const isLoading = ref(true) const isLoading = ref(true)
@@ -591,6 +591,12 @@ const saveCSV = async () => {
content: [header, ...rows].join('\n') content: [header, ...rows].join('\n')
} }
}) })
// Notify other parts of the app that mannschaften changed
try {
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('mannschaften:changed'))
}
} catch (e) { /* no-op */ }
} }
const moveMannschaft = async (index, delta) => { const moveMannschaft = async (index, delta) => {
@@ -674,4 +680,30 @@ onMounted(async () => {
await loadSeasons() await loadSeasons()
await loadMannschaften().catch(() => {}) await loadMannschaften().catch(() => {})
}) })
// Expose load function to parent components
try { defineExpose({ loadMannschaften }) } catch (e) { /* noop if not supported in SSR context */ }
// Reload when tab/window becomes visible or window gains focus
const handleVisibilityOrFocus = () => {
try {
if (document.visibilityState === 'visible') {
loadMannschaften().catch(() => {})
}
} catch (e) {
// ignore
}
}
if (typeof window !== 'undefined') {
window.addEventListener('visibilitychange', handleVisibilityOrFocus)
window.addEventListener('focus', handleVisibilityOrFocus)
}
onUnmounted(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('visibilitychange', handleVisibilityOrFocus)
window.removeEventListener('focus', handleVisibilityOrFocus)
}
})
</script> </script>

View File

@@ -1,14 +0,0 @@
<template>
<div class="min-h-screen">
<Facilities />
</div>
</template>
<script setup>
import Facilities from '~/components/Facilities.vue'
useHead({
title: 'Anlagen - Harheimer TC',
})
</script>

View File

@@ -33,7 +33,7 @@
<!-- Tab Content --> <!-- Tab Content -->
<div> <div>
<CmsTermine v-if="activeTab === 'termine'" /> <CmsTermine v-if="activeTab === 'termine'" />
<CmsMannschaften v-if="activeTab === 'mannschaften'" /> <CmsMannschaften ref="cmsMannschaftenRef" v-if="activeTab === 'mannschaften'" />
<CmsSpielplaene v-if="activeTab === 'spielplaene'" /> <CmsSpielplaene v-if="activeTab === 'spielplaene'" />
</div> </div>
</div> </div>
@@ -41,7 +41,7 @@
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { ref, watch } from 'vue'
import CmsTermine from '~/components/cms/CmsTermine.vue' import CmsTermine from '~/components/cms/CmsTermine.vue'
import CmsMannschaften from '~/components/cms/CmsMannschaften.vue' import CmsMannschaften from '~/components/cms/CmsMannschaften.vue'
import CmsSpielplaene from '~/components/cms/CmsSpielplaene.vue' import CmsSpielplaene from '~/components/cms/CmsSpielplaene.vue'
@@ -56,6 +56,13 @@ useHead({
}) })
const activeTab = ref('termine') const activeTab = ref('termine')
const cmsMannschaftenRef = ref(null)
watch(activeTab, (v) => {
if (v === 'mannschaften' && cmsMannschaftenRef?.value?.loadMannschaften) {
try { cmsMannschaftenRef.value.loadMannschaften() } catch (e) { /* no-op */ }
}
})
const tabs = [ const tabs = [
{ id: 'termine', label: 'Termine' }, { id: 'termine', label: 'Termine' },

View File

@@ -1,7 +1,7 @@
import { getUserFromToken } from '../../../utils/auth.js' import { getUserFromToken } from '../../../utils/auth.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const token = getCookie(event, 'auth_token') const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
const user = token ? await getUserFromToken(token) : null const user = token ? await getUserFromToken(token) : null
if (!user) { if (!user) {
@@ -24,4 +24,3 @@ export default defineEventHandler(async (event) => {
} }
}) })

View File

@@ -1,5 +1,5 @@
import { verifyAuthenticationResponse } from '@simplewebauthn/server' import { verifyAuthenticationResponse } from '@simplewebauthn/server'
import { createSession, generateToken, migrateUserRoles, readUsers, writeUsers } from '../../../utils/auth.js' import { createRefreshSession, createSession, generateAndroidAccessToken, generateToken, migrateUserRoles, readUsers, writeUsers } from '../../../utils/auth.js'
import { getWebAuthnConfig } from '../../../utils/webauthn-config.js' import { getWebAuthnConfig } from '../../../utils/webauthn-config.js'
import { consumeAuthChallenge } from '../../../utils/webauthn-challenges.js' import { consumeAuthChallenge } from '../../../utils/webauthn-challenges.js'
import { fromBase64Url, parseClientDataJSON } from '../../../utils/webauthn-encoding.js' import { fromBase64Url, parseClientDataJSON } from '../../../utils/webauthn-encoding.js'
@@ -39,6 +39,7 @@ export default defineEventHandler(async (event) => {
const ip = getClientIp(event) const ip = getClientIp(event)
const body = await readBody(event) const body = await readBody(event)
const isAndroidClient = body?.client === 'android'
const response = body?.credential const response = body?.credential
if (!response) { if (!response) {
throw createError({ statusCode: 400, statusMessage: 'Credential fehlt' }) throw createError({ statusCode: 400, statusMessage: 'Credential fehlt' })
@@ -105,10 +106,21 @@ export default defineEventHandler(async (event) => {
passkey.lastUsedAt = new Date().toISOString() passkey.lastUsedAt = new Date().toISOString()
await writeUsers(users) await writeUsers(users)
const token = generateToken(user) let token
let refreshSession = null
if (isAndroidClient) {
refreshSession = await createRefreshSession(user.id, body?.deviceName || 'Harheimer TC Android-App')
token = generateAndroidAccessToken(user, refreshSession.session.id)
} else {
token = generateToken(user)
await createSession(user.id, token) await createSession(user.id, token)
}
if (isAndroidClient) {
deleteCookie(event, 'auth_token')
} else {
setCookie(event, 'auth_token', token, { ...getAuthCookieOptions() }) setCookie(event, 'auth_token', token, { ...getAuthCookieOptions() })
}
await writeAuditLog('auth.passkey.login.success', { ip, userId: user.id }) await writeAuditLog('auth.passkey.login.success', { ip, userId: user.id })
@@ -120,6 +132,9 @@ export default defineEventHandler(async (event) => {
return { return {
success: true, success: true,
token, token,
accessToken: isAndroidClient ? token : undefined,
refreshToken: refreshSession?.refreshToken,
sessionId: refreshSession?.session.id,
user: { user: {
id: user.id, id: user.id,
email: user.email, email: user.email,
@@ -129,4 +144,3 @@ export default defineEventHandler(async (event) => {
role: roles[0] || 'mitglied' role: roles[0] || 'mitglied'
} }
}) })

View File

@@ -33,7 +33,7 @@ export default defineEventHandler(async (event) => {
return { success: true } return { success: true }
} }
const token = getCookie(event, 'auth_token') const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
const user = token ? await getUserFromToken(token) : null const user = token ? await getUserFromToken(token) : null
if (!user) { if (!user) {
@@ -119,4 +119,3 @@ export default defineEventHandler(async (event) => {
await writeAuditLog('auth.passkey.registered', { userId: user.id }) await writeAuditLog('auth.passkey.registered', { userId: user.id })
return { success: true, message: 'Passkey hinzugefügt.' } return { success: true, message: 'Passkey hinzugefügt.' }
}) })

View File

@@ -26,7 +26,7 @@ export default defineEventHandler(async (event) => {
? body.preferredAuthenticatorType ? body.preferredAuthenticatorType
: undefined : undefined
const token = getCookie(event, 'auth_token') const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
const user = token ? await getUserFromToken(token) : null const user = token ? await getUserFromToken(token) : null
if (!user) { if (!user) {
@@ -83,4 +83,3 @@ export default defineEventHandler(async (event) => {
return { success: true, options } return { success: true, options }
}) })

View File

@@ -2,7 +2,7 @@ import { getUserFromToken, readUsers, writeUsers } from '../../../utils/auth.js'
import { writeAuditLog } from '../../../utils/audit-log.js' import { writeAuditLog } from '../../../utils/audit-log.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const token = getCookie(event, 'auth_token') const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
const currentUser = token ? await getUserFromToken(token) : null const currentUser = token ? await getUserFromToken(token) : null
if (!currentUser) { if (!currentUser) {
@@ -36,4 +36,3 @@ export default defineEventHandler(async (event) => {
} }
}) })