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] 5. Theme: `Color.kt`, `Typography.kt`, `Theme.kt` erstellen und Tailwind-Farben mappen
[x] 6. Fonts: Inter + Montserrat einbinden (res/font oder GoogleFonts)
[ ] 7. Navigation: Compose Navigation-Graph mit Routen für alle Web-Seiten anlegen
[x] 7. Navigation: Compose Navigation-Graph mit Routen für alle Web-Seiten anlegen
[x] 7a. Umgebungen: Android-Varianten fuer lokal, Test-Instanz und Produktion mit eigener API-Basis konfigurieren
[x] 7b. Adaptive Navigation: Tablet mit persistentem Header/Hauptmenue, Smartphone mit bestehender screenbezogener Navigation
[x] 7c. Branding: vorhandenes Web-Logo als optimierte Android-Ressource in der App-Navigation verwenden
@@ -69,14 +69,13 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
- [x] `/training/trainer`
- [x] `/training/anfaenger`
- [x] `/tt-regeln`: Regelübersicht mit DTTB- und PDF-Aufruf
[ ] 10a. Weitere öffentliche bzw. bestehende Web-Routen prüfen und portieren
- [ ] `/anlagen`
- [ ] `/impressum`
- [ ] Legacy-/Doppelrouten klären: `/galerie`, `/geschichte`, `/satzung`, `/ueber-uns`, `/spielplan`, `/verein/tt-regeln`, `/mannschaft/[slug]`, `/mannschaften/herren`, `/mannschaften/damen`, `/mannschaften/jugend`
[ ] 10b. Newsletter-Screens portieren
- [ ] `/newsletter/subscribe`
- [ ] `/newsletter/unsubscribe`
- [ ] `/newsletter/confirm`, `/newsletter/confirmed`, `/newsletter/unsubscribed`
[x] 10a. Weitere öffentliche bzw. bestehende Web-Routen prüfen und portieren
- [x] `/impressum`
- [x] Legacy-/Doppelrouten geklärt: `/galerie`, `/geschichte`, `/satzung`, `/ueber-uns`, `/spielplan`, `/verein/tt-regeln`, `/mannschaft/[slug]`, `/mannschaften/herren`, `/mannschaften/damen`, `/mannschaften/jugend`
[x] 10b. Newsletter-Screens portieren
- [x] `/newsletter/subscribe`
- [x] `/newsletter/unsubscribe`
- [x] `/newsletter/confirm`, `/newsletter/confirmed`, `/newsletter/unsubscribed`
[x] 10c. Auth-Screens portieren
- [x] `/login`: Passwort-Login und Logout in der laufenden Sitzung
- [x] `/registrieren`
@@ -109,10 +108,19 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
- [x] Backend: Logout, Kontodeaktivierung und Passwortänderung widerrufen betroffene Refresh-Sitzungen
- [x] App: Access- und Refresh-Token verschlüsselt speichern, Sitzung beim App-Start durch Refresh wiederherstellen
- [ ] Optional nach MVP: pro Installation ein nicht exportierbares Keystore-Schlüsselpaar erzeugen und Refresh-Requests daran binden
[ ] 13. Passkeys: Integration prüfen (FIDO2 / Passkeys) und Fallback auf Passwort
[x] 13. Passkeys: Integration prüfen (FIDO2 / Passkeys) und Fallback auf Passwort
- [x] Passkey-Login über Android Credential Manager an bestehende WebAuthn-Endpunkte anbinden
- [x] Passkey-Erstellung und -Entfernung im nativen Profil ergänzen
- [x] Backend-Passkey-Endpunkte für Android-Bearer-Token und Android-Refresh-Sitzungen erweitern
- [ ] Für Produktion `/.well-known/assetlinks.json` mit Package `de.harheimertc` und Release-Zertifikat-Fingerprint veröffentlichen
[ ] 14. Image-Upload: Multipart-Upload + Coil für Anzeige + Bildkompression (u. a. Sharp-Äquivalent evtl. serverseitig)
[ ] 15. Rich-Text: Anzeige von HTML (Compose + WebView) und ggf. Editor via WebView-bridge
[ ] 16. Formulare: Validierung (clientseitig) und Fehlerdarstellung
[x] 15. Rich-Text: Anzeige von HTML (Compose + WebView) und ggf. Editor via WebView-bridge
- [x] Gemeinsame native HTML/Rich-Text-Komponente für CMS- und News-Inhalte ergänzt
- [x] Öffentliche CMS-Seiten, Startseiten-News und interne News rendern HTML-Inhalte nativ
- [ ] Rich-Text-Editor für CMS-Bearbeitung als WebView-Bridge prüfen, falls CMS-Editieren nativ ausgebaut wird
[x] 16. Formulare: Validierung (clientseitig) und Fehlerdarstellung
- [x] Gemeinsame validierte Textfelder mit Inline-Fehlern ergänzt
- [x] Kontakt, Login, Passwort-Reset, Registrierung, Newsletter, Profil und Mitgliedschaft zeigen Feldfehler direkt am Eingabefeld
[ ] 17. Offline & Caching: Room für persistente Daten, Response-Caching, Sync-Strategie
[ ] 18. Lokalisierung: `strings.xml` (DE + EN) und i18n-Check
[ ] 19. Accessibility: ContentDescription, Focus, Farben/Kontrast prüfen
@@ -135,7 +143,7 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
6) Nächste Aktionen (sofort)
- Web-Login bei Bedarf auf denselben Refresh-Flow migrieren; bis dahin bleiben Web-Cookie-Sitzungen bewusst kompatibel.
- Passkey-Anmeldung über Android Credential Manager anbinden.
- Release-Zertifikat erzeugen und Digital Asset Links für native Android-Passkeys veröffentlichen.
- Die noch fehlenden öffentlichen Routen aus `10a` und die Newsletter-Screens aus `10b` nativ portieren.
- Saisonwahl für Mannschaftsübersicht/-details wie in der Web-UI ergänzen.
- Weitere Mitgliederbereich/CMS-Screens portieren; dabei rollenabhängige `Intern`-Navigation und Bearer-Unterstützung der jeweils verwendeten Backend-Endpunkte ergänzen.
@@ -162,6 +170,9 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
- 2026-05-27: Native Profilseite des Mitgliederbereichs umgesetzt; `/api/profile` akzeptiert nun Bearer-Tokens, die App kann Profil-/Sichtbarkeitsdaten bearbeiten und Passwortänderungen auslösen.
- 2026-05-27: Login leitet in der Android-App nun direkt zur Mitgliederbereich-Übersicht weiter; `/mitgliederbereich` ist nativ mit Kacheln und Geburtstagsliste umgesetzt, `/api/birthdays` akzeptiert Bearer-Tokens.
- 2026-05-28: 10d und 10e abgeschlossen: Mitgliederliste, interne News, API-Doku sowie alle CMS-Routen sind nativ angebunden; relevante Mitglieder-/News-/CMS-Endpunkte akzeptieren Bearer-Tokens.
- 2026-05-28: 10a und 10b abgeschlossen: Impressum, Newsletter-An-/Abmeldung und Newsletter-Statusseiten sind nativ umgesetzt; Legacy-/Doppelrouten im Android-NavGraph auf native Screens abgebildet. Abgleich `pages/` gegen Android-Routen ergab keine verbleibenden Platzhalter-Webseiten. `/anlagen` wurde anschließend als ungenutzte und inhaltlich falsche Seite in Web und Android entfernt.
- 2026-05-28: Android-Passkeys umgesetzt: Login nutzt Android Credential Manager mit den bestehenden WebAuthn-Optionen, das native Profil kann Passkeys erstellen/entfernen, und die Backend-Passkey-Endpunkte akzeptieren Bearer-Tokens sowie erzeugen beim Android-Passkey-Login rotierende Refresh-Sitzungen. Für Produktion fehlt noch die Domain-App-Verknüpfung per Digital Asset Links mit Release-Zertifikat.
- 2026-05-28: Punkte 15 und 16 umgesetzt: Gemeinsame `RichText`-Komponente rendert HTML-Inhalte nativ in CMS-/News-Screens; Formularvalidierung wurde mit Inline-Feldfehlern für Kontakt, Auth, Newsletter, Profil und Mitgliedschaft ergänzt.
8) Android-Testumgebungen
- Lokal im Emulator: `./gradlew :app:installLocalDebug` verwendet `http://10.0.2.2:3100/` und die App-ID `de.harheimertc.local`.

View File

@@ -94,6 +94,10 @@ dependencies {
implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
implementation("com.squareup.moshi:moshi-kotlin:1.15.1")
// Passkeys / Credential Manager
implementation("androidx.credentials:credentials:1.6.0")
implementation("androidx.credentials:credentials-play-services-auth:1.6.0")
// Coil
implementation("io.coil-kt:coil-compose:2.4.0")

View File

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

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

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

View File

@@ -15,7 +15,6 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import de.harheimertc.ui.components.AppNavigationHeader
import de.harheimertc.ui.components.PendingPage
@Composable
fun NavGraph(
@@ -90,12 +89,27 @@ fun NavGraph(
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Impressum.route) {
de.harheimertc.ui.screens.publicpages.ImpressumScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Mannschaften.route) {
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable("mannschaften/herren") {
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(navController, !persistentNavigation)
}
composable("mannschaften/damen") {
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(navController, !persistentNavigation)
}
composable("mannschaften/jugend") {
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(navController, !persistentNavigation)
}
composable(Destinations.MannschaftDetail.route) { entry ->
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
slug = entry.arguments?.getString("slug").orEmpty(),
@@ -103,6 +117,13 @@ fun NavGraph(
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.MannschaftLegacyDetail.route) { entry ->
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
slug = entry.arguments?.getString("slug").orEmpty(),
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Termine.route) {
de.harheimertc.ui.screens.termine.TermineScreen(
navController = navController,
@@ -121,6 +142,9 @@ fun NavGraph(
showBackNavigation = !persistentNavigation,
)
}
composable("spielsysteme") {
de.harheimertc.ui.screens.publicpages.SpielsystemeScreen(navController, !persistentNavigation)
}
composable(Destinations.Training.route) {
de.harheimertc.ui.screens.training.TrainingScreen(
navController = navController,
@@ -145,14 +169,48 @@ fun NavGraph(
showBackNavigation = !persistentNavigation,
)
}
composable("tt-regeln") {
de.harheimertc.ui.screens.publicpages.RegelnScreen(navController, !persistentNavigation)
}
composable("verein/tt-regeln") {
de.harheimertc.ui.screens.publicpages.RegelnScreen(navController, !persistentNavigation)
}
composable(Destinations.Gallery.route) {
de.harheimertc.ui.screens.gallery.GalleryScreen()
}
composable("galerie") {
de.harheimertc.ui.screens.gallery.GalleryScreen()
}
composable(Destinations.NewsletterSubscribe.route) {
PendingPage(navController, "Newsletter abonnieren", "/newsletter/subscribe", !persistentNavigation)
de.harheimertc.ui.screens.newsletter.NewsletterSubscribeScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.NewsletterUnsubscribe.route) {
PendingPage(navController, "Newsletter abmelden", "/newsletter/unsubscribe", !persistentNavigation)
de.harheimertc.ui.screens.newsletter.NewsletterUnsubscribeScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.NewsletterConfirm.route) {
de.harheimertc.ui.screens.newsletter.NewsletterConfirmScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
token = null,
)
}
composable(Destinations.NewsletterConfirmed.route) {
de.harheimertc.ui.screens.newsletter.NewsletterConfirmedScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.NewsletterUnsubscribed.route) {
de.harheimertc.ui.screens.newsletter.NewsletterUnsubscribedScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Contact.route) {
de.harheimertc.ui.screens.contact.ContactScreen()
@@ -181,6 +239,15 @@ fun NavGraph(
showBackNavigation = !persistentNavigation,
)
}
composable("ueber-uns") {
de.harheimertc.ui.screens.publicpages.AboutScreen(navController, !persistentNavigation)
}
composable("geschichte") {
de.harheimertc.ui.screens.publicpages.GeschichteScreen(navController, !persistentNavigation)
}
composable("satzung") {
de.harheimertc.ui.screens.publicpages.SatzungScreen(navController, !persistentNavigation)
}
composable(Destinations.MemberArea.route) {
de.harheimertc.ui.screens.memberarea.MemberAreaScreen(
navController = navController,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -27,6 +28,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -35,6 +37,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.components.ValidatedTextField
import de.harheimertc.ui.theme.Primary600
@Composable
@@ -45,6 +48,7 @@ fun ProfileScreen(
) {
val state by viewModel.state.collectAsState()
val form = state.form
val context = LocalContext.current
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
@@ -70,35 +74,34 @@ fun ProfileScreen(
} else {
item {
ProfileCard("Persönliche Daten") {
OutlinedTextField(
ValidatedTextField(
value = form.name,
onValueChange = { viewModel.update(form.copy(name = it)) },
label = { Text("Name") },
label = "Name",
error = state.fieldErrors["name"],
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
ValidatedTextField(
value = form.email,
onValueChange = { viewModel.update(form.copy(email = it)) },
label = { Text("E-Mail") },
label = "E-Mail",
error = state.fieldErrors["email"],
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
ValidatedTextField(
value = form.phone,
onValueChange = { viewModel.update(form.copy(phone = it)) },
label = { Text("Telefon") },
label = "Telefon",
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
ValidatedTextField(
value = form.birthDate,
onValueChange = { viewModel.update(form.copy(birthDate = it)) },
label = { Text("Geburtsdatum (JJJJ-MM-TT)") },
label = "Geburtsdatum (JJJJ-MM-TT)",
error = state.fieldErrors["birthDate"],
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
}
@@ -114,34 +117,71 @@ fun ProfileScreen(
item {
ProfileCard("Passwort ändern") {
OutlinedTextField(
ValidatedTextField(
value = form.currentPassword,
onValueChange = { viewModel.update(form.copy(currentPassword = it)) },
label = { Text("Aktuelles Passwort") },
label = "Aktuelles Passwort",
error = state.fieldErrors["currentPassword"],
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
ValidatedTextField(
value = form.newPassword,
onValueChange = { viewModel.update(form.copy(newPassword = it)) },
label = { Text("Neues Passwort") },
label = "Neues Passwort",
error = state.fieldErrors["newPassword"],
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
ValidatedTextField(
value = form.confirmPassword,
onValueChange = { viewModel.update(form.copy(confirmPassword = it)) },
label = { Text("Neues Passwort wiederholen") },
label = "Neues Passwort wiederholen",
error = state.fieldErrors["confirmPassword"],
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Text("Leer lassen, wenn das Passwort unverändert bleiben soll.", color = Accent500)
}
}
item {
ProfileCard("Passkeys") {
Text(
"Passkeys ermöglichen eine Anmeldung ohne Passwort über den Android Credential Manager.",
color = Accent500,
)
Button(
onClick = { viewModel.addPasskey(context) },
enabled = !state.passkeyLoading,
modifier = Modifier.fillMaxWidth(),
) {
if (state.passkeyLoading) CircularProgressIndicator(color = Color.White, strokeWidth = 2.dp, modifier = Modifier.size(18.dp).padding(end = 8.dp))
Text(if (state.passkeyLoading) "Passkey wird erstellt..." else "Passkey hinzufügen")
}
if (state.passkeys.isEmpty()) {
Text("Noch kein Passkey hinterlegt.", color = Accent500)
} else {
state.passkeys.forEach { passkey ->
Surface(color = Color(0xFFF4F4F5), shape = RoundedCornerShape(9.dp)) {
Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(passkey.name.ifBlank { "Passkey" }, color = Accent900, fontWeight = FontWeight.SemiBold)
passkey.createdAt?.let { Text("Erstellt: $it", color = Accent500) }
passkey.lastUsedAt?.let { Text("Zuletzt genutzt: $it", color = Accent500) }
OutlinedButton(
onClick = { viewModel.removePasskey(passkey.credentialId) },
enabled = !state.passkeyLoading,
modifier = Modifier.fillMaxWidth(),
) {
Text("Entfernen")
}
}
}
}
}
}
}
item {
Button(
onClick = viewModel::save,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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