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] 4. CI-Build: Gradle-Config und GitHub Actions Skeleton
|
||||||
[x] 5. Theme: `Color.kt`, `Typography.kt`, `Theme.kt` erstellen und Tailwind-Farben mappen
|
[x] 5. Theme: `Color.kt`, `Typography.kt`, `Theme.kt` erstellen und Tailwind-Farben mappen
|
||||||
[x] 6. Fonts: Inter + Montserrat einbinden (res/font oder GoogleFonts)
|
[x] 6. Fonts: Inter + Montserrat einbinden (res/font oder GoogleFonts)
|
||||||
[ ] 7. Navigation: Compose Navigation-Graph mit Routen für alle Web-Seiten anlegen
|
[x] 7. Navigation: Compose Navigation-Graph mit Routen für alle Web-Seiten anlegen
|
||||||
[x] 7a. Umgebungen: Android-Varianten fuer lokal, Test-Instanz und Produktion mit eigener API-Basis konfigurieren
|
[x] 7a. Umgebungen: Android-Varianten fuer lokal, Test-Instanz und Produktion mit eigener API-Basis konfigurieren
|
||||||
[x] 7b. Adaptive Navigation: Tablet mit persistentem Header/Hauptmenue, Smartphone mit bestehender screenbezogener Navigation
|
[x] 7b. Adaptive Navigation: Tablet mit persistentem Header/Hauptmenue, Smartphone mit bestehender screenbezogener Navigation
|
||||||
[x] 7c. Branding: vorhandenes Web-Logo als optimierte Android-Ressource in der App-Navigation verwenden
|
[x] 7c. Branding: vorhandenes Web-Logo als optimierte Android-Ressource in der App-Navigation verwenden
|
||||||
@@ -69,14 +69,13 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
|
|||||||
- [x] `/training/trainer`
|
- [x] `/training/trainer`
|
||||||
- [x] `/training/anfaenger`
|
- [x] `/training/anfaenger`
|
||||||
- [x] `/tt-regeln`: Regelübersicht mit DTTB- und PDF-Aufruf
|
- [x] `/tt-regeln`: Regelübersicht mit DTTB- und PDF-Aufruf
|
||||||
[ ] 10a. Weitere öffentliche bzw. bestehende Web-Routen prüfen und portieren
|
[x] 10a. Weitere öffentliche bzw. bestehende Web-Routen prüfen und portieren
|
||||||
- [ ] `/anlagen`
|
- [x] `/impressum`
|
||||||
- [ ] `/impressum`
|
- [x] Legacy-/Doppelrouten geklärt: `/galerie`, `/geschichte`, `/satzung`, `/ueber-uns`, `/spielplan`, `/verein/tt-regeln`, `/mannschaft/[slug]`, `/mannschaften/herren`, `/mannschaften/damen`, `/mannschaften/jugend`
|
||||||
- [ ] Legacy-/Doppelrouten klären: `/galerie`, `/geschichte`, `/satzung`, `/ueber-uns`, `/spielplan`, `/verein/tt-regeln`, `/mannschaft/[slug]`, `/mannschaften/herren`, `/mannschaften/damen`, `/mannschaften/jugend`
|
[x] 10b. Newsletter-Screens portieren
|
||||||
[ ] 10b. Newsletter-Screens portieren
|
- [x] `/newsletter/subscribe`
|
||||||
- [ ] `/newsletter/subscribe`
|
- [x] `/newsletter/unsubscribe`
|
||||||
- [ ] `/newsletter/unsubscribe`
|
- [x] `/newsletter/confirm`, `/newsletter/confirmed`, `/newsletter/unsubscribed`
|
||||||
- [ ] `/newsletter/confirm`, `/newsletter/confirmed`, `/newsletter/unsubscribed`
|
|
||||||
[x] 10c. Auth-Screens portieren
|
[x] 10c. Auth-Screens portieren
|
||||||
- [x] `/login`: Passwort-Login und Logout in der laufenden Sitzung
|
- [x] `/login`: Passwort-Login und Logout in der laufenden Sitzung
|
||||||
- [x] `/registrieren`
|
- [x] `/registrieren`
|
||||||
@@ -109,10 +108,19 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
|
|||||||
- [x] Backend: Logout, Kontodeaktivierung und Passwortänderung widerrufen betroffene Refresh-Sitzungen
|
- [x] Backend: Logout, Kontodeaktivierung und Passwortänderung widerrufen betroffene Refresh-Sitzungen
|
||||||
- [x] App: Access- und Refresh-Token verschlüsselt speichern, Sitzung beim App-Start durch Refresh wiederherstellen
|
- [x] App: Access- und Refresh-Token verschlüsselt speichern, Sitzung beim App-Start durch Refresh wiederherstellen
|
||||||
- [ ] Optional nach MVP: pro Installation ein nicht exportierbares Keystore-Schlüsselpaar erzeugen und Refresh-Requests daran binden
|
- [ ] Optional nach MVP: pro Installation ein nicht exportierbares Keystore-Schlüsselpaar erzeugen und Refresh-Requests daran binden
|
||||||
[ ] 13. Passkeys: Integration prüfen (FIDO2 / Passkeys) und Fallback auf Passwort
|
[x] 13. Passkeys: Integration prüfen (FIDO2 / Passkeys) und Fallback auf Passwort
|
||||||
|
- [x] Passkey-Login über Android Credential Manager an bestehende WebAuthn-Endpunkte anbinden
|
||||||
|
- [x] Passkey-Erstellung und -Entfernung im nativen Profil ergänzen
|
||||||
|
- [x] Backend-Passkey-Endpunkte für Android-Bearer-Token und Android-Refresh-Sitzungen erweitern
|
||||||
|
- [ ] Für Produktion `/.well-known/assetlinks.json` mit Package `de.harheimertc` und Release-Zertifikat-Fingerprint veröffentlichen
|
||||||
[ ] 14. Image-Upload: Multipart-Upload + Coil für Anzeige + Bildkompression (u. a. Sharp-Äquivalent evtl. serverseitig)
|
[ ] 14. Image-Upload: Multipart-Upload + Coil für Anzeige + Bildkompression (u. a. Sharp-Äquivalent evtl. serverseitig)
|
||||||
[ ] 15. Rich-Text: Anzeige von HTML (Compose + WebView) und ggf. Editor via WebView-bridge
|
[x] 15. Rich-Text: Anzeige von HTML (Compose + WebView) und ggf. Editor via WebView-bridge
|
||||||
[ ] 16. Formulare: Validierung (clientseitig) und Fehlerdarstellung
|
- [x] Gemeinsame native HTML/Rich-Text-Komponente für CMS- und News-Inhalte ergänzt
|
||||||
|
- [x] Öffentliche CMS-Seiten, Startseiten-News und interne News rendern HTML-Inhalte nativ
|
||||||
|
- [ ] Rich-Text-Editor für CMS-Bearbeitung als WebView-Bridge prüfen, falls CMS-Editieren nativ ausgebaut wird
|
||||||
|
[x] 16. Formulare: Validierung (clientseitig) und Fehlerdarstellung
|
||||||
|
- [x] Gemeinsame validierte Textfelder mit Inline-Fehlern ergänzt
|
||||||
|
- [x] Kontakt, Login, Passwort-Reset, Registrierung, Newsletter, Profil und Mitgliedschaft zeigen Feldfehler direkt am Eingabefeld
|
||||||
[ ] 17. Offline & Caching: Room für persistente Daten, Response-Caching, Sync-Strategie
|
[ ] 17. Offline & Caching: Room für persistente Daten, Response-Caching, Sync-Strategie
|
||||||
[ ] 18. Lokalisierung: `strings.xml` (DE + EN) und i18n-Check
|
[ ] 18. Lokalisierung: `strings.xml` (DE + EN) und i18n-Check
|
||||||
[ ] 19. Accessibility: ContentDescription, Focus, Farben/Kontrast prüfen
|
[ ] 19. Accessibility: ContentDescription, Focus, Farben/Kontrast prüfen
|
||||||
@@ -135,7 +143,7 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
|
|||||||
|
|
||||||
6) Nächste Aktionen (sofort)
|
6) Nächste Aktionen (sofort)
|
||||||
- Web-Login bei Bedarf auf denselben Refresh-Flow migrieren; bis dahin bleiben Web-Cookie-Sitzungen bewusst kompatibel.
|
- Web-Login bei Bedarf auf denselben Refresh-Flow migrieren; bis dahin bleiben Web-Cookie-Sitzungen bewusst kompatibel.
|
||||||
- Passkey-Anmeldung über Android Credential Manager anbinden.
|
- Release-Zertifikat erzeugen und Digital Asset Links für native Android-Passkeys veröffentlichen.
|
||||||
- Die noch fehlenden öffentlichen Routen aus `10a` und die Newsletter-Screens aus `10b` nativ portieren.
|
- Die noch fehlenden öffentlichen Routen aus `10a` und die Newsletter-Screens aus `10b` nativ portieren.
|
||||||
- Saisonwahl für Mannschaftsübersicht/-details wie in der Web-UI ergänzen.
|
- Saisonwahl für Mannschaftsübersicht/-details wie in der Web-UI ergänzen.
|
||||||
- Weitere Mitgliederbereich/CMS-Screens portieren; dabei rollenabhängige `Intern`-Navigation und Bearer-Unterstützung der jeweils verwendeten Backend-Endpunkte ergänzen.
|
- Weitere Mitgliederbereich/CMS-Screens portieren; dabei rollenabhängige `Intern`-Navigation und Bearer-Unterstützung der jeweils verwendeten Backend-Endpunkte ergänzen.
|
||||||
@@ -162,6 +170,9 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
|
|||||||
- 2026-05-27: Native Profilseite des Mitgliederbereichs umgesetzt; `/api/profile` akzeptiert nun Bearer-Tokens, die App kann Profil-/Sichtbarkeitsdaten bearbeiten und Passwortänderungen auslösen.
|
- 2026-05-27: Native Profilseite des Mitgliederbereichs umgesetzt; `/api/profile` akzeptiert nun Bearer-Tokens, die App kann Profil-/Sichtbarkeitsdaten bearbeiten und Passwortänderungen auslösen.
|
||||||
- 2026-05-27: Login leitet in der Android-App nun direkt zur Mitgliederbereich-Übersicht weiter; `/mitgliederbereich` ist nativ mit Kacheln und Geburtstagsliste umgesetzt, `/api/birthdays` akzeptiert Bearer-Tokens.
|
- 2026-05-27: Login leitet in der Android-App nun direkt zur Mitgliederbereich-Übersicht weiter; `/mitgliederbereich` ist nativ mit Kacheln und Geburtstagsliste umgesetzt, `/api/birthdays` akzeptiert Bearer-Tokens.
|
||||||
- 2026-05-28: 10d und 10e abgeschlossen: Mitgliederliste, interne News, API-Doku sowie alle CMS-Routen sind nativ angebunden; relevante Mitglieder-/News-/CMS-Endpunkte akzeptieren Bearer-Tokens.
|
- 2026-05-28: 10d und 10e abgeschlossen: Mitgliederliste, interne News, API-Doku sowie alle CMS-Routen sind nativ angebunden; relevante Mitglieder-/News-/CMS-Endpunkte akzeptieren Bearer-Tokens.
|
||||||
|
- 2026-05-28: 10a und 10b abgeschlossen: Impressum, Newsletter-An-/Abmeldung und Newsletter-Statusseiten sind nativ umgesetzt; Legacy-/Doppelrouten im Android-NavGraph auf native Screens abgebildet. Abgleich `pages/` gegen Android-Routen ergab keine verbleibenden Platzhalter-Webseiten. `/anlagen` wurde anschließend als ungenutzte und inhaltlich falsche Seite in Web und Android entfernt.
|
||||||
|
- 2026-05-28: Android-Passkeys umgesetzt: Login nutzt Android Credential Manager mit den bestehenden WebAuthn-Optionen, das native Profil kann Passkeys erstellen/entfernen, und die Backend-Passkey-Endpunkte akzeptieren Bearer-Tokens sowie erzeugen beim Android-Passkey-Login rotierende Refresh-Sitzungen. Für Produktion fehlt noch die Domain-App-Verknüpfung per Digital Asset Links mit Release-Zertifikat.
|
||||||
|
- 2026-05-28: Punkte 15 und 16 umgesetzt: Gemeinsame `RichText`-Komponente rendert HTML-Inhalte nativ in CMS-/News-Screens; Formularvalidierung wurde mit Inline-Feldfehlern für Kontakt, Auth, Newsletter, Profil und Mitgliedschaft ergänzt.
|
||||||
|
|
||||||
8) Android-Testumgebungen
|
8) Android-Testumgebungen
|
||||||
- Lokal im Emulator: `./gradlew :app:installLocalDebug` verwendet `http://10.0.2.2:3100/` und die App-ID `de.harheimertc.local`.
|
- Lokal im Emulator: `./gradlew :app:installLocalDebug` verwendet `http://10.0.2.2:3100/` und die App-ID `de.harheimertc.local`.
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ dependencies {
|
|||||||
implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
|
implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
|
||||||
implementation("com.squareup.moshi:moshi-kotlin:1.15.1")
|
implementation("com.squareup.moshi:moshi-kotlin:1.15.1")
|
||||||
|
|
||||||
|
// Passkeys / Credential Manager
|
||||||
|
implementation("androidx.credentials:credentials:1.6.0")
|
||||||
|
implementation("androidx.credentials:credentials-play-services-auth:1.6.0")
|
||||||
|
|
||||||
// Coil
|
// Coil
|
||||||
implementation("io.coil-kt:coil-compose:2.4.0")
|
implementation("io.coil-kt:coil-compose:2.4.0")
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import retrofit2.http.Query
|
|||||||
import retrofit2.http.Url
|
import retrofit2.http.Url
|
||||||
import retrofit2.http.Streaming
|
import retrofit2.http.Streaming
|
||||||
import okhttp3.ResponseBody
|
import okhttp3.ResponseBody
|
||||||
|
import okhttp3.RequestBody
|
||||||
|
|
||||||
data class ContactRequest(val name: String, val email: String, val message: String)
|
data class ContactRequest(val name: String, val email: String, val message: String)
|
||||||
data class ContactResponse(val ok: Boolean, val id: String? = null)
|
data class ContactResponse(val ok: Boolean, val id: String? = null)
|
||||||
@@ -155,6 +156,26 @@ data class AuthStatusResponse(
|
|||||||
)
|
)
|
||||||
data class ResetPasswordRequest(val email: String)
|
data class ResetPasswordRequest(val email: String)
|
||||||
data class AuthMessageResponse(val success: Boolean = false, val message: String? = null)
|
data class AuthMessageResponse(val success: Boolean = false, val message: String? = null)
|
||||||
|
data class PasskeyAuthenticationOptionsRequest(
|
||||||
|
val email: String? = null,
|
||||||
|
val client: String = "android",
|
||||||
|
)
|
||||||
|
data class PasskeyRegistrationOptionsRequest(
|
||||||
|
val preferredAuthenticatorType: String? = null,
|
||||||
|
val client: String = "android",
|
||||||
|
)
|
||||||
|
data class PasskeyDto(
|
||||||
|
val id: String = "",
|
||||||
|
val credentialId: String = "",
|
||||||
|
val createdAt: String? = null,
|
||||||
|
val lastUsedAt: String? = null,
|
||||||
|
val name: String = "",
|
||||||
|
)
|
||||||
|
data class PasskeysResponse(
|
||||||
|
val success: Boolean = false,
|
||||||
|
val passkeys: List<PasskeyDto> = emptyList(),
|
||||||
|
)
|
||||||
|
data class RemovePasskeyRequest(val credentialId: String)
|
||||||
data class ProfileVisibilityDto(
|
data class ProfileVisibilityDto(
|
||||||
val showEmail: Boolean = true,
|
val showEmail: Boolean = true,
|
||||||
val showPhone: Boolean = true,
|
val showPhone: Boolean = true,
|
||||||
@@ -455,6 +476,24 @@ interface ApiService {
|
|||||||
@POST("/api/auth/register")
|
@POST("/api/auth/register")
|
||||||
suspend fun register(@Body request: RegistrationRequest): Response<AuthMessageResponse>
|
suspend fun register(@Body request: RegistrationRequest): Response<AuthMessageResponse>
|
||||||
|
|
||||||
|
@POST("/api/auth/passkeys/authentication-options")
|
||||||
|
suspend fun passkeyAuthenticationOptions(@Body request: PasskeyAuthenticationOptionsRequest): Response<ResponseBody>
|
||||||
|
|
||||||
|
@POST("/api/auth/passkeys/login")
|
||||||
|
suspend fun passkeyLogin(@Body request: RequestBody): Response<LoginResponse>
|
||||||
|
|
||||||
|
@GET("/api/auth/passkeys/list")
|
||||||
|
suspend fun passkeys(): Response<PasskeysResponse>
|
||||||
|
|
||||||
|
@POST("/api/auth/passkeys/registration-options")
|
||||||
|
suspend fun passkeyRegistrationOptions(@Body request: PasskeyRegistrationOptionsRequest): Response<ResponseBody>
|
||||||
|
|
||||||
|
@POST("/api/auth/passkeys/register")
|
||||||
|
suspend fun registerPasskey(@Body request: RequestBody): Response<AuthMessageResponse>
|
||||||
|
|
||||||
|
@POST("/api/auth/passkeys/remove")
|
||||||
|
suspend fun removePasskey(@Body request: RemovePasskeyRequest): Response<AuthMessageResponse>
|
||||||
|
|
||||||
@GET("/api/profile")
|
@GET("/api/profile")
|
||||||
suspend fun profile(): Response<ProfileResponse>
|
suspend fun profile(): Response<ProfileResponse>
|
||||||
|
|
||||||
|
|||||||
@@ -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.Satzung.route,
|
||||||
Destinations.Vereinsmeisterschaften.route,
|
Destinations.Vereinsmeisterschaften.route,
|
||||||
Destinations.Links.route,
|
Destinations.Links.route,
|
||||||
|
Destinations.Impressum.route,
|
||||||
Destinations.Gallery.route -> MenuSection.VEREIN
|
Destinations.Gallery.route -> MenuSection.VEREIN
|
||||||
|
|
||||||
Destinations.Mannschaften.route,
|
Destinations.Mannschaften.route,
|
||||||
@@ -240,7 +241,10 @@ private fun menuSection(route: String?): MenuSection? = when (route) {
|
|||||||
Destinations.Regeln.route -> MenuSection.TRAINING
|
Destinations.Regeln.route -> MenuSection.TRAINING
|
||||||
|
|
||||||
Destinations.NewsletterSubscribe.route,
|
Destinations.NewsletterSubscribe.route,
|
||||||
Destinations.NewsletterUnsubscribe.route -> MenuSection.NEWSLETTER
|
Destinations.NewsletterUnsubscribe.route,
|
||||||
|
Destinations.NewsletterConfirm.route,
|
||||||
|
Destinations.NewsletterConfirmed.route,
|
||||||
|
Destinations.NewsletterUnsubscribed.route -> MenuSection.NEWSLETTER
|
||||||
Destinations.MemberArea.route,
|
Destinations.MemberArea.route,
|
||||||
Destinations.Members.route,
|
Destinations.Members.route,
|
||||||
Destinations.MemberNews.route,
|
Destinations.MemberNews.route,
|
||||||
@@ -271,6 +275,7 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuT
|
|||||||
MenuTarget("Vereinsmeisterschaften", Destinations.Vereinsmeisterschaften.route),
|
MenuTarget("Vereinsmeisterschaften", Destinations.Vereinsmeisterschaften.route),
|
||||||
MenuTarget("Galerie", Destinations.Gallery.route),
|
MenuTarget("Galerie", Destinations.Gallery.route),
|
||||||
MenuTarget("Links", Destinations.Links.route),
|
MenuTarget("Links", Destinations.Links.route),
|
||||||
|
MenuTarget("Impressum", Destinations.Impressum.route),
|
||||||
)
|
)
|
||||||
MenuSection.MANNSCHAFTEN -> listOf(
|
MenuSection.MANNSCHAFTEN -> listOf(
|
||||||
MenuTarget("Übersicht", Destinations.Mannschaften.route),
|
MenuTarget("Übersicht", Destinations.Mannschaften.route),
|
||||||
@@ -287,6 +292,7 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuT
|
|||||||
MenuSection.NEWSLETTER -> listOf(
|
MenuSection.NEWSLETTER -> listOf(
|
||||||
MenuTarget("Abonnieren", Destinations.NewsletterSubscribe.route),
|
MenuTarget("Abonnieren", Destinations.NewsletterSubscribe.route),
|
||||||
MenuTarget("Abmelden", Destinations.NewsletterUnsubscribe.route),
|
MenuTarget("Abmelden", Destinations.NewsletterUnsubscribe.route),
|
||||||
|
MenuTarget("Bestätigt", Destinations.NewsletterConfirmed.route),
|
||||||
)
|
)
|
||||||
MenuSection.INTERN -> buildList {
|
MenuSection.INTERN -> buildList {
|
||||||
add(MenuTarget("Übersicht", Destinations.MemberArea.route))
|
add(MenuTarget("Übersicht", Destinations.MemberArea.route))
|
||||||
|
|||||||
@@ -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 Satzung : Destinations("verein/satzung")
|
||||||
object Vereinsmeisterschaften : Destinations("verein/vereinsmeisterschaften")
|
object Vereinsmeisterschaften : Destinations("verein/vereinsmeisterschaften")
|
||||||
object Links : Destinations("verein/links")
|
object Links : Destinations("verein/links")
|
||||||
|
object Impressum : Destinations("impressum")
|
||||||
object Mannschaften : Destinations("mannschaften")
|
object Mannschaften : Destinations("mannschaften")
|
||||||
object MannschaftDetail : Destinations("mannschaften/{slug}") {
|
object MannschaftDetail : Destinations("mannschaften/{slug}") {
|
||||||
fun create(slug: String): String = "mannschaften/$slug"
|
fun create(slug: String): String = "mannschaften/$slug"
|
||||||
}
|
}
|
||||||
|
object MannschaftLegacyDetail : Destinations("mannschaft/{slug}") {
|
||||||
|
fun create(slug: String): String = "mannschaft/$slug"
|
||||||
|
}
|
||||||
object Termine : Destinations("termine")
|
object Termine : Destinations("termine")
|
||||||
object Spielplan : Destinations("spielplan")
|
object Spielplan : Destinations("spielplan")
|
||||||
object Spielsysteme : Destinations("mannschaften/spielsysteme")
|
object Spielsysteme : Destinations("mannschaften/spielsysteme")
|
||||||
@@ -22,6 +26,9 @@ sealed class Destinations(val route: String) {
|
|||||||
object Gallery : Destinations("gallery")
|
object Gallery : Destinations("gallery")
|
||||||
object NewsletterSubscribe : Destinations("newsletter/subscribe")
|
object NewsletterSubscribe : Destinations("newsletter/subscribe")
|
||||||
object NewsletterUnsubscribe : Destinations("newsletter/unsubscribe")
|
object NewsletterUnsubscribe : Destinations("newsletter/unsubscribe")
|
||||||
|
object NewsletterConfirm : Destinations("newsletter/confirm")
|
||||||
|
object NewsletterConfirmed : Destinations("newsletter/confirmed")
|
||||||
|
object NewsletterUnsubscribed : Destinations("newsletter/unsubscribed")
|
||||||
object Contact : Destinations("contact")
|
object Contact : Destinations("contact")
|
||||||
object Membership : Destinations("membership")
|
object Membership : Destinations("membership")
|
||||||
object Login : Destinations("login")
|
object Login : Destinations("login")
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import de.harheimertc.ui.components.AppNavigationHeader
|
import de.harheimertc.ui.components.AppNavigationHeader
|
||||||
import de.harheimertc.ui.components.PendingPage
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NavGraph(
|
fun NavGraph(
|
||||||
@@ -90,12 +89,27 @@ fun NavGraph(
|
|||||||
showBackNavigation = !persistentNavigation,
|
showBackNavigation = !persistentNavigation,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
composable(Destinations.Impressum.route) {
|
||||||
|
de.harheimertc.ui.screens.publicpages.ImpressumScreen(
|
||||||
|
navController = navController,
|
||||||
|
showBackNavigation = !persistentNavigation,
|
||||||
|
)
|
||||||
|
}
|
||||||
composable(Destinations.Mannschaften.route) {
|
composable(Destinations.Mannschaften.route) {
|
||||||
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(
|
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
showBackNavigation = !persistentNavigation,
|
showBackNavigation = !persistentNavigation,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
composable("mannschaften/herren") {
|
||||||
|
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(navController, !persistentNavigation)
|
||||||
|
}
|
||||||
|
composable("mannschaften/damen") {
|
||||||
|
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(navController, !persistentNavigation)
|
||||||
|
}
|
||||||
|
composable("mannschaften/jugend") {
|
||||||
|
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(navController, !persistentNavigation)
|
||||||
|
}
|
||||||
composable(Destinations.MannschaftDetail.route) { entry ->
|
composable(Destinations.MannschaftDetail.route) { entry ->
|
||||||
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
|
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
|
||||||
slug = entry.arguments?.getString("slug").orEmpty(),
|
slug = entry.arguments?.getString("slug").orEmpty(),
|
||||||
@@ -103,6 +117,13 @@ fun NavGraph(
|
|||||||
showBackNavigation = !persistentNavigation,
|
showBackNavigation = !persistentNavigation,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
composable(Destinations.MannschaftLegacyDetail.route) { entry ->
|
||||||
|
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
|
||||||
|
slug = entry.arguments?.getString("slug").orEmpty(),
|
||||||
|
navController = navController,
|
||||||
|
showBackNavigation = !persistentNavigation,
|
||||||
|
)
|
||||||
|
}
|
||||||
composable(Destinations.Termine.route) {
|
composable(Destinations.Termine.route) {
|
||||||
de.harheimertc.ui.screens.termine.TermineScreen(
|
de.harheimertc.ui.screens.termine.TermineScreen(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
@@ -121,6 +142,9 @@ fun NavGraph(
|
|||||||
showBackNavigation = !persistentNavigation,
|
showBackNavigation = !persistentNavigation,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
composable("spielsysteme") {
|
||||||
|
de.harheimertc.ui.screens.publicpages.SpielsystemeScreen(navController, !persistentNavigation)
|
||||||
|
}
|
||||||
composable(Destinations.Training.route) {
|
composable(Destinations.Training.route) {
|
||||||
de.harheimertc.ui.screens.training.TrainingScreen(
|
de.harheimertc.ui.screens.training.TrainingScreen(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
@@ -145,14 +169,48 @@ fun NavGraph(
|
|||||||
showBackNavigation = !persistentNavigation,
|
showBackNavigation = !persistentNavigation,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
composable("tt-regeln") {
|
||||||
|
de.harheimertc.ui.screens.publicpages.RegelnScreen(navController, !persistentNavigation)
|
||||||
|
}
|
||||||
|
composable("verein/tt-regeln") {
|
||||||
|
de.harheimertc.ui.screens.publicpages.RegelnScreen(navController, !persistentNavigation)
|
||||||
|
}
|
||||||
composable(Destinations.Gallery.route) {
|
composable(Destinations.Gallery.route) {
|
||||||
de.harheimertc.ui.screens.gallery.GalleryScreen()
|
de.harheimertc.ui.screens.gallery.GalleryScreen()
|
||||||
}
|
}
|
||||||
|
composable("galerie") {
|
||||||
|
de.harheimertc.ui.screens.gallery.GalleryScreen()
|
||||||
|
}
|
||||||
composable(Destinations.NewsletterSubscribe.route) {
|
composable(Destinations.NewsletterSubscribe.route) {
|
||||||
PendingPage(navController, "Newsletter abonnieren", "/newsletter/subscribe", !persistentNavigation)
|
de.harheimertc.ui.screens.newsletter.NewsletterSubscribeScreen(
|
||||||
|
navController = navController,
|
||||||
|
showBackNavigation = !persistentNavigation,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
composable(Destinations.NewsletterUnsubscribe.route) {
|
composable(Destinations.NewsletterUnsubscribe.route) {
|
||||||
PendingPage(navController, "Newsletter abmelden", "/newsletter/unsubscribe", !persistentNavigation)
|
de.harheimertc.ui.screens.newsletter.NewsletterUnsubscribeScreen(
|
||||||
|
navController = navController,
|
||||||
|
showBackNavigation = !persistentNavigation,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Destinations.NewsletterConfirm.route) {
|
||||||
|
de.harheimertc.ui.screens.newsletter.NewsletterConfirmScreen(
|
||||||
|
navController = navController,
|
||||||
|
showBackNavigation = !persistentNavigation,
|
||||||
|
token = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Destinations.NewsletterConfirmed.route) {
|
||||||
|
de.harheimertc.ui.screens.newsletter.NewsletterConfirmedScreen(
|
||||||
|
navController = navController,
|
||||||
|
showBackNavigation = !persistentNavigation,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Destinations.NewsletterUnsubscribed.route) {
|
||||||
|
de.harheimertc.ui.screens.newsletter.NewsletterUnsubscribedScreen(
|
||||||
|
navController = navController,
|
||||||
|
showBackNavigation = !persistentNavigation,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
composable(Destinations.Contact.route) {
|
composable(Destinations.Contact.route) {
|
||||||
de.harheimertc.ui.screens.contact.ContactScreen()
|
de.harheimertc.ui.screens.contact.ContactScreen()
|
||||||
@@ -181,6 +239,15 @@ fun NavGraph(
|
|||||||
showBackNavigation = !persistentNavigation,
|
showBackNavigation = !persistentNavigation,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
composable("ueber-uns") {
|
||||||
|
de.harheimertc.ui.screens.publicpages.AboutScreen(navController, !persistentNavigation)
|
||||||
|
}
|
||||||
|
composable("geschichte") {
|
||||||
|
de.harheimertc.ui.screens.publicpages.GeschichteScreen(navController, !persistentNavigation)
|
||||||
|
}
|
||||||
|
composable("satzung") {
|
||||||
|
de.harheimertc.ui.screens.publicpages.SatzungScreen(navController, !persistentNavigation)
|
||||||
|
}
|
||||||
composable(Destinations.MemberArea.route) {
|
composable(Destinations.MemberArea.route) {
|
||||||
de.harheimertc.ui.screens.memberarea.MemberAreaScreen(
|
de.harheimertc.ui.screens.memberarea.MemberAreaScreen(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -13,6 +12,7 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import de.harheimertc.ui.components.ValidatedTextField
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ContactScreen(viewModel: ContactViewModel = hiltViewModel()) {
|
fun ContactScreen(viewModel: ContactViewModel = hiltViewModel()) {
|
||||||
@@ -21,12 +21,20 @@ fun ContactScreen(viewModel: ContactViewModel = hiltViewModel()) {
|
|||||||
val message by viewModel.message.collectAsState()
|
val message by viewModel.message.collectAsState()
|
||||||
val sending by viewModel.sending.collectAsState()
|
val sending by viewModel.sending.collectAsState()
|
||||||
val result by viewModel.result.collectAsState()
|
val result by viewModel.result.collectAsState()
|
||||||
|
val fieldErrors by viewModel.fieldErrors.collectAsState()
|
||||||
|
|
||||||
Surface(modifier = Modifier.padding(16.dp)) {
|
Surface(modifier = Modifier.padding(16.dp)) {
|
||||||
Column {
|
Column {
|
||||||
OutlinedTextField(value = name, onValueChange = { viewModel.onName(it) }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
|
ValidatedTextField(name, viewModel::onName, "Name", error = fieldErrors["name"])
|
||||||
OutlinedTextField(value = email, onValueChange = { viewModel.onEmail(it) }, label = { Text("E-Mail") }, modifier = Modifier.fillMaxWidth())
|
ValidatedTextField(email, viewModel::onEmail, "E-Mail", error = fieldErrors["email"])
|
||||||
OutlinedTextField(value = message, onValueChange = { viewModel.onMessage(it) }, label = { Text("Nachricht") }, modifier = Modifier.fillMaxWidth())
|
ValidatedTextField(
|
||||||
|
value = message,
|
||||||
|
onValueChange = viewModel::onMessage,
|
||||||
|
label = "Nachricht",
|
||||||
|
error = fieldErrors["message"],
|
||||||
|
singleLine = false,
|
||||||
|
minLines = 4,
|
||||||
|
)
|
||||||
Button(onClick = { viewModel.send() }, enabled = !sending, modifier = Modifier.padding(top = 8.dp)) {
|
Button(onClick = { viewModel.send() }, enabled = !sending, modifier = Modifier.padding(top = 8.dp)) {
|
||||||
Text(if (sending) "Sende…" else "Absenden")
|
Text(if (sending) "Sende…" else "Absenden")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.harheimertc.data.ContactRequest
|
import de.harheimertc.data.ContactRequest
|
||||||
import de.harheimertc.repositories.ContactRepository
|
import de.harheimertc.repositories.ContactRepository
|
||||||
|
import de.harheimertc.ui.components.isValidEmail
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -27,16 +28,38 @@ class ContactViewModel @Inject constructor(private val repo: ContactRepository)
|
|||||||
private val _result = MutableStateFlow<String?>(null)
|
private val _result = MutableStateFlow<String?>(null)
|
||||||
val result: StateFlow<String?> = _result
|
val result: StateFlow<String?> = _result
|
||||||
|
|
||||||
fun onName(v: String) { _name.value = v }
|
private val _fieldErrors = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||||
fun onEmail(v: String) { _email.value = v }
|
val fieldErrors: StateFlow<Map<String, String>> = _fieldErrors
|
||||||
fun onMessage(v: String) { _message.value = v }
|
|
||||||
|
fun onName(v: String) {
|
||||||
|
_name.value = v
|
||||||
|
clearFieldError("name")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEmail(v: String) {
|
||||||
|
_email.value = v
|
||||||
|
clearFieldError("email")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onMessage(v: String) {
|
||||||
|
_message.value = v
|
||||||
|
clearFieldError("message")
|
||||||
|
}
|
||||||
|
|
||||||
fun send() {
|
fun send() {
|
||||||
val n = _name.value.trim()
|
val n = _name.value.trim()
|
||||||
val e = _email.value.trim()
|
val e = _email.value.trim()
|
||||||
val m = _message.value.trim()
|
val m = _message.value.trim()
|
||||||
if (n.isEmpty() || e.isEmpty() || m.isEmpty()) {
|
val errors = buildMap {
|
||||||
_result.value = "Bitte alle Felder ausfüllen"
|
if (n.isEmpty()) put("name", "Bitte geben Sie Ihren Namen ein.")
|
||||||
|
if (e.isEmpty()) put("email", "Bitte geben Sie Ihre E-Mail-Adresse ein.")
|
||||||
|
else if (!isValidEmail(e)) put("email", "Bitte geben Sie eine gültige E-Mail-Adresse ein.")
|
||||||
|
if (m.isEmpty()) put("message", "Bitte geben Sie eine Nachricht ein.")
|
||||||
|
else if (m.length < 10) put("message", "Die Nachricht sollte mindestens 10 Zeichen haben.")
|
||||||
|
}
|
||||||
|
if (errors.isNotEmpty()) {
|
||||||
|
_fieldErrors.value = errors
|
||||||
|
_result.value = "Bitte prüfen Sie die markierten Felder."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -45,6 +68,7 @@ class ContactViewModel @Inject constructor(private val repo: ContactRepository)
|
|||||||
val resp = repo.sendContact(ContactRequest(n, e, m))
|
val resp = repo.sendContact(ContactRequest(n, e, m))
|
||||||
if (resp.isSuccessful) {
|
if (resp.isSuccessful) {
|
||||||
_result.value = "Nachricht gesendet"
|
_result.value = "Nachricht gesendet"
|
||||||
|
_fieldErrors.value = emptyMap()
|
||||||
_name.value = ""
|
_name.value = ""
|
||||||
_email.value = ""
|
_email.value = ""
|
||||||
_message.value = ""
|
_message.value = ""
|
||||||
@@ -58,4 +82,11 @@ class ContactViewModel @Inject constructor(private val repo: ContactRepository)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun clearFieldError(field: String) {
|
||||||
|
if (_fieldErrors.value.containsKey(field)) {
|
||||||
|
_fieldErrors.value = _fieldErrors.value - field
|
||||||
|
}
|
||||||
|
_result.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import de.harheimertc.data.NewsDto
|
|||||||
import de.harheimertc.data.SpielDto
|
import de.harheimertc.data.SpielDto
|
||||||
import de.harheimertc.data.TerminDto
|
import de.harheimertc.data.TerminDto
|
||||||
import de.harheimertc.ui.components.AppNavigationHeader
|
import de.harheimertc.ui.components.AppNavigationHeader
|
||||||
|
import de.harheimertc.ui.components.RichText
|
||||||
import de.harheimertc.ui.navigation.Destinations
|
import de.harheimertc.ui.navigation.Destinations
|
||||||
import de.harheimertc.ui.theme.Accent100
|
import de.harheimertc.ui.theme.Accent100
|
||||||
import de.harheimertc.ui.theme.Accent200
|
import de.harheimertc.ui.theme.Accent200
|
||||||
@@ -83,7 +84,7 @@ fun HomeScreen(
|
|||||||
Text(item.title, style = MaterialTheme.typography.titleLarge)
|
Text(item.title, style = MaterialTheme.typography.titleLarge)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
text = { Text(item.content, style = MaterialTheme.typography.bodyMedium, color = Accent700) },
|
text = { RichText(item.content) },
|
||||||
confirmButton = { TextButton(onClick = { selectedNews = null }) { Text("Schließen") } },
|
confirmButton = { TextButton(onClick = { selectedNews = null }) { Text("Schließen") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -221,13 +222,7 @@ private fun HomeNewsSection(news: List<NewsDto>, onOpen: (NewsDto) -> Unit) {
|
|||||||
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) {
|
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||||
Text(formatNewsDate(item.created), style = MaterialTheme.typography.labelSmall, color = Accent500)
|
Text(formatNewsDate(item.created), style = MaterialTheme.typography.labelSmall, color = Accent500)
|
||||||
Text(item.title, style = MaterialTheme.typography.titleLarge, color = Accent900)
|
Text(item.title, style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||||
Text(
|
RichText(item.content)
|
||||||
item.content,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = Accent700,
|
|
||||||
maxLines = 3,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import androidx.compose.runtime.collectAsState
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
@@ -33,6 +34,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import de.harheimertc.ui.theme.Accent500
|
import de.harheimertc.ui.theme.Accent500
|
||||||
|
import de.harheimertc.ui.components.ValidatedTextField
|
||||||
import de.harheimertc.ui.theme.Accent900
|
import de.harheimertc.ui.theme.Accent900
|
||||||
import de.harheimertc.ui.theme.Primary100
|
import de.harheimertc.ui.theme.Primary100
|
||||||
import de.harheimertc.ui.theme.Primary600
|
import de.harheimertc.ui.theme.Primary600
|
||||||
@@ -46,6 +48,7 @@ fun LoginScreen(
|
|||||||
viewModel: LoginViewModel = hiltViewModel(),
|
viewModel: LoginViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
LaunchedEffect(state.loggedIn, state.restoring) {
|
LaunchedEffect(state.loggedIn, state.restoring) {
|
||||||
if (state.loggedIn && !state.restoring) {
|
if (state.loggedIn && !state.restoring) {
|
||||||
@@ -92,26 +95,33 @@ fun LoginScreen(
|
|||||||
CircularProgressIndicator(color = Primary600, modifier = Modifier.size(28.dp))
|
CircularProgressIndicator(color = Primary600, modifier = Modifier.size(28.dp))
|
||||||
Text("Sitzung wird geprüft...", color = Accent500)
|
Text("Sitzung wird geprüft...", color = Accent500)
|
||||||
} else if (!state.loggedIn) {
|
} else if (!state.loggedIn) {
|
||||||
OutlinedTextField(
|
ValidatedTextField(
|
||||||
value = state.email,
|
value = state.email,
|
||||||
onValueChange = viewModel::setEmail,
|
onValueChange = viewModel::setEmail,
|
||||||
label = { Text("E-Mail-Adresse") },
|
label = "E-Mail-Adresse",
|
||||||
|
error = state.fieldErrors["email"],
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
ValidatedTextField(
|
||||||
value = state.password,
|
value = state.password,
|
||||||
onValueChange = viewModel::setPassword,
|
onValueChange = viewModel::setPassword,
|
||||||
label = { Text("Passwort") },
|
label = "Passwort",
|
||||||
|
error = state.fieldErrors["password"],
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
)
|
||||||
Button(onClick = viewModel::login, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) {
|
Button(onClick = viewModel::login, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) {
|
||||||
if (state.loading) CircularProgressIndicator(color = Color.White, strokeWidth = 2.dp, modifier = Modifier.size(18.dp).padding(end = 4.dp))
|
if (state.loading) CircularProgressIndicator(color = Color.White, strokeWidth = 2.dp, modifier = Modifier.size(18.dp).padding(end = 4.dp))
|
||||||
Text(if (state.loading) "Anmeldung läuft..." else "Anmelden")
|
Text(if (state.loading) "Anmeldung läuft..." else "Anmelden")
|
||||||
}
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { viewModel.passkeyLogin(context) },
|
||||||
|
enabled = !state.loading,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text("Mit Passkey anmelden")
|
||||||
|
}
|
||||||
TextButton(onClick = { navController.navigate(Destinations.PasswordReset.route) }, modifier = Modifier.fillMaxWidth()) {
|
TextButton(onClick = { navController.navigate(Destinations.PasswordReset.route) }, modifier = Modifier.fillMaxWidth()) {
|
||||||
Text("Passwort vergessen?")
|
Text("Passwort vergessen?")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package de.harheimertc.ui.screens.login
|
package de.harheimertc.ui.screens.login
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.harheimertc.repositories.LoginRepository
|
import de.harheimertc.repositories.LoginRepository
|
||||||
|
import de.harheimertc.repositories.PasskeyRepository
|
||||||
|
import de.harheimertc.ui.components.isValidEmail
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -12,6 +15,7 @@ import javax.inject.Inject
|
|||||||
data class LoginUiState(
|
data class LoginUiState(
|
||||||
val email: String = "",
|
val email: String = "",
|
||||||
val password: String = "",
|
val password: String = "",
|
||||||
|
val fieldErrors: Map<String, String> = emptyMap(),
|
||||||
val loading: Boolean = false,
|
val loading: Boolean = false,
|
||||||
val restoring: Boolean = true,
|
val restoring: Boolean = true,
|
||||||
val loggedIn: Boolean = false,
|
val loggedIn: Boolean = false,
|
||||||
@@ -22,7 +26,10 @@ data class LoginUiState(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class LoginViewModel @Inject constructor(private val repository: LoginRepository) : ViewModel() {
|
class LoginViewModel @Inject constructor(
|
||||||
|
private val repository: LoginRepository,
|
||||||
|
private val passkeyRepository: PasskeyRepository,
|
||||||
|
) : ViewModel() {
|
||||||
private val _state = MutableStateFlow(LoginUiState())
|
private val _state = MutableStateFlow(LoginUiState())
|
||||||
val state: StateFlow<LoginUiState> = _state
|
val state: StateFlow<LoginUiState> = _state
|
||||||
|
|
||||||
@@ -42,17 +49,21 @@ class LoginViewModel @Inject constructor(private val repository: LoginRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setEmail(value: String) {
|
fun setEmail(value: String) {
|
||||||
_state.value = _state.value.copy(email = value, error = null)
|
_state.value = _state.value.copy(email = value, fieldErrors = _state.value.fieldErrors - "email", error = null)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setPassword(value: String) {
|
fun setPassword(value: String) {
|
||||||
_state.value = _state.value.copy(password = value, error = null)
|
_state.value = _state.value.copy(password = value, fieldErrors = _state.value.fieldErrors - "password", error = null)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun login() {
|
fun login() {
|
||||||
val current = _state.value
|
val current = _state.value
|
||||||
if (current.email.isBlank() || current.password.isBlank()) {
|
val fieldErrors = buildMap {
|
||||||
_state.value = current.copy(error = "Bitte E-Mail-Adresse und Passwort eingeben.")
|
if (!isValidEmail(current.email)) put("email", "Bitte geben Sie eine gültige E-Mail-Adresse ein.")
|
||||||
|
if (current.password.isBlank()) put("password", "Bitte geben Sie Ihr Passwort ein.")
|
||||||
|
}
|
||||||
|
if (fieldErrors.isNotEmpty()) {
|
||||||
|
_state.value = current.copy(fieldErrors = fieldErrors, error = "Bitte prüfen Sie die markierten Felder.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -75,6 +86,32 @@ class LoginViewModel @Inject constructor(private val repository: LoginRepository
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun passkeyLogin(context: Context) {
|
||||||
|
val current = _state.value
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = current.copy(loading = true, error = null, message = null)
|
||||||
|
passkeyRepository.login(context, current.email)
|
||||||
|
.onSuccess { response ->
|
||||||
|
_state.value = current.copy(
|
||||||
|
password = "",
|
||||||
|
loading = false,
|
||||||
|
restoring = false,
|
||||||
|
loggedIn = true,
|
||||||
|
userName = response.user?.name ?: response.user?.email,
|
||||||
|
roles = response.user?.roles.orEmpty(),
|
||||||
|
message = "Passkey-Anmeldung erfolgreich.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
_state.value = current.copy(
|
||||||
|
loading = false,
|
||||||
|
restoring = false,
|
||||||
|
error = it.message ?: "Passkey-Anmeldung fehlgeschlagen.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun logout() {
|
fun logout() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.logout()
|
repository.logout()
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import de.harheimertc.ui.navigation.Destinations
|
import de.harheimertc.ui.navigation.Destinations
|
||||||
|
import de.harheimertc.ui.components.ValidatedTextField
|
||||||
import de.harheimertc.ui.theme.Accent500
|
import de.harheimertc.ui.theme.Accent500
|
||||||
import de.harheimertc.ui.theme.Accent900
|
import de.harheimertc.ui.theme.Accent900
|
||||||
import de.harheimertc.ui.theme.Primary100
|
import de.harheimertc.ui.theme.Primary100
|
||||||
@@ -52,13 +53,13 @@ fun PasswordResetScreen(
|
|||||||
onBack = { navController.navigate(Destinations.Login.route) },
|
onBack = { navController.navigate(Destinations.Login.route) },
|
||||||
showBackNavigation = showBackNavigation,
|
showBackNavigation = showBackNavigation,
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
ValidatedTextField(
|
||||||
state.email,
|
value = state.email,
|
||||||
viewModel::setEmail,
|
onValueChange = viewModel::setEmail,
|
||||||
label = { Text("E-Mail-Adresse") },
|
label = "E-Mail-Adresse",
|
||||||
|
error = state.fieldErrors["email"],
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
)
|
||||||
MessageLines(state.error, state.message)
|
MessageLines(state.error, state.message)
|
||||||
Button(onClick = viewModel::submit, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) {
|
Button(onClick = viewModel::submit, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) {
|
||||||
@@ -85,40 +86,39 @@ fun RegisterScreen(
|
|||||||
onBack = { navController.navigate(Destinations.Login.route) },
|
onBack = { navController.navigate(Destinations.Login.route) },
|
||||||
showBackNavigation = showBackNavigation,
|
showBackNavigation = showBackNavigation,
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(form.name, { viewModel.update(form.copy(name = it)) }, label = { Text("Name *") }, modifier = Modifier.fillMaxWidth())
|
ValidatedTextField(form.name, { viewModel.update(form.copy(name = it)) }, "Name *", error = state.fieldErrors["name"])
|
||||||
OutlinedTextField(
|
ValidatedTextField(
|
||||||
form.email,
|
value = form.email,
|
||||||
{ viewModel.update(form.copy(email = it)) },
|
onValueChange = { viewModel.update(form.copy(email = it)) },
|
||||||
label = { Text("E-Mail-Adresse *") },
|
label = "E-Mail-Adresse *",
|
||||||
|
error = state.fieldErrors["email"],
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
ValidatedTextField(
|
||||||
form.phone,
|
value = form.phone,
|
||||||
{ viewModel.update(form.copy(phone = it)) },
|
onValueChange = { viewModel.update(form.copy(phone = it)) },
|
||||||
label = { Text("Telefon") },
|
label = "Telefon",
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
ValidatedTextField(
|
||||||
form.birthDate,
|
value = form.birthDate,
|
||||||
{ viewModel.update(form.copy(birthDate = it)) },
|
onValueChange = { viewModel.update(form.copy(birthDate = it)) },
|
||||||
label = { Text("Geburtsdatum * (JJJJ-MM-TT)") },
|
label = "Geburtsdatum * (JJJJ-MM-TT)",
|
||||||
modifier = Modifier.fillMaxWidth(),
|
error = state.fieldErrors["birthDate"],
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
ValidatedTextField(
|
||||||
form.password,
|
value = form.password,
|
||||||
{ viewModel.update(form.copy(password = it)) },
|
onValueChange = { viewModel.update(form.copy(password = it)) },
|
||||||
label = { Text("Passwort *") },
|
label = "Passwort *",
|
||||||
|
error = state.fieldErrors["password"],
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
ValidatedTextField(
|
||||||
form.passwordRepeat,
|
value = form.passwordRepeat,
|
||||||
{ viewModel.update(form.copy(passwordRepeat = it)) },
|
onValueChange = { viewModel.update(form.copy(passwordRepeat = it)) },
|
||||||
label = { Text("Passwort wiederholen *") },
|
label = "Passwort wiederholen *",
|
||||||
|
error = state.fieldErrors["passwordRepeat"],
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
)
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Checkbox(form.showBirthday, { viewModel.update(form.copy(showBirthday = it)) })
|
Checkbox(form.showBirthday, { viewModel.update(form.copy(showBirthday = it)) })
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import de.harheimertc.data.RegistrationRequest
|
import de.harheimertc.data.RegistrationRequest
|
||||||
import de.harheimertc.data.RegistrationVisibility
|
import de.harheimertc.data.RegistrationVisibility
|
||||||
import de.harheimertc.repositories.LoginRepository
|
import de.harheimertc.repositories.LoginRepository
|
||||||
|
import de.harheimertc.ui.components.isValidEmail
|
||||||
|
import de.harheimertc.ui.components.isValidIsoDate
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -13,6 +15,7 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
data class PasswordResetUiState(
|
data class PasswordResetUiState(
|
||||||
val email: String = "",
|
val email: String = "",
|
||||||
|
val fieldErrors: Map<String, String> = emptyMap(),
|
||||||
val loading: Boolean = false,
|
val loading: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val message: String? = null,
|
val message: String? = null,
|
||||||
@@ -24,13 +27,16 @@ class PasswordResetViewModel @Inject constructor(private val repository: LoginRe
|
|||||||
val state: StateFlow<PasswordResetUiState> = _state
|
val state: StateFlow<PasswordResetUiState> = _state
|
||||||
|
|
||||||
fun setEmail(value: String) {
|
fun setEmail(value: String) {
|
||||||
_state.value = _state.value.copy(email = value, error = null)
|
_state.value = _state.value.copy(email = value, fieldErrors = _state.value.fieldErrors - "email", error = null)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun submit() {
|
fun submit() {
|
||||||
val email = _state.value.email.trim()
|
val email = _state.value.email.trim()
|
||||||
if (!email.contains("@")) {
|
if (!isValidEmail(email)) {
|
||||||
_state.value = _state.value.copy(error = "Bitte eine gültige E-Mail-Adresse eingeben.")
|
_state.value = _state.value.copy(
|
||||||
|
fieldErrors = mapOf("email" to "Bitte eine gültige E-Mail-Adresse eingeben."),
|
||||||
|
error = "Bitte prüfen Sie die markierten Felder.",
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -58,6 +64,7 @@ data class RegisterFormState(
|
|||||||
|
|
||||||
data class RegisterUiState(
|
data class RegisterUiState(
|
||||||
val form: RegisterFormState = RegisterFormState(),
|
val form: RegisterFormState = RegisterFormState(),
|
||||||
|
val fieldErrors: Map<String, String> = emptyMap(),
|
||||||
val loading: Boolean = false,
|
val loading: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val message: String? = null,
|
val message: String? = null,
|
||||||
@@ -69,21 +76,14 @@ class RegisterViewModel @Inject constructor(private val repository: LoginReposit
|
|||||||
val state: StateFlow<RegisterUiState> = _state
|
val state: StateFlow<RegisterUiState> = _state
|
||||||
|
|
||||||
fun update(form: RegisterFormState) {
|
fun update(form: RegisterFormState) {
|
||||||
_state.value = _state.value.copy(form = form, error = null)
|
_state.value = _state.value.copy(form = form, fieldErrors = validateFields(form, onlyTouched = true), error = null)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun submit() {
|
fun submit() {
|
||||||
val form = _state.value.form
|
val form = _state.value.form
|
||||||
val error = when {
|
val fieldErrors = validateFields(form)
|
||||||
form.name.isBlank() || form.email.isBlank() || form.birthDate.isBlank() || form.password.isBlank() ->
|
if (fieldErrors.isNotEmpty()) {
|
||||||
"Bitte alle Pflichtfelder ausfüllen."
|
_state.value = _state.value.copy(fieldErrors = fieldErrors, error = "Bitte prüfen Sie die markierten Felder.")
|
||||||
!form.email.contains("@") -> "Bitte eine gültige E-Mail-Adresse eingeben."
|
|
||||||
form.password.length < 8 -> "Das Passwort muss mindestens 8 Zeichen lang sein."
|
|
||||||
form.password != form.passwordRepeat -> "Die Passwörter stimmen nicht überein."
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
if (error != null) {
|
|
||||||
_state.value = _state.value.copy(error = error)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -104,4 +104,14 @@ class RegisterViewModel @Inject constructor(private val repository: LoginReposit
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun validateFields(form: RegisterFormState, onlyTouched: Boolean = false): Map<String, String> = buildMap {
|
||||||
|
fun shouldValidate(value: String) = !onlyTouched || value.isNotBlank()
|
||||||
|
if (!onlyTouched && form.name.isBlank()) put("name", "Bitte geben Sie Ihren Namen ein.")
|
||||||
|
if (shouldValidate(form.email) && !isValidEmail(form.email)) put("email", "Bitte geben Sie eine gültige E-Mail-Adresse ein.")
|
||||||
|
if (shouldValidate(form.birthDate) && !isValidIsoDate(form.birthDate)) put("birthDate", "Bitte verwenden Sie das Format JJJJ-MM-TT.")
|
||||||
|
if (shouldValidate(form.password) && form.password.length < 8) put("password", "Das Passwort muss mindestens 8 Zeichen lang sein.")
|
||||||
|
if (form.passwordRepeat.isNotBlank() && form.password != form.passwordRepeat) put("passwordRepeat", "Die Passwörter stimmen nicht überein.")
|
||||||
|
if (!onlyTouched && form.passwordRepeat.isBlank()) put("passwordRepeat", "Bitte wiederholen Sie das Passwort.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import de.harheimertc.data.MemberDto
|
import de.harheimertc.data.MemberDto
|
||||||
import de.harheimertc.data.NewsDto
|
import de.harheimertc.data.NewsDto
|
||||||
|
import de.harheimertc.ui.components.RichText
|
||||||
import de.harheimertc.ui.theme.Accent100
|
import de.harheimertc.ui.theme.Accent100
|
||||||
import de.harheimertc.ui.theme.Accent500
|
import de.harheimertc.ui.theme.Accent500
|
||||||
import de.harheimertc.ui.theme.Accent700
|
import de.harheimertc.ui.theme.Accent700
|
||||||
@@ -179,7 +180,7 @@ private fun NewsCard(item: NewsDto) {
|
|||||||
if (item.isHidden) Badge("Ausgeblendet")
|
if (item.isHidden) Badge("Ausgeblendet")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(item.content, color = Accent700)
|
RichText(item.content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import androidx.compose.material3.Checkbox
|
|||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.RadioButton
|
import androidx.compose.material3.RadioButton
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -39,6 +38,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import de.harheimertc.ui.navigation.Destinations
|
import de.harheimertc.ui.navigation.Destinations
|
||||||
|
import de.harheimertc.ui.components.ValidatedTextField
|
||||||
import de.harheimertc.ui.theme.Accent500
|
import de.harheimertc.ui.theme.Accent500
|
||||||
import de.harheimertc.ui.theme.Accent700
|
import de.harheimertc.ui.theme.Accent700
|
||||||
import de.harheimertc.ui.theme.Accent900
|
import de.harheimertc.ui.theme.Accent900
|
||||||
@@ -85,33 +85,33 @@ fun MembershipScreen(
|
|||||||
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(13.dp)) {
|
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(13.dp)) {
|
||||||
Text("Beitrittserklärung", style = MaterialTheme.typography.titleLarge, color = Accent900)
|
Text("Beitrittserklärung", style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||||
FormHeading("Persönliche Daten")
|
FormHeading("Persönliche Daten")
|
||||||
TextInput("Vorname *", form.vorname) { viewModel.update(form.copy(vorname = it)) }
|
TextInput("Vorname *", form.vorname, error = state.fieldErrors["vorname"]) { viewModel.update(form.copy(vorname = it)) }
|
||||||
TextInput("Nachname *", form.nachname) { viewModel.update(form.copy(nachname = it)) }
|
TextInput("Nachname *", form.nachname, error = state.fieldErrors["nachname"]) { viewModel.update(form.copy(nachname = it)) }
|
||||||
TextInput("Straße und Hausnummer *", form.strasse) { viewModel.update(form.copy(strasse = it)) }
|
TextInput("Straße und Hausnummer *", form.strasse, error = state.fieldErrors["strasse"]) { viewModel.update(form.copy(strasse = it)) }
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||||
TextInput("PLZ *", form.plz, Modifier.weight(0.38f), KeyboardType.Number) { viewModel.update(form.copy(plz = it)) }
|
TextInput("PLZ *", form.plz, Modifier.weight(0.38f), KeyboardType.Number, state.fieldErrors["plz"]) { viewModel.update(form.copy(plz = it)) }
|
||||||
TextInput("Wohnort *", form.ort, Modifier.weight(0.62f)) { viewModel.update(form.copy(ort = it)) }
|
TextInput("Wohnort *", form.ort, Modifier.weight(0.62f), error = state.fieldErrors["ort"]) { viewModel.update(form.copy(ort = it)) }
|
||||||
}
|
}
|
||||||
TextInput("Geburtsdatum * (JJJJ-MM-TT)", form.geburtsdatum) { viewModel.update(form.copy(geburtsdatum = it)) }
|
TextInput("Geburtsdatum * (JJJJ-MM-TT)", form.geburtsdatum, error = state.fieldErrors["geburtsdatum"]) { viewModel.update(form.copy(geburtsdatum = it)) }
|
||||||
TextInput("E-Mail *", form.email, keyboard = KeyboardType.Email) { viewModel.update(form.copy(email = it)) }
|
TextInput("E-Mail *", form.email, keyboard = KeyboardType.Email, error = state.fieldErrors["email"]) { viewModel.update(form.copy(email = it)) }
|
||||||
TextInput("Telefon (Mobil)", form.telefon, keyboard = KeyboardType.Phone) { viewModel.update(form.copy(telefon = it)) }
|
TextInput("Telefon (Mobil)", form.telefon, keyboard = KeyboardType.Phone) { viewModel.update(form.copy(telefon = it)) }
|
||||||
FormHeading("Mitgliedschaftsart")
|
FormHeading("Mitgliedschaftsart")
|
||||||
ChoiceRow("Aktives Mitglied", form.art == "aktiv") { viewModel.update(form.copy(art = "aktiv")) }
|
ChoiceRow("Aktives Mitglied", form.art == "aktiv") { viewModel.update(form.copy(art = "aktiv")) }
|
||||||
ChoiceRow("Passives Mitglied", form.art == "passiv") { viewModel.update(form.copy(art = "passiv")) }
|
ChoiceRow("Passives Mitglied", form.art == "passiv") { viewModel.update(form.copy(art = "passiv")) }
|
||||||
FeeInfo()
|
FeeInfo()
|
||||||
AgreementRow("Hierzu erteile ich das SEPA-Lastschriftmandat. *", form.lastschrift) {
|
AgreementRow("Hierzu erteile ich das SEPA-Lastschriftmandat. *", form.lastschrift, state.fieldErrors["lastschrift"]) {
|
||||||
viewModel.update(form.copy(lastschrift = it))
|
viewModel.update(form.copy(lastschrift = it))
|
||||||
}
|
}
|
||||||
FormHeading("Bankdaten für SEPA-Lastschrift")
|
FormHeading("Bankdaten für SEPA-Lastschrift")
|
||||||
TextInput("Kontoinhaber *", form.kontoinhaber) { viewModel.update(form.copy(kontoinhaber = it)) }
|
TextInput("Kontoinhaber *", form.kontoinhaber, error = state.fieldErrors["kontoinhaber"]) { viewModel.update(form.copy(kontoinhaber = it)) }
|
||||||
TextInput("IBAN *", form.iban) { viewModel.update(form.copy(iban = it)) }
|
TextInput("IBAN *", form.iban, error = state.fieldErrors["iban"]) { viewModel.update(form.copy(iban = it)) }
|
||||||
TextInput("BIC", form.bic) { viewModel.update(form.copy(bic = it)) }
|
TextInput("BIC", form.bic) { viewModel.update(form.copy(bic = it)) }
|
||||||
TextInput("Kreditinstitut", form.bank) { viewModel.update(form.copy(bank = it)) }
|
TextInput("Kreditinstitut", form.bank) { viewModel.update(form.copy(bank = it)) }
|
||||||
FormHeading("Datenschutz und Vereinssatzung")
|
FormHeading("Datenschutz und Vereinssatzung")
|
||||||
AgreementRow("Ich willige in die erforderliche Verarbeitung und Veröffentlichung gemäß Antrag ein. *", form.datenschutz) {
|
AgreementRow("Ich willige in die erforderliche Verarbeitung und Veröffentlichung gemäß Antrag ein. *", form.datenschutz, state.fieldErrors["datenschutz"]) {
|
||||||
viewModel.update(form.copy(datenschutz = it))
|
viewModel.update(form.copy(datenschutz = it))
|
||||||
}
|
}
|
||||||
AgreementRow("Ich erkenne die Vereinssatzung an. *", form.satzung) {
|
AgreementRow("Ich erkenne die Vereinssatzung an. *", form.satzung, state.fieldErrors["satzung"]) {
|
||||||
viewModel.update(form.copy(satzung = it))
|
viewModel.update(form.copy(satzung = it))
|
||||||
}
|
}
|
||||||
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||||
@@ -164,12 +164,14 @@ private fun TextInput(
|
|||||||
value: String,
|
value: String,
|
||||||
modifier: Modifier = Modifier.fillMaxWidth(),
|
modifier: Modifier = Modifier.fillMaxWidth(),
|
||||||
keyboard: KeyboardType = KeyboardType.Text,
|
keyboard: KeyboardType = KeyboardType.Text,
|
||||||
|
error: String? = null,
|
||||||
onChange: (String) -> Unit,
|
onChange: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
ValidatedTextField(
|
||||||
value = value,
|
value = value,
|
||||||
onValueChange = onChange,
|
onValueChange = onChange,
|
||||||
label = { Text(label) },
|
label = label,
|
||||||
|
error = error,
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = keyboard),
|
keyboardOptions = KeyboardOptions(keyboardType = keyboard),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
@@ -185,10 +187,13 @@ private fun ChoiceRow(label: String, selected: Boolean, onClick: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgreementRow(label: String, selected: Boolean, onChange: (Boolean) -> Unit) {
|
private fun AgreementRow(label: String, selected: Boolean, error: String? = null, onChange: (Boolean) -> Unit) {
|
||||||
Row(verticalAlignment = Alignment.Top) {
|
Column {
|
||||||
Checkbox(checked = selected, onCheckedChange = onChange)
|
Row(verticalAlignment = Alignment.Top) {
|
||||||
Text(label, color = Accent700, modifier = Modifier.padding(top = 12.dp))
|
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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.harheimertc.data.MembershipRequest
|
import de.harheimertc.data.MembershipRequest
|
||||||
import de.harheimertc.repositories.MembershipRepository
|
import de.harheimertc.repositories.MembershipRepository
|
||||||
|
import de.harheimertc.ui.components.isValidEmail
|
||||||
|
import de.harheimertc.ui.components.isValidIsoDate
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -31,6 +33,7 @@ data class MembershipFormState(
|
|||||||
|
|
||||||
data class MembershipUiState(
|
data class MembershipUiState(
|
||||||
val form: MembershipFormState = MembershipFormState(),
|
val form: MembershipFormState = MembershipFormState(),
|
||||||
|
val fieldErrors: Map<String, String> = emptyMap(),
|
||||||
val sending: Boolean = false,
|
val sending: Boolean = false,
|
||||||
val message: String? = null,
|
val message: String? = null,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
@@ -43,13 +46,14 @@ class MembershipViewModel @Inject constructor(private val repository: Membership
|
|||||||
val state: StateFlow<MembershipUiState> = _state
|
val state: StateFlow<MembershipUiState> = _state
|
||||||
|
|
||||||
fun update(form: MembershipFormState) {
|
fun update(form: MembershipFormState) {
|
||||||
_state.value = _state.value.copy(form = form, error = null)
|
_state.value = _state.value.copy(form = form, fieldErrors = _state.value.fieldErrors - changedKeys(_state.value.form, form), error = null)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun submit() {
|
fun submit() {
|
||||||
val form = _state.value.form
|
val form = _state.value.form
|
||||||
validate(form)?.let {
|
val fieldErrors = validateFields(form)
|
||||||
_state.value = _state.value.copy(error = it)
|
if (fieldErrors.isNotEmpty()) {
|
||||||
|
_state.value = _state.value.copy(fieldErrors = fieldErrors, error = "Bitte prüfen Sie die markierten Felder.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -74,7 +78,7 @@ class MembershipViewModel @Inject constructor(private val repository: Membership
|
|||||||
)
|
)
|
||||||
repository.submit(request)
|
repository.submit(request)
|
||||||
.onSuccess { document ->
|
.onSuccess { document ->
|
||||||
_state.value = _state.value.copy(sending = false, message = document.message, pdfUri = document.uri)
|
_state.value = _state.value.copy(sending = false, fieldErrors = emptyMap(), message = document.message, pdfUri = document.uri)
|
||||||
}
|
}
|
||||||
.onFailure {
|
.onFailure {
|
||||||
_state.value = _state.value.copy(sending = false, error = "Beitrittsformular konnte nicht erstellt werden.")
|
_state.value = _state.value.copy(sending = false, error = "Beitrittsformular konnte nicht erstellt werden.")
|
||||||
@@ -82,12 +86,33 @@ class MembershipViewModel @Inject constructor(private val repository: Membership
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun validate(form: MembershipFormState): String? = when {
|
private fun validateFields(form: MembershipFormState): Map<String, String> = buildMap {
|
||||||
listOf(form.vorname, form.nachname, form.strasse, form.plz, form.ort, form.geburtsdatum, form.email, form.kontoinhaber, form.iban)
|
if (form.vorname.isBlank()) put("vorname", "Bitte geben Sie den Vornamen ein.")
|
||||||
.any { it.isBlank() } -> "Bitte alle Pflichtfelder ausfüllen."
|
if (form.nachname.isBlank()) put("nachname", "Bitte geben Sie den Nachnamen ein.")
|
||||||
!form.plz.matches(Regex("\\d{5}")) -> "Bitte eine gültige fünfstellige PLZ eingeben."
|
if (form.strasse.isBlank()) put("strasse", "Bitte geben Sie Straße und Hausnummer ein.")
|
||||||
!form.email.contains("@") -> "Bitte eine gültige E-Mail-Adresse eingeben."
|
if (!form.plz.matches(Regex("\\d{5}"))) put("plz", "Bitte eine gültige fünfstellige PLZ eingeben.")
|
||||||
!form.lastschrift || !form.datenschutz || !form.satzung -> "Bitte die erforderlichen Einwilligungen bestätigen."
|
if (form.ort.isBlank()) put("ort", "Bitte geben Sie den Wohnort ein.")
|
||||||
else -> null
|
if (!isValidIsoDate(form.geburtsdatum)) put("geburtsdatum", "Bitte verwenden Sie das Format JJJJ-MM-TT.")
|
||||||
|
if (!isValidEmail(form.email)) put("email", "Bitte eine gültige E-Mail-Adresse eingeben.")
|
||||||
|
if (form.kontoinhaber.isBlank()) put("kontoinhaber", "Bitte geben Sie den Kontoinhaber ein.")
|
||||||
|
if (form.iban.filterNot(Char::isWhitespace).length < 15) put("iban", "Bitte geben Sie eine gültige IBAN ein.")
|
||||||
|
if (!form.lastschrift) put("lastschrift", "Das SEPA-Lastschriftmandat ist erforderlich.")
|
||||||
|
if (!form.datenschutz) put("datenschutz", "Die Datenschutzeinwilligung ist erforderlich.")
|
||||||
|
if (!form.satzung) put("satzung", "Die Anerkennung der Satzung ist erforderlich.")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun changedKeys(previous: MembershipFormState, next: MembershipFormState): Set<String> = buildSet {
|
||||||
|
if (previous.vorname != next.vorname) add("vorname")
|
||||||
|
if (previous.nachname != next.nachname) add("nachname")
|
||||||
|
if (previous.strasse != next.strasse) add("strasse")
|
||||||
|
if (previous.plz != next.plz) add("plz")
|
||||||
|
if (previous.ort != next.ort) add("ort")
|
||||||
|
if (previous.geburtsdatum != next.geburtsdatum) add("geburtsdatum")
|
||||||
|
if (previous.email != next.email) add("email")
|
||||||
|
if (previous.kontoinhaber != next.kontoinhaber) add("kontoinhaber")
|
||||||
|
if (previous.iban != next.iban) add("iban")
|
||||||
|
if (previous.lastschrift != next.lastschrift) add("lastschrift")
|
||||||
|
if (previous.datenschutz != next.datenschutz) add("datenschutz")
|
||||||
|
if (previous.satzung != next.satzung) add("satzung")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.Checkbox
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -27,6 +28,7 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
@@ -35,6 +37,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import de.harheimertc.ui.theme.Accent500
|
import de.harheimertc.ui.theme.Accent500
|
||||||
import de.harheimertc.ui.theme.Accent900
|
import de.harheimertc.ui.theme.Accent900
|
||||||
|
import de.harheimertc.ui.components.ValidatedTextField
|
||||||
import de.harheimertc.ui.theme.Primary600
|
import de.harheimertc.ui.theme.Primary600
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -45,6 +48,7 @@ fun ProfileScreen(
|
|||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
val form = state.form
|
val form = state.form
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||||
@@ -70,35 +74,34 @@ fun ProfileScreen(
|
|||||||
} else {
|
} else {
|
||||||
item {
|
item {
|
||||||
ProfileCard("Persönliche Daten") {
|
ProfileCard("Persönliche Daten") {
|
||||||
OutlinedTextField(
|
ValidatedTextField(
|
||||||
value = form.name,
|
value = form.name,
|
||||||
onValueChange = { viewModel.update(form.copy(name = it)) },
|
onValueChange = { viewModel.update(form.copy(name = it)) },
|
||||||
label = { Text("Name") },
|
label = "Name",
|
||||||
|
error = state.fieldErrors["name"],
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
ValidatedTextField(
|
||||||
value = form.email,
|
value = form.email,
|
||||||
onValueChange = { viewModel.update(form.copy(email = it)) },
|
onValueChange = { viewModel.update(form.copy(email = it)) },
|
||||||
label = { Text("E-Mail") },
|
label = "E-Mail",
|
||||||
|
error = state.fieldErrors["email"],
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
ValidatedTextField(
|
||||||
value = form.phone,
|
value = form.phone,
|
||||||
onValueChange = { viewModel.update(form.copy(phone = it)) },
|
onValueChange = { viewModel.update(form.copy(phone = it)) },
|
||||||
label = { Text("Telefon") },
|
label = "Telefon",
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
ValidatedTextField(
|
||||||
value = form.birthDate,
|
value = form.birthDate,
|
||||||
onValueChange = { viewModel.update(form.copy(birthDate = it)) },
|
onValueChange = { viewModel.update(form.copy(birthDate = it)) },
|
||||||
label = { Text("Geburtsdatum (JJJJ-MM-TT)") },
|
label = "Geburtsdatum (JJJJ-MM-TT)",
|
||||||
|
error = state.fieldErrors["birthDate"],
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,34 +117,71 @@ fun ProfileScreen(
|
|||||||
|
|
||||||
item {
|
item {
|
||||||
ProfileCard("Passwort ändern") {
|
ProfileCard("Passwort ändern") {
|
||||||
OutlinedTextField(
|
ValidatedTextField(
|
||||||
value = form.currentPassword,
|
value = form.currentPassword,
|
||||||
onValueChange = { viewModel.update(form.copy(currentPassword = it)) },
|
onValueChange = { viewModel.update(form.copy(currentPassword = it)) },
|
||||||
label = { Text("Aktuelles Passwort") },
|
label = "Aktuelles Passwort",
|
||||||
|
error = state.fieldErrors["currentPassword"],
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
ValidatedTextField(
|
||||||
value = form.newPassword,
|
value = form.newPassword,
|
||||||
onValueChange = { viewModel.update(form.copy(newPassword = it)) },
|
onValueChange = { viewModel.update(form.copy(newPassword = it)) },
|
||||||
label = { Text("Neues Passwort") },
|
label = "Neues Passwort",
|
||||||
|
error = state.fieldErrors["newPassword"],
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
ValidatedTextField(
|
||||||
value = form.confirmPassword,
|
value = form.confirmPassword,
|
||||||
onValueChange = { viewModel.update(form.copy(confirmPassword = it)) },
|
onValueChange = { viewModel.update(form.copy(confirmPassword = it)) },
|
||||||
label = { Text("Neues Passwort wiederholen") },
|
label = "Neues Passwort wiederholen",
|
||||||
|
error = state.fieldErrors["confirmPassword"],
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
)
|
||||||
Text("Leer lassen, wenn das Passwort unverändert bleiben soll.", color = Accent500)
|
Text("Leer lassen, wenn das Passwort unverändert bleiben soll.", color = Accent500)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
ProfileCard("Passkeys") {
|
||||||
|
Text(
|
||||||
|
"Passkeys ermöglichen eine Anmeldung ohne Passwort über den Android Credential Manager.",
|
||||||
|
color = Accent500,
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.addPasskey(context) },
|
||||||
|
enabled = !state.passkeyLoading,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
if (state.passkeyLoading) CircularProgressIndicator(color = Color.White, strokeWidth = 2.dp, modifier = Modifier.size(18.dp).padding(end = 8.dp))
|
||||||
|
Text(if (state.passkeyLoading) "Passkey wird erstellt..." else "Passkey hinzufügen")
|
||||||
|
}
|
||||||
|
if (state.passkeys.isEmpty()) {
|
||||||
|
Text("Noch kein Passkey hinterlegt.", color = Accent500)
|
||||||
|
} else {
|
||||||
|
state.passkeys.forEach { passkey ->
|
||||||
|
Surface(color = Color(0xFFF4F4F5), shape = RoundedCornerShape(9.dp)) {
|
||||||
|
Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Text(passkey.name.ifBlank { "Passkey" }, color = Accent900, fontWeight = FontWeight.SemiBold)
|
||||||
|
passkey.createdAt?.let { Text("Erstellt: $it", color = Accent500) }
|
||||||
|
passkey.lastUsedAt?.let { Text("Zuletzt genutzt: $it", color = Accent500) }
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { viewModel.removePasskey(passkey.credentialId) },
|
||||||
|
enabled = !state.passkeyLoading,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text("Entfernen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
Button(
|
Button(
|
||||||
onClick = viewModel::save,
|
onClick = viewModel::save,
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
package de.harheimertc.ui.screens.profile
|
package de.harheimertc.ui.screens.profile
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import de.harheimertc.data.PasskeyDto
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.harheimertc.data.ProfileUpdateRequest
|
import de.harheimertc.data.ProfileUpdateRequest
|
||||||
import de.harheimertc.data.ProfileVisibilityDto
|
import de.harheimertc.data.ProfileVisibilityDto
|
||||||
import de.harheimertc.repositories.ProfileRepository
|
import de.harheimertc.repositories.ProfileRepository
|
||||||
|
import de.harheimertc.repositories.PasskeyRepository
|
||||||
|
import de.harheimertc.ui.components.isValidEmail
|
||||||
|
import de.harheimertc.ui.components.isValidIsoDate
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -27,8 +32,11 @@ data class ProfileFormState(
|
|||||||
|
|
||||||
data class ProfileUiState(
|
data class ProfileUiState(
|
||||||
val form: ProfileFormState = ProfileFormState(),
|
val form: ProfileFormState = ProfileFormState(),
|
||||||
|
val passkeys: List<PasskeyDto> = emptyList(),
|
||||||
|
val fieldErrors: Map<String, String> = emptyMap(),
|
||||||
val loading: Boolean = true,
|
val loading: Boolean = true,
|
||||||
val saving: Boolean = false,
|
val saving: Boolean = false,
|
||||||
|
val passkeyLoading: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val message: String? = null,
|
val message: String? = null,
|
||||||
)
|
)
|
||||||
@@ -36,6 +44,7 @@ data class ProfileUiState(
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ProfileViewModel @Inject constructor(
|
class ProfileViewModel @Inject constructor(
|
||||||
private val repository: ProfileRepository,
|
private val repository: ProfileRepository,
|
||||||
|
private val passkeyRepository: PasskeyRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _state = MutableStateFlow(ProfileUiState())
|
private val _state = MutableStateFlow(ProfileUiState())
|
||||||
val state: StateFlow<ProfileUiState> = _state
|
val state: StateFlow<ProfileUiState> = _state
|
||||||
@@ -64,6 +73,7 @@ class ProfileViewModel @Inject constructor(
|
|||||||
showBirthday = visibility.showBirthday,
|
showBirthday = visibility.showBirthday,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
loadPasskeys()
|
||||||
}
|
}
|
||||||
.onFailure {
|
.onFailure {
|
||||||
_state.value = _state.value.copy(
|
_state.value = _state.value.copy(
|
||||||
@@ -74,28 +84,80 @@ class ProfileViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun loadPasskeys() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
passkeyRepository.list()
|
||||||
|
.onSuccess { response ->
|
||||||
|
_state.value = _state.value.copy(passkeys = response.passkeys, passkeyLoading = false)
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
passkeyLoading = false,
|
||||||
|
error = it.message ?: "Passkeys konnten nicht geladen werden.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addPasskey(context: Context) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(passkeyLoading = true, error = null, message = null)
|
||||||
|
passkeyRepository.add(context)
|
||||||
|
.onSuccess { response ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
passkeyLoading = false,
|
||||||
|
message = response.message ?: "Passkey hinzugefügt.",
|
||||||
|
)
|
||||||
|
loadPasskeys()
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
passkeyLoading = false,
|
||||||
|
error = it.message ?: "Passkey konnte nicht hinzugefügt werden.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removePasskey(credentialId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(passkeyLoading = true, error = null, message = null)
|
||||||
|
passkeyRepository.remove(credentialId)
|
||||||
|
.onSuccess { response ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
passkeyLoading = false,
|
||||||
|
message = response.message ?: "Passkey entfernt.",
|
||||||
|
)
|
||||||
|
loadPasskeys()
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
passkeyLoading = false,
|
||||||
|
error = it.message ?: "Passkey konnte nicht entfernt werden.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun update(form: ProfileFormState) {
|
fun update(form: ProfileFormState) {
|
||||||
_state.value = _state.value.copy(form = form, error = null, message = null)
|
_state.value = _state.value.copy(form = form, fieldErrors = emptyMap(), error = null, message = null)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun save() {
|
fun save() {
|
||||||
val form = _state.value.form
|
val form = _state.value.form
|
||||||
val validationError = when {
|
val fieldErrors = buildMap {
|
||||||
form.name.isBlank() || form.email.isBlank() -> "Name und E-Mail sind erforderlich."
|
if (form.name.isBlank()) put("name", "Bitte geben Sie Ihren Namen ein.")
|
||||||
!form.email.contains("@") -> "Bitte eine gültige E-Mail-Adresse eingeben."
|
if (!isValidEmail(form.email)) put("email", "Bitte geben Sie eine gültige E-Mail-Adresse ein.")
|
||||||
form.currentPassword.isNotBlank() || form.newPassword.isNotBlank() || form.confirmPassword.isNotBlank() -> {
|
if (form.birthDate.isNotBlank() && !isValidIsoDate(form.birthDate)) put("birthDate", "Bitte verwenden Sie das Format JJJJ-MM-TT.")
|
||||||
when {
|
if (form.currentPassword.isNotBlank() || form.newPassword.isNotBlank() || form.confirmPassword.isNotBlank()) {
|
||||||
form.currentPassword.isBlank() -> "Bitte geben Sie Ihr aktuelles Passwort ein."
|
if (form.currentPassword.isBlank()) put("currentPassword", "Bitte geben Sie Ihr aktuelles Passwort ein.")
|
||||||
form.newPassword.isBlank() -> "Bitte geben Sie ein neues Passwort ein."
|
if (form.newPassword.isBlank()) put("newPassword", "Bitte geben Sie ein neues Passwort ein.")
|
||||||
form.newPassword != form.confirmPassword -> "Die neuen Passwörter stimmen nicht überein."
|
else if (form.newPassword.length < 6) put("newPassword", "Das neue Passwort muss mindestens 6 Zeichen lang sein.")
|
||||||
form.newPassword.length < 6 -> "Das neue Passwort muss mindestens 6 Zeichen lang sein."
|
if (form.newPassword != form.confirmPassword) put("confirmPassword", "Die neuen Passwörter stimmen nicht überein.")
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else -> null
|
|
||||||
}
|
}
|
||||||
if (validationError != null) {
|
if (fieldErrors.isNotEmpty()) {
|
||||||
_state.value = _state.value.copy(error = validationError, message = null)
|
_state.value = _state.value.copy(fieldErrors = fieldErrors, error = "Bitte prüfen Sie die markierten Felder.", message = null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +179,7 @@ class ProfileViewModel @Inject constructor(
|
|||||||
newPassword = form.newPassword.takeIf(String::isNotBlank),
|
newPassword = form.newPassword.takeIf(String::isNotBlank),
|
||||||
),
|
),
|
||||||
).onSuccess { response ->
|
).onSuccess { response ->
|
||||||
|
val current = _state.value
|
||||||
val next = response.user
|
val next = response.user
|
||||||
val visibility = next?.visibility ?: ProfileVisibilityDto(
|
val visibility = next?.visibility ?: ProfileVisibilityDto(
|
||||||
form.showEmail,
|
form.showEmail,
|
||||||
@@ -127,6 +190,8 @@ class ProfileViewModel @Inject constructor(
|
|||||||
_state.value = ProfileUiState(
|
_state.value = ProfileUiState(
|
||||||
loading = false,
|
loading = false,
|
||||||
message = response.message ?: "Profil erfolgreich aktualisiert.",
|
message = response.message ?: "Profil erfolgreich aktualisiert.",
|
||||||
|
passkeys = current.passkeys,
|
||||||
|
fieldErrors = emptyMap(),
|
||||||
form = form.copy(
|
form = form.copy(
|
||||||
name = next?.name ?: form.name.trim(),
|
name = next?.name ?: form.name.trim(),
|
||||||
email = next?.email ?: form.email.trim(),
|
email = next?.email ?: form.email.trim(),
|
||||||
|
|||||||
@@ -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.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.text.method.LinkMovementMethod
|
|
||||||
import android.widget.TextView
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -27,10 +25,9 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
|
||||||
import androidx.core.text.HtmlCompat
|
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import de.harheimertc.BuildConfig
|
import de.harheimertc.BuildConfig
|
||||||
|
import de.harheimertc.ui.components.RichText
|
||||||
import de.harheimertc.ui.theme.Accent500
|
import de.harheimertc.ui.theme.Accent500
|
||||||
import de.harheimertc.ui.theme.Accent900
|
import de.harheimertc.ui.theme.Accent900
|
||||||
import de.harheimertc.ui.theme.Primary600
|
import de.harheimertc.ui.theme.Primary600
|
||||||
@@ -86,20 +83,7 @@ internal fun PublicError(message: String, retry: () -> Unit) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun HtmlContent(html: String) {
|
internal fun HtmlContent(html: String) {
|
||||||
AndroidView(
|
RichText(html)
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
factory = { context ->
|
|
||||||
TextView(context).apply {
|
|
||||||
textSize = 17f
|
|
||||||
setTextColor(android.graphics.Color.rgb(63, 63, 70))
|
|
||||||
movementMethod = LinkMovementMethod.getInstance()
|
|
||||||
setLineSpacing(0f, 1.2f)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
update = { textView ->
|
|
||||||
textView.text = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun Context.openPublicUri(value: String) {
|
internal fun Context.openPublicUri(value: String) {
|
||||||
|
|||||||
@@ -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>
|
<template>
|
||||||
<!-- Success Modal -->
|
<!-- Success Toast (bottom) -->
|
||||||
<div
|
<div
|
||||||
v-if="showSuccess"
|
v-if="showSuccessToast"
|
||||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
class="fixed left-0 right-0 mx-auto max-w-3xl z-50 pointer-events-none"
|
||||||
@click.self="closeSuccess"
|
style="bottom:72px;"
|
||||||
>
|
>
|
||||||
<div class="bg-white rounded-lg max-w-md w-full p-6">
|
<div class="pointer-events-auto mx-4 shadow-lg rounded-md overflow-hidden">
|
||||||
<div class="flex items-center mb-4">
|
<div class="px-4 py-3 bg-green-50 text-green-800 text-sm">
|
||||||
<div class="flex-shrink-0 w-10 h-10 mx-auto bg-green-100 rounded-full flex items-center justify-center">
|
<div class="font-medium">{{ toastTitle }}</div>
|
||||||
<svg
|
<div class="mt-1">{{ toastMessage }}</div>
|
||||||
class="w-6 h-6 text-green-600"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M5 13l4 4L19 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-center">
|
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-2">
|
|
||||||
{{ successTitle }}
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-gray-600 mb-6">
|
|
||||||
{{ successMessage }}
|
|
||||||
</p>
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<button
|
|
||||||
class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg transition-colors"
|
|
||||||
@click="closeSuccess"
|
|
||||||
>
|
|
||||||
OK
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,14 +109,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
// Modal States
|
// Modal / Toast States
|
||||||
const showSuccess = ref(false)
|
const showSuccessToast = ref(false)
|
||||||
const showError = ref(false)
|
const showError = ref(false)
|
||||||
const showConfirm = ref(false)
|
const showConfirm = ref(false)
|
||||||
|
|
||||||
// Modal Content
|
// Modal / Toast Content
|
||||||
const successTitle = ref('')
|
const toastTitle = ref('')
|
||||||
const successMessage = ref('')
|
const toastMessage = ref('')
|
||||||
const errorTitle = ref('')
|
const errorTitle = ref('')
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const confirmTitle = ref('')
|
const confirmTitle = ref('')
|
||||||
@@ -153,10 +124,14 @@ const confirmMessage = ref('')
|
|||||||
const confirmAction = ref(null)
|
const confirmAction = ref(null)
|
||||||
|
|
||||||
// Modal Functions
|
// Modal Functions
|
||||||
|
let toastTimeout = null
|
||||||
const showSuccessModal = (title, message) => {
|
const showSuccessModal = (title, message) => {
|
||||||
successTitle.value = title
|
// Show non-blocking toast at bottom instead of modal dialog
|
||||||
successMessage.value = message
|
toastTitle.value = title || 'Erfolg'
|
||||||
showSuccess.value = true
|
toastMessage.value = message || ''
|
||||||
|
showSuccessToast.value = true
|
||||||
|
if (toastTimeout) clearTimeout(toastTimeout)
|
||||||
|
toastTimeout = setTimeout(() => { showSuccessToast.value = false; toastTimeout = null }, 3500)
|
||||||
}
|
}
|
||||||
|
|
||||||
const showErrorModal = (title, message) => {
|
const showErrorModal = (title, message) => {
|
||||||
@@ -173,7 +148,8 @@ const showConfirmModal = (title, message, action) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const closeSuccess = () => {
|
const closeSuccess = () => {
|
||||||
showSuccess.value = false
|
showSuccessToast.value = false
|
||||||
|
if (toastTimeout) { clearTimeout(toastTimeout); toastTimeout = null }
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeError = () => {
|
const closeError = () => {
|
||||||
|
|||||||
@@ -989,10 +989,17 @@ onMounted(() => {
|
|||||||
|
|
||||||
// Close CMS dropdown when clicking outside
|
// Close CMS dropdown when clicking outside
|
||||||
document.addEventListener('click', handleDocumentClick)
|
document.addEventListener('click', handleDocumentClick)
|
||||||
|
// Listen for global updates to mannschaften (e.g., CMS saved)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('mannschaften:changed', loadMannschaften)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('click', handleDocumentClick)
|
document.removeEventListener('click', handleDocumentClick)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.removeEventListener('mannschaften:changed', loadMannschaften)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const toggleSubmenu = (menu) => {
|
const toggleSubmenu = (menu) => {
|
||||||
|
|||||||
@@ -404,7 +404,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed, onUnmounted } from 'vue'
|
||||||
import { Plus, Trash2, Loader2, AlertCircle, Pencil, Users, ChevronUp, ChevronDown, ArrowRight } from 'lucide-vue-next'
|
import { Plus, Trash2, Loader2, AlertCircle, Pencil, Users, ChevronUp, ChevronDown, ArrowRight } from 'lucide-vue-next'
|
||||||
|
|
||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
@@ -591,6 +591,12 @@ const saveCSV = async () => {
|
|||||||
content: [header, ...rows].join('\n')
|
content: [header, ...rows].join('\n')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
// Notify other parts of the app that mannschaften changed
|
||||||
|
try {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.dispatchEvent(new CustomEvent('mannschaften:changed'))
|
||||||
|
}
|
||||||
|
} catch (e) { /* no-op */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
const moveMannschaft = async (index, delta) => {
|
const moveMannschaft = async (index, delta) => {
|
||||||
@@ -674,4 +680,30 @@ onMounted(async () => {
|
|||||||
await loadSeasons()
|
await loadSeasons()
|
||||||
await loadMannschaften().catch(() => {})
|
await loadMannschaften().catch(() => {})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Expose load function to parent components
|
||||||
|
try { defineExpose({ loadMannschaften }) } catch (e) { /* noop if not supported in SSR context */ }
|
||||||
|
|
||||||
|
// Reload when tab/window becomes visible or window gains focus
|
||||||
|
const handleVisibilityOrFocus = () => {
|
||||||
|
try {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
loadMannschaften().catch(() => {})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('visibilitychange', handleVisibilityOrFocus)
|
||||||
|
window.addEventListener('focus', handleVisibilityOrFocus)
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.removeEventListener('visibilitychange', handleVisibilityOrFocus)
|
||||||
|
window.removeEventListener('focus', handleVisibilityOrFocus)
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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 -->
|
<!-- Tab Content -->
|
||||||
<div>
|
<div>
|
||||||
<CmsTermine v-if="activeTab === 'termine'" />
|
<CmsTermine v-if="activeTab === 'termine'" />
|
||||||
<CmsMannschaften v-if="activeTab === 'mannschaften'" />
|
<CmsMannschaften ref="cmsMannschaftenRef" v-if="activeTab === 'mannschaften'" />
|
||||||
<CmsSpielplaene v-if="activeTab === 'spielplaene'" />
|
<CmsSpielplaene v-if="activeTab === 'spielplaene'" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import CmsTermine from '~/components/cms/CmsTermine.vue'
|
import CmsTermine from '~/components/cms/CmsTermine.vue'
|
||||||
import CmsMannschaften from '~/components/cms/CmsMannschaften.vue'
|
import CmsMannschaften from '~/components/cms/CmsMannschaften.vue'
|
||||||
import CmsSpielplaene from '~/components/cms/CmsSpielplaene.vue'
|
import CmsSpielplaene from '~/components/cms/CmsSpielplaene.vue'
|
||||||
@@ -56,6 +56,13 @@ useHead({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const activeTab = ref('termine')
|
const activeTab = ref('termine')
|
||||||
|
const cmsMannschaftenRef = ref(null)
|
||||||
|
|
||||||
|
watch(activeTab, (v) => {
|
||||||
|
if (v === 'mannschaften' && cmsMannschaftenRef?.value?.loadMannschaften) {
|
||||||
|
try { cmsMannschaftenRef.value.loadMannschaften() } catch (e) { /* no-op */ }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'termine', label: 'Termine' },
|
{ id: 'termine', label: 'Termine' },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getUserFromToken } from '../../../utils/auth.js'
|
import { getUserFromToken } from '../../../utils/auth.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const token = getCookie(event, 'auth_token')
|
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||||
const user = token ? await getUserFromToken(token) : null
|
const user = token ? await getUserFromToken(token) : null
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -24,4 +24,3 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { verifyAuthenticationResponse } from '@simplewebauthn/server'
|
import { verifyAuthenticationResponse } from '@simplewebauthn/server'
|
||||||
import { createSession, generateToken, migrateUserRoles, readUsers, writeUsers } from '../../../utils/auth.js'
|
import { createRefreshSession, createSession, generateAndroidAccessToken, generateToken, migrateUserRoles, readUsers, writeUsers } from '../../../utils/auth.js'
|
||||||
import { getWebAuthnConfig } from '../../../utils/webauthn-config.js'
|
import { getWebAuthnConfig } from '../../../utils/webauthn-config.js'
|
||||||
import { consumeAuthChallenge } from '../../../utils/webauthn-challenges.js'
|
import { consumeAuthChallenge } from '../../../utils/webauthn-challenges.js'
|
||||||
import { fromBase64Url, parseClientDataJSON } from '../../../utils/webauthn-encoding.js'
|
import { fromBase64Url, parseClientDataJSON } from '../../../utils/webauthn-encoding.js'
|
||||||
@@ -39,6 +39,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
const ip = getClientIp(event)
|
const ip = getClientIp(event)
|
||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
|
const isAndroidClient = body?.client === 'android'
|
||||||
const response = body?.credential
|
const response = body?.credential
|
||||||
if (!response) {
|
if (!response) {
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Credential fehlt' })
|
throw createError({ statusCode: 400, statusMessage: 'Credential fehlt' })
|
||||||
@@ -105,10 +106,21 @@ export default defineEventHandler(async (event) => {
|
|||||||
passkey.lastUsedAt = new Date().toISOString()
|
passkey.lastUsedAt = new Date().toISOString()
|
||||||
await writeUsers(users)
|
await writeUsers(users)
|
||||||
|
|
||||||
const token = generateToken(user)
|
let token
|
||||||
await createSession(user.id, 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 })
|
await writeAuditLog('auth.passkey.login.success', { ip, userId: user.id })
|
||||||
|
|
||||||
@@ -120,6 +132,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
token,
|
token,
|
||||||
|
accessToken: isAndroidClient ? token : undefined,
|
||||||
|
refreshToken: refreshSession?.refreshToken,
|
||||||
|
sessionId: refreshSession?.session.id,
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
@@ -129,4 +144,3 @@ export default defineEventHandler(async (event) => {
|
|||||||
role: roles[0] || 'mitglied'
|
role: roles[0] || 'mitglied'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = getCookie(event, 'auth_token')
|
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||||
const user = token ? await getUserFromToken(token) : null
|
const user = token ? await getUserFromToken(token) : null
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -119,4 +119,3 @@ export default defineEventHandler(async (event) => {
|
|||||||
await writeAuditLog('auth.passkey.registered', { userId: user.id })
|
await writeAuditLog('auth.passkey.registered', { userId: user.id })
|
||||||
return { success: true, message: 'Passkey hinzugefügt.' }
|
return { success: true, message: 'Passkey hinzugefügt.' }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
? body.preferredAuthenticatorType
|
? body.preferredAuthenticatorType
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const token = getCookie(event, 'auth_token')
|
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||||
const user = token ? await getUserFromToken(token) : null
|
const user = token ? await getUserFromToken(token) : null
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -83,4 +83,3 @@ export default defineEventHandler(async (event) => {
|
|||||||
return { success: true, options }
|
return { success: true, options }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { getUserFromToken, readUsers, writeUsers } from '../../../utils/auth.js'
|
|||||||
import { writeAuditLog } from '../../../utils/audit-log.js'
|
import { writeAuditLog } from '../../../utils/audit-log.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const token = getCookie(event, 'auth_token')
|
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||||
const currentUser = token ? await getUserFromToken(token) : null
|
const currentUser = token ? await getUserFromToken(token) : null
|
||||||
|
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
@@ -36,4 +36,3 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user