feat: replace success modal with non-blocking toast notification
feat: add global event listener for mannschaften updates in Navigation component feat: notify app of mannschaften changes after CSV save and handle visibility changes refactor: remove unused anlagen page fix: update CmsMannschaften reference in sportbetrieb page for reactivity fix: enhance authentication token retrieval in passkey API endpoints feat: implement refresh session and access token generation for Android clients in passkey login fix: unify token retrieval method across passkey API endpoints feat: add MediaTypes utility for JSON content type in Android app feat: create PasskeyRepository for handling passkey authentication and registration in Android app feat: add validated text field and rich text components for Android UI feat: implement newsletter subscription and unsubscription screens in Android app feat: create public pages including Impressum with dynamic content loading
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.harheimertc.data
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
|
||||
object MediaTypes {
|
||||
val json = "application/json; charset=utf-8".toMediaType()
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package de.harheimertc.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
|
||||
@Composable
|
||||
fun ValidatedTextField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier.fillMaxWidth(),
|
||||
error: String? = null,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||
visualTransformation: VisualTransformation = VisualTransformation.None,
|
||||
singleLine: Boolean = true,
|
||||
minLines: Int = 1,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = { Text(label) },
|
||||
isError = error != null,
|
||||
supportingText = error?.let { { Text(it) } },
|
||||
keyboardOptions = keyboardOptions,
|
||||
visualTransformation = visualTransformation,
|
||||
singleLine = singleLine,
|
||||
minLines = minLines,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FormMessages(error: String?, message: String?) {
|
||||
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
message?.let { Text(it, color = Color(0xFF166534)) }
|
||||
}
|
||||
|
||||
internal fun isValidEmail(value: String): Boolean {
|
||||
val trimmed = value.trim()
|
||||
return trimmed.length in 5..254 &&
|
||||
trimmed.count { it == '@' } == 1 &&
|
||||
trimmed.substringBefore('@').isNotBlank() &&
|
||||
trimmed.substringAfter('@').contains('.') &&
|
||||
!trimmed.any(Char::isWhitespace)
|
||||
}
|
||||
|
||||
internal fun isValidIsoDate(value: String): Boolean =
|
||||
value.trim().matches(Regex("\\d{4}-\\d{2}-\\d{2}"))
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package de.harheimertc.ui.components
|
||||
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.widget.TextView
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.text.HtmlCompat
|
||||
|
||||
@Composable
|
||||
fun RichText(
|
||||
html: String,
|
||||
modifier: Modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
AndroidView(
|
||||
modifier = modifier,
|
||||
factory = { context ->
|
||||
TextView(context).apply {
|
||||
textSize = 17f
|
||||
setTextColor(android.graphics.Color.rgb(63, 63, 70))
|
||||
movementMethod = LinkMovementMethod.getInstance()
|
||||
setLineSpacing(0f, 1.2f)
|
||||
}
|
||||
},
|
||||
update = { textView ->
|
||||
textView.text = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,10 +8,14 @@ sealed class Destinations(val route: String) {
|
||||
object Satzung : Destinations("verein/satzung")
|
||||
object Vereinsmeisterschaften : Destinations("verein/vereinsmeisterschaften")
|
||||
object Links : Destinations("verein/links")
|
||||
object Impressum : Destinations("impressum")
|
||||
object Mannschaften : Destinations("mannschaften")
|
||||
object MannschaftDetail : Destinations("mannschaften/{slug}") {
|
||||
fun create(slug: String): String = "mannschaften/$slug"
|
||||
}
|
||||
object MannschaftLegacyDetail : Destinations("mannschaft/{slug}") {
|
||||
fun create(slug: String): String = "mannschaft/$slug"
|
||||
}
|
||||
object Termine : Destinations("termine")
|
||||
object Spielplan : Destinations("spielplan")
|
||||
object Spielsysteme : Destinations("mannschaften/spielsysteme")
|
||||
@@ -22,6 +26,9 @@ sealed class Destinations(val route: String) {
|
||||
object Gallery : Destinations("gallery")
|
||||
object NewsletterSubscribe : Destinations("newsletter/subscribe")
|
||||
object NewsletterUnsubscribe : Destinations("newsletter/unsubscribe")
|
||||
object NewsletterConfirm : Destinations("newsletter/confirm")
|
||||
object NewsletterConfirmed : Destinations("newsletter/confirmed")
|
||||
object NewsletterUnsubscribed : Destinations("newsletter/unsubscribed")
|
||||
object Contact : Destinations("contact")
|
||||
object Membership : Destinations("membership")
|
||||
object Login : Destinations("login")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -13,6 +12,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import de.harheimertc.ui.components.ValidatedTextField
|
||||
|
||||
@Composable
|
||||
fun ContactScreen(viewModel: ContactViewModel = hiltViewModel()) {
|
||||
@@ -21,12 +21,20 @@ fun ContactScreen(viewModel: ContactViewModel = hiltViewModel()) {
|
||||
val message by viewModel.message.collectAsState()
|
||||
val sending by viewModel.sending.collectAsState()
|
||||
val result by viewModel.result.collectAsState()
|
||||
val fieldErrors by viewModel.fieldErrors.collectAsState()
|
||||
|
||||
Surface(modifier = Modifier.padding(16.dp)) {
|
||||
Column {
|
||||
OutlinedTextField(value = name, onValueChange = { viewModel.onName(it) }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = email, onValueChange = { viewModel.onEmail(it) }, label = { Text("E-Mail") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = message, onValueChange = { viewModel.onMessage(it) }, label = { Text("Nachricht") }, modifier = Modifier.fillMaxWidth())
|
||||
ValidatedTextField(name, viewModel::onName, "Name", error = fieldErrors["name"])
|
||||
ValidatedTextField(email, viewModel::onEmail, "E-Mail", error = fieldErrors["email"])
|
||||
ValidatedTextField(
|
||||
value = message,
|
||||
onValueChange = viewModel::onMessage,
|
||||
label = "Nachricht",
|
||||
error = fieldErrors["message"],
|
||||
singleLine = false,
|
||||
minLines = 4,
|
||||
)
|
||||
Button(onClick = { viewModel.send() }, enabled = !sending, modifier = Modifier.padding(top = 8.dp)) {
|
||||
Text(if (sending) "Sende…" else "Absenden")
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.ContactRequest
|
||||
import de.harheimertc.repositories.ContactRepository
|
||||
import de.harheimertc.ui.components.isValidEmail
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -27,16 +28,38 @@ class ContactViewModel @Inject constructor(private val repo: ContactRepository)
|
||||
private val _result = MutableStateFlow<String?>(null)
|
||||
val result: StateFlow<String?> = _result
|
||||
|
||||
fun onName(v: String) { _name.value = v }
|
||||
fun onEmail(v: String) { _email.value = v }
|
||||
fun onMessage(v: String) { _message.value = v }
|
||||
private val _fieldErrors = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||
val fieldErrors: StateFlow<Map<String, String>> = _fieldErrors
|
||||
|
||||
fun onName(v: String) {
|
||||
_name.value = v
|
||||
clearFieldError("name")
|
||||
}
|
||||
|
||||
fun onEmail(v: String) {
|
||||
_email.value = v
|
||||
clearFieldError("email")
|
||||
}
|
||||
|
||||
fun onMessage(v: String) {
|
||||
_message.value = v
|
||||
clearFieldError("message")
|
||||
}
|
||||
|
||||
fun send() {
|
||||
val n = _name.value.trim()
|
||||
val e = _email.value.trim()
|
||||
val m = _message.value.trim()
|
||||
if (n.isEmpty() || e.isEmpty() || m.isEmpty()) {
|
||||
_result.value = "Bitte alle Felder ausfüllen"
|
||||
val errors = buildMap {
|
||||
if (n.isEmpty()) put("name", "Bitte geben Sie Ihren Namen ein.")
|
||||
if (e.isEmpty()) put("email", "Bitte geben Sie Ihre E-Mail-Adresse ein.")
|
||||
else if (!isValidEmail(e)) put("email", "Bitte geben Sie eine gültige E-Mail-Adresse ein.")
|
||||
if (m.isEmpty()) put("message", "Bitte geben Sie eine Nachricht ein.")
|
||||
else if (m.length < 10) put("message", "Die Nachricht sollte mindestens 10 Zeichen haben.")
|
||||
}
|
||||
if (errors.isNotEmpty()) {
|
||||
_fieldErrors.value = errors
|
||||
_result.value = "Bitte prüfen Sie die markierten Felder."
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
@@ -45,6 +68,7 @@ class ContactViewModel @Inject constructor(private val repo: ContactRepository)
|
||||
val resp = repo.sendContact(ContactRequest(n, e, m))
|
||||
if (resp.isSuccessful) {
|
||||
_result.value = "Nachricht gesendet"
|
||||
_fieldErrors.value = emptyMap()
|
||||
_name.value = ""
|
||||
_email.value = ""
|
||||
_message.value = ""
|
||||
@@ -58,4 +82,11 @@ class ContactViewModel @Inject constructor(private val repo: ContactRepository)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearFieldError(field: String) {
|
||||
if (_fieldErrors.value.containsKey(field)) {
|
||||
_fieldErrors.value = _fieldErrors.value - field
|
||||
}
|
||||
_result.value = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ import de.harheimertc.data.NewsDto
|
||||
import de.harheimertc.data.SpielDto
|
||||
import de.harheimertc.data.TerminDto
|
||||
import de.harheimertc.ui.components.AppNavigationHeader
|
||||
import de.harheimertc.ui.components.RichText
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import de.harheimertc.ui.theme.Accent100
|
||||
import de.harheimertc.ui.theme.Accent200
|
||||
@@ -83,7 +84,7 @@ fun HomeScreen(
|
||||
Text(item.title, style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
},
|
||||
text = { Text(item.content, style = MaterialTheme.typography.bodyMedium, color = Accent700) },
|
||||
text = { RichText(item.content) },
|
||||
confirmButton = { TextButton(onClick = { selectedNews = null }) { Text("Schließen") } },
|
||||
)
|
||||
}
|
||||
@@ -221,13 +222,7 @@ private fun HomeNewsSection(news: List<NewsDto>, onOpen: (NewsDto) -> Unit) {
|
||||
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
Text(formatNewsDate(item.created), style = MaterialTheme.typography.labelSmall, color = Accent500)
|
||||
Text(item.title, style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
Text(
|
||||
item.content,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Accent700,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
RichText(item.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?")
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package de.harheimertc.ui.screens.login
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.repositories.LoginRepository
|
||||
import de.harheimertc.repositories.PasskeyRepository
|
||||
import de.harheimertc.ui.components.isValidEmail
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -12,6 +15,7 @@ import javax.inject.Inject
|
||||
data class LoginUiState(
|
||||
val email: String = "",
|
||||
val password: String = "",
|
||||
val fieldErrors: Map<String, String> = emptyMap(),
|
||||
val loading: Boolean = false,
|
||||
val restoring: Boolean = true,
|
||||
val loggedIn: Boolean = false,
|
||||
@@ -22,7 +26,10 @@ data class LoginUiState(
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class LoginViewModel @Inject constructor(private val repository: LoginRepository) : ViewModel() {
|
||||
class LoginViewModel @Inject constructor(
|
||||
private val repository: LoginRepository,
|
||||
private val passkeyRepository: PasskeyRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(LoginUiState())
|
||||
val state: StateFlow<LoginUiState> = _state
|
||||
|
||||
@@ -42,17 +49,21 @@ class LoginViewModel @Inject constructor(private val repository: LoginRepository
|
||||
}
|
||||
|
||||
fun setEmail(value: String) {
|
||||
_state.value = _state.value.copy(email = value, error = null)
|
||||
_state.value = _state.value.copy(email = value, fieldErrors = _state.value.fieldErrors - "email", error = null)
|
||||
}
|
||||
|
||||
fun setPassword(value: String) {
|
||||
_state.value = _state.value.copy(password = value, error = null)
|
||||
_state.value = _state.value.copy(password = value, fieldErrors = _state.value.fieldErrors - "password", error = null)
|
||||
}
|
||||
|
||||
fun login() {
|
||||
val current = _state.value
|
||||
if (current.email.isBlank() || current.password.isBlank()) {
|
||||
_state.value = current.copy(error = "Bitte E-Mail-Adresse und Passwort eingeben.")
|
||||
val fieldErrors = buildMap {
|
||||
if (!isValidEmail(current.email)) put("email", "Bitte geben Sie eine gültige E-Mail-Adresse ein.")
|
||||
if (current.password.isBlank()) put("password", "Bitte geben Sie Ihr Passwort ein.")
|
||||
}
|
||||
if (fieldErrors.isNotEmpty()) {
|
||||
_state.value = current.copy(fieldErrors = fieldErrors, error = "Bitte prüfen Sie die markierten Felder.")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
@@ -75,6 +86,32 @@ class LoginViewModel @Inject constructor(private val repository: LoginRepository
|
||||
}
|
||||
}
|
||||
|
||||
fun passkeyLogin(context: Context) {
|
||||
val current = _state.value
|
||||
viewModelScope.launch {
|
||||
_state.value = current.copy(loading = true, error = null, message = null)
|
||||
passkeyRepository.login(context, current.email)
|
||||
.onSuccess { response ->
|
||||
_state.value = current.copy(
|
||||
password = "",
|
||||
loading = false,
|
||||
restoring = false,
|
||||
loggedIn = true,
|
||||
userName = response.user?.name ?: response.user?.email,
|
||||
roles = response.user?.roles.orEmpty(),
|
||||
message = "Passkey-Anmeldung erfolgreich.",
|
||||
)
|
||||
}
|
||||
.onFailure {
|
||||
_state.value = current.copy(
|
||||
loading = false,
|
||||
restoring = false,
|
||||
error = it.message ?: "Passkey-Anmeldung fehlgeschlagen.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch {
|
||||
repository.logout()
|
||||
|
||||
@@ -33,6 +33,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import de.harheimertc.ui.components.ValidatedTextField
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary100
|
||||
@@ -52,13 +53,13 @@ fun PasswordResetScreen(
|
||||
onBack = { navController.navigate(Destinations.Login.route) },
|
||||
showBackNavigation = showBackNavigation,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
state.email,
|
||||
viewModel::setEmail,
|
||||
label = { Text("E-Mail-Adresse") },
|
||||
ValidatedTextField(
|
||||
value = state.email,
|
||||
onValueChange = viewModel::setEmail,
|
||||
label = "E-Mail-Adresse",
|
||||
error = state.fieldErrors["email"],
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
MessageLines(state.error, state.message)
|
||||
Button(onClick = viewModel::submit, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) {
|
||||
@@ -85,40 +86,39 @@ fun RegisterScreen(
|
||||
onBack = { navController.navigate(Destinations.Login.route) },
|
||||
showBackNavigation = showBackNavigation,
|
||||
) {
|
||||
OutlinedTextField(form.name, { viewModel.update(form.copy(name = it)) }, label = { Text("Name *") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(
|
||||
form.email,
|
||||
{ viewModel.update(form.copy(email = it)) },
|
||||
label = { Text("E-Mail-Adresse *") },
|
||||
ValidatedTextField(form.name, { viewModel.update(form.copy(name = it)) }, "Name *", error = state.fieldErrors["name"])
|
||||
ValidatedTextField(
|
||||
value = form.email,
|
||||
onValueChange = { viewModel.update(form.copy(email = it)) },
|
||||
label = "E-Mail-Adresse *",
|
||||
error = state.fieldErrors["email"],
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
form.phone,
|
||||
{ viewModel.update(form.copy(phone = it)) },
|
||||
label = { Text("Telefon") },
|
||||
ValidatedTextField(
|
||||
value = form.phone,
|
||||
onValueChange = { viewModel.update(form.copy(phone = it)) },
|
||||
label = "Telefon",
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
form.birthDate,
|
||||
{ viewModel.update(form.copy(birthDate = it)) },
|
||||
label = { Text("Geburtsdatum * (JJJJ-MM-TT)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
ValidatedTextField(
|
||||
value = form.birthDate,
|
||||
onValueChange = { viewModel.update(form.copy(birthDate = it)) },
|
||||
label = "Geburtsdatum * (JJJJ-MM-TT)",
|
||||
error = state.fieldErrors["birthDate"],
|
||||
)
|
||||
OutlinedTextField(
|
||||
form.password,
|
||||
{ viewModel.update(form.copy(password = it)) },
|
||||
label = { Text("Passwort *") },
|
||||
ValidatedTextField(
|
||||
value = form.password,
|
||||
onValueChange = { viewModel.update(form.copy(password = it)) },
|
||||
label = "Passwort *",
|
||||
error = state.fieldErrors["password"],
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
form.passwordRepeat,
|
||||
{ viewModel.update(form.copy(passwordRepeat = it)) },
|
||||
label = { Text("Passwort wiederholen *") },
|
||||
ValidatedTextField(
|
||||
value = form.passwordRepeat,
|
||||
onValueChange = { viewModel.update(form.copy(passwordRepeat = it)) },
|
||||
label = "Passwort wiederholen *",
|
||||
error = state.fieldErrors["passwordRepeat"],
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(form.showBirthday, { viewModel.update(form.copy(showBirthday = it)) })
|
||||
|
||||
@@ -6,6 +6,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.RegistrationRequest
|
||||
import de.harheimertc.data.RegistrationVisibility
|
||||
import de.harheimertc.repositories.LoginRepository
|
||||
import de.harheimertc.ui.components.isValidEmail
|
||||
import de.harheimertc.ui.components.isValidIsoDate
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -13,6 +15,7 @@ import javax.inject.Inject
|
||||
|
||||
data class PasswordResetUiState(
|
||||
val email: String = "",
|
||||
val fieldErrors: Map<String, String> = emptyMap(),
|
||||
val loading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val message: String? = null,
|
||||
@@ -24,13 +27,16 @@ class PasswordResetViewModel @Inject constructor(private val repository: LoginRe
|
||||
val state: StateFlow<PasswordResetUiState> = _state
|
||||
|
||||
fun setEmail(value: String) {
|
||||
_state.value = _state.value.copy(email = value, error = null)
|
||||
_state.value = _state.value.copy(email = value, fieldErrors = _state.value.fieldErrors - "email", error = null)
|
||||
}
|
||||
|
||||
fun submit() {
|
||||
val email = _state.value.email.trim()
|
||||
if (!email.contains("@")) {
|
||||
_state.value = _state.value.copy(error = "Bitte eine gültige E-Mail-Adresse eingeben.")
|
||||
if (!isValidEmail(email)) {
|
||||
_state.value = _state.value.copy(
|
||||
fieldErrors = mapOf("email" to "Bitte eine gültige E-Mail-Adresse eingeben."),
|
||||
error = "Bitte prüfen Sie die markierten Felder.",
|
||||
)
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
@@ -58,6 +64,7 @@ data class RegisterFormState(
|
||||
|
||||
data class RegisterUiState(
|
||||
val form: RegisterFormState = RegisterFormState(),
|
||||
val fieldErrors: Map<String, String> = emptyMap(),
|
||||
val loading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val message: String? = null,
|
||||
@@ -69,21 +76,14 @@ class RegisterViewModel @Inject constructor(private val repository: LoginReposit
|
||||
val state: StateFlow<RegisterUiState> = _state
|
||||
|
||||
fun update(form: RegisterFormState) {
|
||||
_state.value = _state.value.copy(form = form, error = null)
|
||||
_state.value = _state.value.copy(form = form, fieldErrors = validateFields(form, onlyTouched = true), error = null)
|
||||
}
|
||||
|
||||
fun submit() {
|
||||
val form = _state.value.form
|
||||
val error = when {
|
||||
form.name.isBlank() || form.email.isBlank() || form.birthDate.isBlank() || form.password.isBlank() ->
|
||||
"Bitte alle Pflichtfelder ausfüllen."
|
||||
!form.email.contains("@") -> "Bitte eine gültige E-Mail-Adresse eingeben."
|
||||
form.password.length < 8 -> "Das Passwort muss mindestens 8 Zeichen lang sein."
|
||||
form.password != form.passwordRepeat -> "Die Passwörter stimmen nicht überein."
|
||||
else -> null
|
||||
}
|
||||
if (error != null) {
|
||||
_state.value = _state.value.copy(error = error)
|
||||
val fieldErrors = validateFields(form)
|
||||
if (fieldErrors.isNotEmpty()) {
|
||||
_state.value = _state.value.copy(fieldErrors = fieldErrors, error = "Bitte prüfen Sie die markierten Felder.")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
@@ -104,4 +104,14 @@ class RegisterViewModel @Inject constructor(private val repository: LoginReposit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateFields(form: RegisterFormState, onlyTouched: Boolean = false): Map<String, String> = buildMap {
|
||||
fun shouldValidate(value: String) = !onlyTouched || value.isNotBlank()
|
||||
if (!onlyTouched && form.name.isBlank()) put("name", "Bitte geben Sie Ihren Namen ein.")
|
||||
if (shouldValidate(form.email) && !isValidEmail(form.email)) put("email", "Bitte geben Sie eine gültige E-Mail-Adresse ein.")
|
||||
if (shouldValidate(form.birthDate) && !isValidIsoDate(form.birthDate)) put("birthDate", "Bitte verwenden Sie das Format JJJJ-MM-TT.")
|
||||
if (shouldValidate(form.password) && form.password.length < 8) put("password", "Das Passwort muss mindestens 8 Zeichen lang sein.")
|
||||
if (form.passwordRepeat.isNotBlank() && form.password != form.passwordRepeat) put("passwordRepeat", "Die Passwörter stimmen nicht überein.")
|
||||
if (!onlyTouched && form.passwordRepeat.isBlank()) put("passwordRepeat", "Bitte wiederholen Sie das Passwort.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.MembershipRequest
|
||||
import de.harheimertc.repositories.MembershipRepository
|
||||
import de.harheimertc.ui.components.isValidEmail
|
||||
import de.harheimertc.ui.components.isValidIsoDate
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -31,6 +33,7 @@ data class MembershipFormState(
|
||||
|
||||
data class MembershipUiState(
|
||||
val form: MembershipFormState = MembershipFormState(),
|
||||
val fieldErrors: Map<String, String> = emptyMap(),
|
||||
val sending: Boolean = false,
|
||||
val message: String? = null,
|
||||
val error: String? = null,
|
||||
@@ -43,13 +46,14 @@ class MembershipViewModel @Inject constructor(private val repository: Membership
|
||||
val state: StateFlow<MembershipUiState> = _state
|
||||
|
||||
fun update(form: MembershipFormState) {
|
||||
_state.value = _state.value.copy(form = form, error = null)
|
||||
_state.value = _state.value.copy(form = form, fieldErrors = _state.value.fieldErrors - changedKeys(_state.value.form, form), error = null)
|
||||
}
|
||||
|
||||
fun submit() {
|
||||
val form = _state.value.form
|
||||
validate(form)?.let {
|
||||
_state.value = _state.value.copy(error = it)
|
||||
val fieldErrors = validateFields(form)
|
||||
if (fieldErrors.isNotEmpty()) {
|
||||
_state.value = _state.value.copy(fieldErrors = fieldErrors, error = "Bitte prüfen Sie die markierten Felder.")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
@@ -74,7 +78,7 @@ class MembershipViewModel @Inject constructor(private val repository: Membership
|
||||
)
|
||||
repository.submit(request)
|
||||
.onSuccess { document ->
|
||||
_state.value = _state.value.copy(sending = false, message = document.message, pdfUri = document.uri)
|
||||
_state.value = _state.value.copy(sending = false, fieldErrors = emptyMap(), message = document.message, pdfUri = document.uri)
|
||||
}
|
||||
.onFailure {
|
||||
_state.value = _state.value.copy(sending = false, error = "Beitrittsformular konnte nicht erstellt werden.")
|
||||
@@ -82,12 +86,33 @@ class MembershipViewModel @Inject constructor(private val repository: Membership
|
||||
}
|
||||
}
|
||||
|
||||
private fun validate(form: MembershipFormState): String? = when {
|
||||
listOf(form.vorname, form.nachname, form.strasse, form.plz, form.ort, form.geburtsdatum, form.email, form.kontoinhaber, form.iban)
|
||||
.any { it.isBlank() } -> "Bitte alle Pflichtfelder ausfüllen."
|
||||
!form.plz.matches(Regex("\\d{5}")) -> "Bitte eine gültige fünfstellige PLZ eingeben."
|
||||
!form.email.contains("@") -> "Bitte eine gültige E-Mail-Adresse eingeben."
|
||||
!form.lastschrift || !form.datenschutz || !form.satzung -> "Bitte die erforderlichen Einwilligungen bestätigen."
|
||||
else -> null
|
||||
private fun validateFields(form: MembershipFormState): Map<String, String> = buildMap {
|
||||
if (form.vorname.isBlank()) put("vorname", "Bitte geben Sie den Vornamen ein.")
|
||||
if (form.nachname.isBlank()) put("nachname", "Bitte geben Sie den Nachnamen ein.")
|
||||
if (form.strasse.isBlank()) put("strasse", "Bitte geben Sie Straße und Hausnummer ein.")
|
||||
if (!form.plz.matches(Regex("\\d{5}"))) put("plz", "Bitte eine gültige fünfstellige PLZ eingeben.")
|
||||
if (form.ort.isBlank()) put("ort", "Bitte geben Sie den Wohnort ein.")
|
||||
if (!isValidIsoDate(form.geburtsdatum)) put("geburtsdatum", "Bitte verwenden Sie das Format JJJJ-MM-TT.")
|
||||
if (!isValidEmail(form.email)) put("email", "Bitte eine gültige E-Mail-Adresse eingeben.")
|
||||
if (form.kontoinhaber.isBlank()) put("kontoinhaber", "Bitte geben Sie den Kontoinhaber ein.")
|
||||
if (form.iban.filterNot(Char::isWhitespace).length < 15) put("iban", "Bitte geben Sie eine gültige IBAN ein.")
|
||||
if (!form.lastschrift) put("lastschrift", "Das SEPA-Lastschriftmandat ist erforderlich.")
|
||||
if (!form.datenschutz) put("datenschutz", "Die Datenschutzeinwilligung ist erforderlich.")
|
||||
if (!form.satzung) put("satzung", "Die Anerkennung der Satzung ist erforderlich.")
|
||||
}
|
||||
|
||||
private fun changedKeys(previous: MembershipFormState, next: MembershipFormState): Set<String> = buildSet {
|
||||
if (previous.vorname != next.vorname) add("vorname")
|
||||
if (previous.nachname != next.nachname) add("nachname")
|
||||
if (previous.strasse != next.strasse) add("strasse")
|
||||
if (previous.plz != next.plz) add("plz")
|
||||
if (previous.ort != next.ort) add("ort")
|
||||
if (previous.geburtsdatum != next.geburtsdatum) add("geburtsdatum")
|
||||
if (previous.email != next.email) add("email")
|
||||
if (previous.kontoinhaber != next.kontoinhaber) add("kontoinhaber")
|
||||
if (previous.iban != next.iban) add("iban")
|
||||
if (previous.lastschrift != next.lastschrift) add("lastschrift")
|
||||
if (previous.datenschutz != next.datenschutz) add("datenschutz")
|
||||
if (previous.satzung != next.satzung) add("satzung")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
package de.harheimertc.ui.screens.newsletter
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.data.NewsletterGroupDto
|
||||
import de.harheimertc.ui.components.ValidatedTextField
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import de.harheimertc.ui.theme.Accent100
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent700
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary100
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
import de.harheimertc.ui.theme.Primary900
|
||||
|
||||
@Composable
|
||||
fun NewsletterSubscribeScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: NewsletterViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
NewsletterFormScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = showBackNavigation,
|
||||
title = "Newsletter abonnieren",
|
||||
buttonLabel = "Newsletter abonnieren",
|
||||
state = state,
|
||||
onUpdate = viewModel::update,
|
||||
onSubmit = viewModel::subscribe,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NewsletterUnsubscribeScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: NewsletterViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
NewsletterFormScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = showBackNavigation,
|
||||
title = "Newsletter abmelden",
|
||||
buttonLabel = "Newsletter abmelden",
|
||||
state = state,
|
||||
onUpdate = viewModel::update,
|
||||
onSubmit = viewModel::unsubscribe,
|
||||
showName = false,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NewsletterConfirmScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
token: String?,
|
||||
viewModel: NewsletterConfirmViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
LaunchedEffect(token) {
|
||||
viewModel.confirm(token.orEmpty())
|
||||
}
|
||||
NewsletterStatusPage(navController, showBackNavigation, "Newsletter bestätigen") {
|
||||
when {
|
||||
state.loading -> {
|
||||
CircularProgressIndicator(color = Primary600)
|
||||
Text("Newsletter-Anmeldung wird bestätigt...", color = Accent700)
|
||||
}
|
||||
state.error != null -> {
|
||||
Text("Fehler", style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
Text(state.error.orEmpty(), color = MaterialTheme.colorScheme.error, textAlign = TextAlign.Center)
|
||||
Button(onClick = { navController.navigate(Destinations.NewsletterSubscribe.route) }) { Text("Zur Anmeldung") }
|
||||
}
|
||||
else -> {
|
||||
Text("Anmeldung bestätigt", style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
Text(state.message ?: "Vielen Dank. Ihre Newsletter-Anmeldung wurde bestätigt.", color = Accent700, textAlign = TextAlign.Center)
|
||||
Button(onClick = { navController.navigate(Destinations.Home.route) }) { Text("Zur Startseite") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NewsletterConfirmedScreen(navController: NavController, showBackNavigation: Boolean) {
|
||||
NewsletterStatusPage(navController, showBackNavigation, "Newsletter bestätigt") {
|
||||
Text("Anmeldung bestätigt", style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
Text("Vielen Dank. Sie erhalten ab sofort unseren Newsletter.", color = Accent700, textAlign = TextAlign.Center)
|
||||
Button(onClick = { navController.navigate(Destinations.Home.route) }) { Text("Zur Startseite") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NewsletterUnsubscribedScreen(navController: NavController, showBackNavigation: Boolean) {
|
||||
NewsletterStatusPage(navController, showBackNavigation, "Newsletter abgemeldet") {
|
||||
Text("Erfolgreich abgemeldet", style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
Text("Sie erhalten keine weiteren Newsletter dieser Auswahl.", color = Accent700, textAlign = TextAlign.Center)
|
||||
Button(onClick = { navController.navigate(Destinations.Home.route) }) { Text("Zur Startseite") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NewsletterFormScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
title: String,
|
||||
buttonLabel: String,
|
||||
state: NewsletterUiState,
|
||||
onUpdate: (NewsletterFormState) -> Unit,
|
||||
onSubmit: () -> Unit,
|
||||
showName: Boolean = true,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
item {
|
||||
if (showBackNavigation) {
|
||||
TextButton(onClick = { navController.popBackStack() }) {
|
||||
Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
Text(title, style = MaterialTheme.typography.displayLarge, color = Accent900)
|
||||
Text("Wählen Sie einen Newsletter und geben Sie Ihre E-Mail-Adresse ein.", color = Accent500, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
if (state.loading) {
|
||||
item { CircularProgressIndicator(color = Primary600) }
|
||||
} else {
|
||||
item {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Text("Newsletter auswählen", style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
state.groups.forEach { group ->
|
||||
NewsletterGroupOption(group, selected = group.id == state.form.selectedGroupId) {
|
||||
onUpdate(state.form.copy(selectedGroupId = group.id))
|
||||
}
|
||||
}
|
||||
state.fieldErrors["selectedGroupId"]?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
ValidatedTextField(
|
||||
value = state.form.email,
|
||||
onValueChange = { onUpdate(state.form.copy(email = it)) },
|
||||
label = "E-Mail-Adresse",
|
||||
error = state.fieldErrors["email"],
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||
singleLine = true,
|
||||
)
|
||||
if (showName) {
|
||||
ValidatedTextField(
|
||||
value = state.form.name,
|
||||
onValueChange = { onUpdate(state.form.copy(name = it)) },
|
||||
label = "Name (optional)",
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
state.message?.let { Text(it, color = Color(0xFF166534)) }
|
||||
Button(onClick = onSubmit, enabled = !state.submitting && state.groups.isNotEmpty(), modifier = Modifier.fillMaxWidth()) {
|
||||
Text(if (state.submitting) "Wird verarbeitet..." else buttonLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NewsletterGroupOption(group: NewsletterGroupDto, selected: Boolean, onClick: () -> Unit) {
|
||||
Surface(
|
||||
color = if (selected) Primary100 else Accent100,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(if (selected) "✓" else "○", color = Primary600)
|
||||
Text(group.name, color = Accent900, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
if (group.description.isNotBlank()) Text(group.description, color = Accent700)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NewsletterStatusPage(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
title: String,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
item {
|
||||
if (showBackNavigation) {
|
||||
TextButton(onClick = { navController.popBackStack() }) {
|
||||
Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
Text(title, style = MaterialTheme.typography.displayLarge, color = Accent900)
|
||||
}
|
||||
item {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Surface(color = Primary100, shape = RoundedCornerShape(9.dp)) {
|
||||
Text("Harheimer TC Newsletter", color = Primary900, modifier = Modifier.fillMaxWidth().padding(16.dp), textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package de.harheimertc.ui.screens.newsletter
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.NewsletterGroupDto
|
||||
import de.harheimertc.repositories.NewsletterRepository
|
||||
import de.harheimertc.ui.components.isValidEmail
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class NewsletterFormState(
|
||||
val selectedGroupId: String = "",
|
||||
val email: String = "",
|
||||
val name: String = "",
|
||||
)
|
||||
|
||||
data class NewsletterUiState(
|
||||
val groups: List<NewsletterGroupDto> = emptyList(),
|
||||
val form: NewsletterFormState = NewsletterFormState(),
|
||||
val fieldErrors: Map<String, String> = emptyMap(),
|
||||
val loading: Boolean = true,
|
||||
val submitting: Boolean = false,
|
||||
val error: String? = null,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class NewsletterViewModel @Inject constructor(
|
||||
private val repository: NewsletterRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(NewsletterUiState())
|
||||
val state: StateFlow<NewsletterUiState> = _state
|
||||
|
||||
init {
|
||||
loadGroups()
|
||||
}
|
||||
|
||||
fun loadGroups() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, error = null)
|
||||
repository.groups()
|
||||
.onSuccess { response -> _state.value = _state.value.copy(groups = response.groups, loading = false) }
|
||||
.onFailure { _state.value = _state.value.copy(loading = false, error = it.message ?: "Newsletter konnten nicht geladen werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun update(form: NewsletterFormState) {
|
||||
_state.value = _state.value.copy(
|
||||
form = form,
|
||||
fieldErrors = _state.value.fieldErrors.filterKeys { key ->
|
||||
when (key) {
|
||||
"selectedGroupId" -> form.selectedGroupId.isBlank()
|
||||
"email" -> form.email.isBlank() || !isValidEmail(form.email)
|
||||
else -> true
|
||||
}
|
||||
},
|
||||
error = null,
|
||||
message = null,
|
||||
)
|
||||
}
|
||||
|
||||
fun subscribe() = submit { groupId, email, name -> repository.subscribe(groupId, email, name) }
|
||||
|
||||
fun unsubscribe() = submit { groupId, email, _ -> repository.unsubscribe(groupId, email) }
|
||||
|
||||
private fun submit(action: suspend (String, String, String) -> Result<de.harheimertc.data.AuthMessageResponse>) {
|
||||
val current = _state.value
|
||||
val form = current.form
|
||||
val fieldErrors = buildMap {
|
||||
if (form.selectedGroupId.isBlank()) put("selectedGroupId", "Bitte Newsletter auswählen.")
|
||||
if (!isValidEmail(form.email)) put("email", "Bitte gültige E-Mail-Adresse eingeben.")
|
||||
}
|
||||
if (fieldErrors.isNotEmpty()) {
|
||||
_state.value = current.copy(fieldErrors = fieldErrors, error = "Bitte prüfen Sie die markierten Felder.")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.value = current.copy(submitting = true, error = null, message = null)
|
||||
action(form.selectedGroupId, form.email, form.name)
|
||||
.onSuccess { response ->
|
||||
_state.value = current.copy(
|
||||
form = NewsletterFormState(),
|
||||
fieldErrors = emptyMap(),
|
||||
submitting = false,
|
||||
loading = false,
|
||||
groups = current.groups,
|
||||
message = response.message ?: "Vorgang erfolgreich.",
|
||||
)
|
||||
}
|
||||
.onFailure { _state.value = current.copy(submitting = false, error = it.message ?: "Vorgang fehlgeschlagen.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class NewsletterConfirmUiState(
|
||||
val loading: Boolean = false,
|
||||
val message: String? = null,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class NewsletterConfirmViewModel @Inject constructor(
|
||||
private val repository: NewsletterRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(NewsletterConfirmUiState())
|
||||
val state: StateFlow<NewsletterConfirmUiState> = _state
|
||||
|
||||
fun confirm(token: String) {
|
||||
if (token.isBlank()) {
|
||||
_state.value = NewsletterConfirmUiState(error = "Bestätigungstoken fehlt.")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.value = NewsletterConfirmUiState(loading = true)
|
||||
repository.confirm(token)
|
||||
.onSuccess { _state.value = NewsletterConfirmUiState(message = it.message ?: "Newsletter-Anmeldung bestätigt.") }
|
||||
.onFailure { _state.value = NewsletterConfirmUiState(error = it.message ?: "Bestätigung fehlgeschlagen.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package de.harheimertc.ui.screens.publicpages
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.data.ConfigResponse
|
||||
import de.harheimertc.ui.theme.Accent700
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
|
||||
@Composable
|
||||
fun ImpressumScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: PublicConfigViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val context = LocalContext.current
|
||||
PublicPage(navController, showBackNavigation, "Impressum") {
|
||||
when {
|
||||
state.loading -> item { PublicLoading() }
|
||||
state.error != null -> item { PublicError(state.error.orEmpty(), viewModel::load) }
|
||||
state.config != null -> {
|
||||
val config = state.config
|
||||
item { ImpressumContent(config!!, onOpenSatzung = { navController.navigate(de.harheimertc.ui.navigation.Destinations.Satzung.route) }, onMail = { context.openPublicUri("mailto:$it") }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImpressumContent(config: ConfigResponse, onOpenSatzung: () -> Unit, onMail: (String) -> Unit) {
|
||||
val address = if (config.verein.useVorsitzenderAddress) {
|
||||
listOf(config.vorstand.vorsitzender.strasse, "${config.vorstand.vorsitzender.plz} ${config.vorstand.vorsitzender.ort}".trim())
|
||||
} else {
|
||||
listOf(config.verein.strasse, "${config.verein.plz} ${config.verein.ort}".trim())
|
||||
}.filter(String::isNotBlank)
|
||||
|
||||
PublicCard("Angaben gemäß § 5 TMG") {
|
||||
Text(config.verein.name.ifBlank { "Harheimer Tischtennis-Club 1954 e. V. (HTC)" }, color = Accent700)
|
||||
address.forEach { Text(it, color = Accent700) }
|
||||
}
|
||||
PublicCard("Kontakt") {
|
||||
config.vorstand.vorsitzender.telefon.takeIf(String::isNotBlank)?.let { Text("Telefon: $it", color = Accent700) }
|
||||
config.vorstand.vorsitzender.email.takeIf(String::isNotBlank)?.let { email ->
|
||||
Button(onClick = { onMail(email) }, colors = ButtonDefaults.buttonColors(containerColor = Primary600)) { Text(email) }
|
||||
}
|
||||
Text("Internet: www.harheimertc.de", color = Accent700)
|
||||
}
|
||||
PublicCard("Vertretungsberechtigter Vorstand") {
|
||||
boardRows(config).forEach { Text(it, color = Accent700) }
|
||||
}
|
||||
PublicCard("Registereintrag") {
|
||||
Text("lsb h-Vereinsnummer: 24091", color = Accent700)
|
||||
Text("Registereintrag: Amtsgericht Frankfurt am Main, Registergericht", color = Accent700)
|
||||
Text("Registernummer: VR 6835", color = Accent700)
|
||||
}
|
||||
PublicCard("Vereinssatzung") {
|
||||
Text("Unsere aktuelle Vereinssatzung können Sie online einsehen.", color = Accent700)
|
||||
Button(onClick = onOpenSatzung, colors = ButtonDefaults.buttonColors(containerColor = Primary600)) { Text("Satzung öffnen") }
|
||||
}
|
||||
PublicCard("Verantwortlich für die Website") {
|
||||
val person = config.website.verantwortlicher
|
||||
Text("${person.vorname} ${person.nachname}".trim().ifBlank { "-" }, color = Accent700)
|
||||
person.email.takeIf(String::isNotBlank)?.let { email ->
|
||||
Button(onClick = { onMail(email) }, colors = ButtonDefaults.buttonColors(containerColor = Primary600)) { Text(email) }
|
||||
}
|
||||
}
|
||||
PublicCard("Haftungsausschluss und Datenschutz") {
|
||||
SectionText("Haftung für Inhalte", "Als Diensteanbieter sind wir für eigene Inhalte nach den allgemeinen Gesetzen verantwortlich. Eine Haftung für externe oder fehlerhafte Informationen ist erst ab Kenntnis einer konkreten Rechtsverletzung möglich.")
|
||||
SectionText("Haftung für Links", "Für Inhalte externer Websites sind deren Betreiber verantwortlich. Bei Bekanntwerden von Rechtsverletzungen entfernen wir entsprechende Links.")
|
||||
SectionText("Urheberrecht", "Die Inhalte dieser App und Website unterliegen dem deutschen Urheberrecht.")
|
||||
SectionText("Datenschutz", "Personenbezogene Daten werden vertraulich und entsprechend der Datenschutzvorschriften behandelt.")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionText(title: String, text: String) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(title, color = Accent900, fontWeight = FontWeight.SemiBold)
|
||||
Text(text, color = Accent700)
|
||||
}
|
||||
}
|
||||
|
||||
private fun boardRows(config: ConfigResponse): List<String> = listOf(
|
||||
"Vorsitzender" to config.vorstand.vorsitzender,
|
||||
"Stellvertreter" to config.vorstand.stellvertreter,
|
||||
"Kassenwart" to config.vorstand.kassenwart,
|
||||
"Schriftführer" to config.vorstand.schriftfuehrer,
|
||||
"Sportwart" to config.vorstand.sportwart,
|
||||
"Jugendwart" to config.vorstand.jugendwart,
|
||||
).mapNotNull { (role, member) ->
|
||||
val name = "${member.vorname} ${member.nachname}".trim()
|
||||
name.takeIf(String::isNotBlank)?.let { "$it, $role" }
|
||||
}
|
||||
@@ -3,8 +3,6 @@ package de.harheimertc.ui.screens.publicpages
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.widget.TextView
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -27,10 +25,9 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.text.HtmlCompat
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.ui.components.RichText
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
@@ -86,20 +83,7 @@ internal fun PublicError(message: String, retry: () -> Unit) {
|
||||
|
||||
@Composable
|
||||
internal fun HtmlContent(html: String) {
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
factory = { context ->
|
||||
TextView(context).apply {
|
||||
textSize = 17f
|
||||
setTextColor(android.graphics.Color.rgb(63, 63, 70))
|
||||
movementMethod = LinkMovementMethod.getInstance()
|
||||
setLineSpacing(0f, 1.2f)
|
||||
}
|
||||
},
|
||||
update = { textView ->
|
||||
textView.text = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT)
|
||||
},
|
||||
)
|
||||
RichText(html)
|
||||
}
|
||||
|
||||
internal fun Context.openPublicUri(value: String) {
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
<template>
|
||||
<section
|
||||
id="facilities"
|
||||
class="py-16 sm:py-20 bg-white"
|
||||
>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-4">
|
||||
Unsere Anlagen
|
||||
</h2>
|
||||
<div class="w-24 h-1 bg-primary-600 mx-auto mb-6" />
|
||||
<p class="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||
Moderne Ausstattung und erstklassige Einrichtungen für ein perfektes Tischtenniserlebnis
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-8 mb-16">
|
||||
<div
|
||||
v-for="facility in facilities"
|
||||
:key="facility.title"
|
||||
class="group relative bg-white rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 overflow-hidden border border-gray-100"
|
||||
>
|
||||
<div :class="['absolute top-0 left-0 right-0 h-1 bg-gradient-to-r opacity-0 group-hover:opacity-100 transition-opacity', facility.color]" />
|
||||
<div class="p-8">
|
||||
<div :class="['w-16 h-16 bg-gradient-to-br rounded-xl flex items-center justify-center mb-4 group-hover:scale-110 transition-transform', facility.color]">
|
||||
<component
|
||||
:is="facility.icon"
|
||||
:size="32"
|
||||
class="text-white"
|
||||
/>
|
||||
</div>
|
||||
<h3 class="text-2xl font-display font-bold text-gray-900 mb-3">
|
||||
{{ facility.title }}
|
||||
</h3>
|
||||
<p class="text-gray-600 leading-relaxed">
|
||||
{{ facility.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Gallery -->
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div class="relative h-[300px] rounded-2xl overflow-hidden shadow-xl group">
|
||||
<div
|
||||
class="w-full h-full bg-cover bg-center group-hover:scale-110 transition-transform duration-700"
|
||||
style="background-image: url('https://images.unsplash.com/photo-1534438097545-77fef53fe2e8?q=80&w=2070')"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent flex items-end">
|
||||
<p class="text-white font-semibold text-xl p-6">
|
||||
Hochwertige Wettkampftische
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative h-[300px] rounded-2xl overflow-hidden shadow-xl group">
|
||||
<div
|
||||
class="w-full h-full bg-cover bg-center group-hover:scale-110 transition-transform duration-700"
|
||||
style="background-image: url('https://images.unsplash.com/photo-1611004275469-8583ed5d7b8d?q=80&w=2070')"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent flex items-end">
|
||||
<p class="text-white font-semibold text-xl p-6">
|
||||
Moderne Tischtennishalle
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Sun, CloudRain, Dumbbell, Utensils, Wifi, Droplets } from 'lucide-vue-next'
|
||||
|
||||
const facilities = [
|
||||
{
|
||||
icon: Sun,
|
||||
title: '8 Tischtennisplatten',
|
||||
description: 'Hochwertige Wettkampftische für optimales Spielvergnügen',
|
||||
color: 'from-yellow-400 to-orange-500',
|
||||
},
|
||||
{
|
||||
icon: CloudRain,
|
||||
title: 'Klimatisierte Halle',
|
||||
description: 'Optimale Bedingungen bei jedem Wetter in unserer modernen Halle',
|
||||
color: 'from-blue-400 to-blue-600',
|
||||
},
|
||||
{
|
||||
icon: Dumbbell,
|
||||
title: 'Trainingsbereich',
|
||||
description: 'Ballmaschinen und Trainingsgeräte für gezieltes Training',
|
||||
color: 'from-red-400 to-red-600',
|
||||
},
|
||||
{
|
||||
icon: Utensils,
|
||||
title: 'Clubhaus',
|
||||
description: 'Gemütliches Clubhaus mit Aufenthaltsraum und Küche',
|
||||
color: 'from-green-400 to-green-600',
|
||||
},
|
||||
{
|
||||
icon: Wifi,
|
||||
title: 'Kostenloses WLAN',
|
||||
description: 'Schnelles Internet auf der gesamten Anlage',
|
||||
color: 'from-purple-400 to-purple-600',
|
||||
},
|
||||
{
|
||||
icon: Droplets,
|
||||
title: 'Umkleiden & Duschen',
|
||||
description: 'Moderne, saubere Umkleideräume mit Duschen',
|
||||
color: 'from-cyan-400 to-cyan-600',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
@@ -1,43 +1,14 @@
|
||||
<template>
|
||||
<!-- Success Modal -->
|
||||
<!-- Success Toast (bottom) -->
|
||||
<div
|
||||
v-if="showSuccess"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||
@click.self="closeSuccess"
|
||||
v-if="showSuccessToast"
|
||||
class="fixed left-0 right-0 mx-auto max-w-3xl z-50 pointer-events-none"
|
||||
style="bottom:72px;"
|
||||
>
|
||||
<div class="bg-white rounded-lg max-w-md w-full p-6">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="flex-shrink-0 w-10 h-10 mx-auto bg-green-100 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
class="w-6 h-6 text-green-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">
|
||||
{{ successTitle }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 mb-6">
|
||||
{{ successMessage }}
|
||||
</p>
|
||||
<div class="flex justify-center">
|
||||
<button
|
||||
class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg transition-colors"
|
||||
@click="closeSuccess"
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
<div class="pointer-events-auto mx-4 shadow-lg rounded-md overflow-hidden">
|
||||
<div class="px-4 py-3 bg-green-50 text-green-800 text-sm">
|
||||
<div class="font-medium">{{ toastTitle }}</div>
|
||||
<div class="mt-1">{{ toastMessage }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -138,14 +109,14 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
// Modal States
|
||||
const showSuccess = ref(false)
|
||||
// Modal / Toast States
|
||||
const showSuccessToast = ref(false)
|
||||
const showError = ref(false)
|
||||
const showConfirm = ref(false)
|
||||
|
||||
// Modal Content
|
||||
const successTitle = ref('')
|
||||
const successMessage = ref('')
|
||||
// Modal / Toast Content
|
||||
const toastTitle = ref('')
|
||||
const toastMessage = ref('')
|
||||
const errorTitle = ref('')
|
||||
const errorMessage = ref('')
|
||||
const confirmTitle = ref('')
|
||||
@@ -153,10 +124,14 @@ const confirmMessage = ref('')
|
||||
const confirmAction = ref(null)
|
||||
|
||||
// Modal Functions
|
||||
let toastTimeout = null
|
||||
const showSuccessModal = (title, message) => {
|
||||
successTitle.value = title
|
||||
successMessage.value = message
|
||||
showSuccess.value = true
|
||||
// Show non-blocking toast at bottom instead of modal dialog
|
||||
toastTitle.value = title || 'Erfolg'
|
||||
toastMessage.value = message || ''
|
||||
showSuccessToast.value = true
|
||||
if (toastTimeout) clearTimeout(toastTimeout)
|
||||
toastTimeout = setTimeout(() => { showSuccessToast.value = false; toastTimeout = null }, 3500)
|
||||
}
|
||||
|
||||
const showErrorModal = (title, message) => {
|
||||
@@ -173,7 +148,8 @@ const showConfirmModal = (title, message, action) => {
|
||||
}
|
||||
|
||||
const closeSuccess = () => {
|
||||
showSuccess.value = false
|
||||
showSuccessToast.value = false
|
||||
if (toastTimeout) { clearTimeout(toastTimeout); toastTimeout = null }
|
||||
}
|
||||
|
||||
const closeError = () => {
|
||||
|
||||
@@ -989,10 +989,17 @@ onMounted(() => {
|
||||
|
||||
// Close CMS dropdown when clicking outside
|
||||
document.addEventListener('click', handleDocumentClick)
|
||||
// Listen for global updates to mannschaften (e.g., CMS saved)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('mannschaften:changed', loadMannschaften)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleDocumentClick)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('mannschaften:changed', loadMannschaften)
|
||||
}
|
||||
})
|
||||
|
||||
const toggleSubmenu = (menu) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<template>
|
||||
<div class="min-h-screen">
|
||||
<Facilities />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Facilities from '~/components/Facilities.vue'
|
||||
|
||||
useHead({
|
||||
title: 'Anlagen - Harheimer TC',
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<!-- Tab Content -->
|
||||
<div>
|
||||
<CmsTermine v-if="activeTab === 'termine'" />
|
||||
<CmsMannschaften v-if="activeTab === 'mannschaften'" />
|
||||
<CmsMannschaften ref="cmsMannschaftenRef" v-if="activeTab === 'mannschaften'" />
|
||||
<CmsSpielplaene v-if="activeTab === 'spielplaene'" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,7 +41,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import CmsTermine from '~/components/cms/CmsTermine.vue'
|
||||
import CmsMannschaften from '~/components/cms/CmsMannschaften.vue'
|
||||
import CmsSpielplaene from '~/components/cms/CmsSpielplaene.vue'
|
||||
@@ -56,6 +56,13 @@ useHead({
|
||||
})
|
||||
|
||||
const activeTab = ref('termine')
|
||||
const cmsMannschaftenRef = ref(null)
|
||||
|
||||
watch(activeTab, (v) => {
|
||||
if (v === 'mannschaften' && cmsMannschaftenRef?.value?.loadMannschaften) {
|
||||
try { cmsMannschaftenRef.value.loadMannschaften() } catch (e) { /* no-op */ }
|
||||
}
|
||||
})
|
||||
|
||||
const tabs = [
|
||||
{ id: 'termine', label: 'Termine' },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getUserFromToken } from '../../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
const user = token ? await getUserFromToken(token) : null
|
||||
|
||||
if (!user) {
|
||||
@@ -24,4 +24,3 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { verifyAuthenticationResponse } from '@simplewebauthn/server'
|
||||
import { createSession, generateToken, migrateUserRoles, readUsers, writeUsers } from '../../../utils/auth.js'
|
||||
import { createRefreshSession, createSession, generateAndroidAccessToken, generateToken, migrateUserRoles, readUsers, writeUsers } from '../../../utils/auth.js'
|
||||
import { getWebAuthnConfig } from '../../../utils/webauthn-config.js'
|
||||
import { consumeAuthChallenge } from '../../../utils/webauthn-challenges.js'
|
||||
import { fromBase64Url, parseClientDataJSON } from '../../../utils/webauthn-encoding.js'
|
||||
@@ -39,6 +39,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const ip = getClientIp(event)
|
||||
const body = await readBody(event)
|
||||
const isAndroidClient = body?.client === 'android'
|
||||
const response = body?.credential
|
||||
if (!response) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Credential fehlt' })
|
||||
@@ -105,10 +106,21 @@ export default defineEventHandler(async (event) => {
|
||||
passkey.lastUsedAt = new Date().toISOString()
|
||||
await writeUsers(users)
|
||||
|
||||
const token = generateToken(user)
|
||||
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'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ export default defineEventHandler(async (event) => {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
const user = token ? await getUserFromToken(token) : null
|
||||
|
||||
if (!user) {
|
||||
@@ -119,4 +119,3 @@ export default defineEventHandler(async (event) => {
|
||||
await writeAuditLog('auth.passkey.registered', { userId: user.id })
|
||||
return { success: true, message: 'Passkey hinzugefügt.' }
|
||||
})
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export default defineEventHandler(async (event) => {
|
||||
? body.preferredAuthenticatorType
|
||||
: undefined
|
||||
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
const user = token ? await getUserFromToken(token) : null
|
||||
|
||||
if (!user) {
|
||||
@@ -83,4 +83,3 @@ export default defineEventHandler(async (event) => {
|
||||
return { success: true, options }
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { getUserFromToken, readUsers, writeUsers } from '../../../utils/auth.js'
|
||||
import { writeAuditLog } from '../../../utils/audit-log.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
const currentUser = token ? await getUserFromToken(token) : null
|
||||
|
||||
if (!currentUser) {
|
||||
@@ -36,4 +36,3 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user