From e033d716dd9f17c2c028597307e41944f6f7b2df Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Thu, 28 May 2026 08:01:35 +0200 Subject: [PATCH] feat: Add CMS and Member Area screens with ViewModels - Implemented CmsViewModel to manage CMS data loading and state. - Created MemberAreaDetailScreens for displaying member information and news. - Added MembersViewModel and MemberNewsViewModel for managing member data and news. - Developed MemberAreaScreen to provide navigation and display member-related options. - Introduced ProfileScreen and ProfileViewModel for user profile management. - Implemented state management for loading, error handling, and form updates across screens. --- ANDROID_KOTLIN_PLAN.md | 27 +- .../java/de/harheimertc/data/ApiService.kt | 214 ++++++++++++ .../harheimertc/repositories/CmsRepository.kt | 48 +++ .../repositories/MemberAreaRepository.kt | 38 +++ .../repositories/NewsletterRepository.kt | 33 ++ .../repositories/ProfileRepository.kt | 22 ++ .../ui/components/AppNavigationHeader.kt | 18 +- .../harheimertc/ui/navigation/Destinations.kt | 8 + .../de/harheimertc/ui/navigation/NavGraph.kt | 55 +++- .../harheimertc/ui/screens/cms/CmsScreens.kt | 305 ++++++++++++++++++ .../ui/screens/cms/CmsViewModels.kt | 62 ++++ .../ui/screens/login/LoginScreen.kt | 12 + .../memberarea/MemberAreaDetailScreens.kt | 202 ++++++++++++ .../memberarea/MemberAreaDetailViewModels.kt | 71 ++++ .../ui/screens/memberarea/MemberAreaScreen.kt | 190 +++++++++++ .../screens/memberarea/MemberAreaViewModel.kt | 48 +++ .../ui/screens/profile/ProfileScreen.kt | 182 +++++++++++ .../ui/screens/profile/ProfileViewModel.kt | 152 +++++++++ components/cms/CmsMannschaften.vue | 39 +++ package.json | 2 +- pages/login.vue | 32 +- server/api/birthdays.get.js | 6 +- server/api/cms/contact-requests.get.js | 2 +- .../api/cms/password-reset-diagnostics.get.js | 2 +- server/api/cms/users/list.get.js | 7 +- server/api/config.put.js | 3 +- server/api/members.get.js | 11 +- server/api/news.delete.js | 3 +- server/api/news.get.js | 3 +- server/api/news.post.js | 3 +- server/api/profile.get.js | 5 +- server/api/profile.put.js | 16 +- tests/config-profile-endpoints.spec.ts | 40 +++ tests/spielplan-public-endpoints.spec.ts | 20 ++ 34 files changed, 1809 insertions(+), 72 deletions(-) create mode 100644 android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt create mode 100644 android-app/app/src/main/java/de/harheimertc/repositories/MemberAreaRepository.kt create mode 100644 android-app/app/src/main/java/de/harheimertc/repositories/NewsletterRepository.kt create mode 100644 android-app/app/src/main/java/de/harheimertc/repositories/ProfileRepository.kt create mode 100644 android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt create mode 100644 android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt create mode 100644 android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailScreens.kt create mode 100644 android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailViewModels.kt create mode 100644 android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaScreen.kt create mode 100644 android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaViewModel.kt create mode 100644 android-app/app/src/main/java/de/harheimertc/ui/screens/profile/ProfileScreen.kt create mode 100644 android-app/app/src/main/java/de/harheimertc/ui/screens/profile/ProfileViewModel.kt diff --git a/ANDROID_KOTLIN_PLAN.md b/ANDROID_KOTLIN_PLAN.md index 7dc6459..b1221e5 100644 --- a/ANDROID_KOTLIN_PLAN.md +++ b/ANDROID_KOTLIN_PLAN.md @@ -81,23 +81,23 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web - [x] `/login`: Passwort-Login und Logout in der laufenden Sitzung - [x] `/registrieren` - [x] `/passwort-vergessen` - [ ] 10d. Mitgliederbereich portieren - - [ ] `/mitgliederbereich`: Übersicht - - [ ] `/mitgliederbereich/mitglieder` - - [ ] `/mitgliederbereich/news` - - [ ] `/mitgliederbereich/profil` - - [ ] `/mitgliederbereich/api` - [ ] 10e. CMS-Screens nach Rollenberechtigung portieren - - [ ] `/cms`, `/cms/startseite`, `/cms/inhalte`, `/cms/vereinsmeisterschaften` - - [ ] `/cms/sportbetrieb`, `/cms/mitgliederverwaltung`, `/cms/kontaktanfragen` - - [ ] `/cms/newsletter`, `/cms/einstellungen`, `/cms/benutzer` + [x] 10d. Mitgliederbereich portieren + - [x] `/mitgliederbereich`: Übersicht + - [x] `/mitgliederbereich/mitglieder` + - [x] `/mitgliederbereich/news` + - [x] `/mitgliederbereich/profil` + - [x] `/mitgliederbereich/api` + [x] 10e. CMS-Screens nach Rollenberechtigung portieren + - [x] `/cms`, `/cms/startseite`, `/cms/inhalte`, `/cms/vereinsmeisterschaften` + - [x] `/cms/sportbetrieb`, `/cms/mitgliederverwaltung`, `/cms/kontaktanfragen` + - [x] `/cms/newsletter`, `/cms/einstellungen`, `/cms/benutzer` [ ] 11. API-Client: Retrofit/Ktor-Client implementieren, Auth-Interceptor (Token Refresh) - [x] Retrofit/OkHttp/Moshi und Hilt-Verdrahtung - [x] Öffentliche Endpunkte für Startseite, Termine, Spielplan, Galerie und Kontakt - [x] Mitgliedschafts-PDF-Endpunkte mit Cookie-Jar und `FileProvider` - [x] Passwort-Login-Endpunkt und Token-Übergabe an den Interceptor - [x] Verschlüsselte Token-Persistenz sowie Status/Logout per Bearer-Token - - [ ] Bearer-Unterstützung aller später portierten geschützten Bereiche + - [ ] Bearer-Unterstützung aller später portierten geschützten Bereiche (`/api/profile`, `/api/birthdays`, `/api/members`, `/api/news`, CMS-Leseendpunkte erledigt) - [x] `POST /api/auth/refresh` anbinden und Access-Token bei Ablauf automatisch erneuern - [x] OkHttp-`Authenticator` mit genau einem synchronisierten Refresh-Versuch pro fehlgeschlagenem Request ergänzen [x] 12. Auth: Login/Register/Logout + sichere Token-Speicherung (EncryptedSharedPreferences) @@ -138,7 +138,7 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web - Passkey-Anmeldung über Android Credential Manager anbinden. - 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. -- Mitgliederbereich/CMS-Screens portieren; dabei rollenabhängige `Intern`-Navigation und Bearer-Unterstützung der 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. 7) Umsetzungsprotokoll - 2026-05-27: Webnahe Startseite mit öffentlichen Live-Daten umgesetzt; Hilt/Moshi-App-Verdrahtung ergänzt. @@ -159,6 +159,9 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web - 2026-05-27: Die verbleibenden öffentlichen Screens aus Punkt 10 portiert: Verein/CMS-Inhalte, Vorstand, Satzung/PDF, Links, Vereinsmeisterschaften mit Personenbild-Dialog, Spielsysteme und TT-Regeln. - 2026-05-27: Architektur für dauerhaftes Android-Login festgelegt: kein eingebetteter App-Key, sondern kurzlebige Access-Tokens und rotierende, widerrufbare Refresh-Tokens pro Gerätesitzung; optionale spätere Gerätebindung per Keystore-Schlüsselpaar. - 2026-05-27: Dauerhaftes Android-Login umgesetzt: Android-Logins erhalten 15-Minuten-Access-Tokens und rotierende Refresh-Tokens; Token-Hashes, Wiederverwendungswiderruf, Logout-/Reset-/Deaktivierungswiderruf sowie verschlüsselte App-Speicherung und automatischer OkHttp-Refresh sind implementiert. +- 2026-05-27: Native Profilseite des Mitgliederbereichs umgesetzt; `/api/profile` akzeptiert nun Bearer-Tokens, die App kann Profil-/Sichtbarkeitsdaten bearbeiten und Passwortänderungen auslösen. +- 2026-05-27: Login leitet in der Android-App nun direkt zur Mitgliederbereich-Übersicht weiter; `/mitgliederbereich` ist nativ mit Kacheln und Geburtstagsliste umgesetzt, `/api/birthdays` akzeptiert Bearer-Tokens. +- 2026-05-28: 10d und 10e abgeschlossen: Mitgliederliste, interne News, API-Doku sowie alle CMS-Routen sind nativ angebunden; relevante Mitglieder-/News-/CMS-Endpunkte akzeptieren Bearer-Tokens. 8) Android-Testumgebungen - Lokal im Emulator: `./gradlew :app:installLocalDebug` verwendet `http://10.0.2.2:3100/` und die App-ID `de.harheimertc.local`. diff --git a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt index 630d5e2..258d0be 100644 --- a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt +++ b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt @@ -3,6 +3,7 @@ package de.harheimertc.data import com.squareup.moshi.Json import retrofit2.Response import retrofit2.http.Body +import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.POST import retrofit2.http.Query @@ -77,6 +78,23 @@ data class NewsDto( val title: String = "", val content: String = "", val created: String? = null, + val updated: String? = null, + val author: String? = null, + val isPublic: Boolean = false, + val isHidden: Boolean = false, + val expiresAt: String? = null, +) +data class NewsResponse( + val success: Boolean = false, + val news: List = emptyList(), +) +data class NewsSaveRequest( + val id: Int? = null, + val title: String, + val content: String, + val isPublic: Boolean = false, + val isHidden: Boolean = false, + val expiresAt: String? = null, ) data class PublicGalleryImageDto( val filename: String = "", @@ -137,6 +155,67 @@ data class AuthStatusResponse( ) data class ResetPasswordRequest(val email: String) data class AuthMessageResponse(val success: Boolean = false, val message: String? = null) +data class ProfileVisibilityDto( + val showEmail: Boolean = true, + val showPhone: Boolean = true, + val showAddress: Boolean = false, + val showBirthday: Boolean = true, +) +data class ProfileUserDto( + val id: String? = null, + val name: String = "", + val email: String = "", + val phone: String = "", + val geburtsdatum: String = "", + val visibility: ProfileVisibilityDto = ProfileVisibilityDto(), + val roles: List = emptyList(), + val role: String? = null, +) +data class ProfileResponse( + val success: Boolean = false, + val message: String? = null, + val user: ProfileUserDto? = null, +) +data class ProfileUpdateRequest( + val name: String, + val email: String, + val phone: String? = null, + val geburtsdatum: String? = null, + val visibility: ProfileVisibilityDto, + val currentPassword: String? = null, + val newPassword: String? = null, +) +data class BirthdayDto( + val name: String = "", + val dayMonth: String = "", + val inDays: Int = 0, +) +data class BirthdaysResponse( + val success: Boolean = false, + val birthdays: List = emptyList(), +) +data class MemberDto( + val id: String? = null, + val name: String = "", + val firstName: String = "", + val lastName: String = "", + val email: String? = null, + val phone: String? = null, + val address: String? = null, + val birthday: String? = null, + val geburtsdatum: String? = null, + val source: String = "", + val notes: String = "", + val hasLogin: Boolean = false, + val editable: Boolean = false, + val isMannschaftsspieler: Boolean = false, + val hasHallKey: Boolean = false, + val loginRoles: List = emptyList(), +) +data class MembersResponse( + val success: Boolean = false, + val members: List = emptyList(), +) data class RegistrationVisibility(val showBirthday: Boolean) data class RegistrationRequest( val name: String, @@ -190,6 +269,21 @@ data class VorstandDto( val sportwart: BoardMemberDto = BoardMemberDto(), val jugendwart: BoardMemberDto = BoardMemberDto(), ) +data class VereinDto( + val name: String = "", + val strasse: String = "", + val plz: String = "", + val ort: String = "", + val useVorsitzenderAddress: Boolean = false, +) +data class WebsiteResponsibleDto( + val vorname: String = "", + val nachname: String = "", + val email: String = "", +) +data class WebsiteDto( + val verantwortlicher: WebsiteResponsibleDto = WebsiteResponsibleDto(), +) data class SatzungDto( val pdfUrl: String = "", val content: String = "", @@ -214,9 +308,81 @@ data class SeitenDto( data class ConfigResponse( val training: TrainingDto = TrainingDto(), val trainer: List = emptyList(), + val verein: VereinDto = VereinDto(), val vorstand: VorstandDto = VorstandDto(), + val website: WebsiteDto = WebsiteDto(), val seiten: SeitenDto = SeitenDto(), ) +data class CmsUserDto( + val id: String = "", + val email: String? = null, + val name: String = "", + val roles: List = emptyList(), + val role: String? = null, + val phone: String = "", + val active: Boolean = true, + val created: String? = null, + val lastLogin: String? = null, +) +data class CmsUsersResponse(val users: List = emptyList()) +data class ContactRequestDto( + val id: String = "", + val name: String = "", + val email: String = "", + val phone: String? = null, + val message: String = "", + val status: String = "", + val createdAt: String? = null, + val repliedAt: String? = null, +) +data class NewsletterDto( + val id: String = "", + val subject: String = "", + val title: String = "", + val createdAt: String? = null, + val sentAt: String? = null, + val status: String? = null, +) +data class NewsletterListResponse( + val success: Boolean = false, + val newsletters: List = emptyList(), +) +data class NewsletterGroupDto( + val id: String = "", + val name: String = "", + val description: String = "", + val subscribers: List = emptyList(), + val createdAt: String? = null, +) +data class NewsletterGroupsResponse( + val success: Boolean = false, + val groups: List = emptyList(), +) +data class NewsletterSubscriptionRequest( + val groupId: String, + val email: String, + val name: String? = null, +) +data class PasswordResetStepDto( + val ts: String? = null, + val step: String = "", + val status: String = "", + val reason: String? = null, + val errorCode: String? = null, + val errorMessage: String? = null, +) +data class PasswordResetAttemptDto( + val requestId: String = "", + val startedAt: String? = null, + val emailMasked: String? = null, + val ip: String? = null, + val failed: Boolean = false, + val steps: List = emptyList(), +) +data class PasswordResetDiagnosticsResponse( + val retentionHours: Int = 0, + val attempts: List = emptyList(), +) interface ApiService { @POST("/api/contact") @@ -243,6 +409,15 @@ interface ApiService { @GET("/api/news-public") suspend fun publicNews(): Response + @GET("/api/news") + suspend fun memberNews(): Response + + @POST("/api/news") + suspend fun saveNews(@Body request: NewsSaveRequest): Response + + @DELETE("/api/news") + suspend fun deleteNews(@Query("id") id: Int): Response + @GET("/api/mannschaften") suspend fun mannschaften(@Query("season") season: String? = null): Response @@ -279,4 +454,43 @@ interface ApiService { @POST("/api/auth/register") suspend fun register(@Body request: RegistrationRequest): Response + + @GET("/api/profile") + suspend fun profile(): Response + + @retrofit2.http.PUT("/api/profile") + suspend fun updateProfile(@Body request: ProfileUpdateRequest): Response + + @GET("/api/birthdays") + suspend fun birthdays(): Response + + @GET("/api/members") + suspend fun members(): Response + + @GET("/api/cms/users/list") + suspend fun cmsUsers(): Response + + @GET("/api/cms/contact-requests") + suspend fun contactRequests(): Response> + + @GET("/api/newsletter/list") + suspend fun newsletters(): Response + + @GET("/api/newsletter/groups/list") + suspend fun newsletterGroups(): Response + + @GET("/api/newsletter/groups/public-list") + suspend fun publicNewsletterGroups(): Response + + @POST("/api/newsletter/subscribe") + suspend fun subscribeNewsletter(@Body request: NewsletterSubscriptionRequest): Response + + @POST("/api/newsletter/unsubscribe-by-email") + suspend fun unsubscribeNewsletter(@Body request: NewsletterSubscriptionRequest): Response + + @GET("/api/newsletter/confirm") + suspend fun confirmNewsletter(@Query("token") token: String): Response + + @GET("/api/cms/password-reset-diagnostics") + suspend fun passwordResetDiagnostics(): Response } diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt new file mode 100644 index 0000000..8b6380c --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt @@ -0,0 +1,48 @@ +package de.harheimertc.repositories + +import de.harheimertc.data.ApiService +import de.harheimertc.data.CmsUsersResponse +import de.harheimertc.data.ConfigResponse +import de.harheimertc.data.ContactRequestDto +import de.harheimertc.data.NewsletterGroupsResponse +import de.harheimertc.data.NewsletterListResponse +import de.harheimertc.data.PasswordResetDiagnosticsResponse +import javax.inject.Inject + +class CmsRepository @Inject constructor(private val api: ApiService) { + suspend fun config(): Result = runCatching { + val response = api.config() + if (!response.isSuccessful) error("Konfiguration konnte nicht geladen werden.") + response.body() ?: error("Leere Antwort vom Server.") + } + + suspend fun users(): Result = runCatching { + val response = api.cmsUsers() + if (!response.isSuccessful) error("Benutzer konnten nicht geladen werden.") + response.body() ?: error("Leere Antwort vom Server.") + } + + suspend fun contactRequests(): Result> = runCatching { + val response = api.contactRequests() + if (!response.isSuccessful) error("Kontaktanfragen konnten nicht geladen werden.") + response.body() ?: emptyList() + } + + suspend fun newsletters(): Result = runCatching { + val response = api.newsletters() + if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.") + response.body() ?: error("Leere Antwort vom Server.") + } + + suspend fun newsletterGroups(): Result = runCatching { + val response = api.newsletterGroups() + if (!response.isSuccessful) error("Newsletter-Gruppen konnten nicht geladen werden.") + response.body() ?: error("Leere Antwort vom Server.") + } + + suspend fun passwordResetDiagnostics(): Result = runCatching { + val response = api.passwordResetDiagnostics() + if (!response.isSuccessful) error("Passwort-Reset-Diagnose konnte nicht geladen werden.") + response.body() ?: error("Leere Antwort vom Server.") + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/MemberAreaRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/MemberAreaRepository.kt new file mode 100644 index 0000000..9c9778d --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/repositories/MemberAreaRepository.kt @@ -0,0 +1,38 @@ +package de.harheimertc.repositories + +import de.harheimertc.data.ApiService +import de.harheimertc.data.BirthdaysResponse +import de.harheimertc.data.MembersResponse +import de.harheimertc.data.NewsResponse +import de.harheimertc.data.NewsSaveRequest +import javax.inject.Inject + +class MemberAreaRepository @Inject constructor(private val api: ApiService) { + suspend fun birthdays(): Result = runCatching { + val response = api.birthdays() + if (!response.isSuccessful) error("Geburtstage konnten nicht geladen werden.") + response.body() ?: error("Leere Antwort vom Server.") + } + + suspend fun members(): Result = runCatching { + val response = api.members() + if (!response.isSuccessful) error("Mitglieder konnten nicht geladen werden.") + response.body() ?: error("Leere Antwort vom Server.") + } + + suspend fun news(): Result = runCatching { + val response = api.memberNews() + if (!response.isSuccessful) error("News konnten nicht geladen werden.") + response.body() ?: error("Leere Antwort vom Server.") + } + + suspend fun saveNews(request: NewsSaveRequest): Result = runCatching { + val response = api.saveNews(request) + if (!response.isSuccessful) error("News konnten nicht gespeichert werden.") + } + + suspend fun deleteNews(id: Int): Result = runCatching { + val response = api.deleteNews(id) + if (!response.isSuccessful) error("News konnten nicht gelöscht werden.") + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/NewsletterRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/NewsletterRepository.kt new file mode 100644 index 0000000..9bada4d --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/repositories/NewsletterRepository.kt @@ -0,0 +1,33 @@ +package de.harheimertc.repositories + +import de.harheimertc.data.ApiService +import de.harheimertc.data.AuthMessageResponse +import de.harheimertc.data.NewsletterGroupsResponse +import de.harheimertc.data.NewsletterSubscriptionRequest +import javax.inject.Inject + +class NewsletterRepository @Inject constructor(private val api: ApiService) { + suspend fun groups(): Result = runCatching { + val response = api.publicNewsletterGroups() + if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.") + response.body() ?: error("Leere Antwort vom Server.") + } + + suspend fun subscribe(groupId: String, email: String, name: String?): Result = runCatching { + val response = api.subscribeNewsletter(NewsletterSubscriptionRequest(groupId, email.trim(), name?.trim().orEmpty())) + if (!response.isSuccessful) error("Newsletter-Anmeldung fehlgeschlagen.") + response.body() ?: AuthMessageResponse(success = true, message = "Eine Bestätigungsmail wurde versendet.") + } + + suspend fun unsubscribe(groupId: String, email: String): Result = runCatching { + val response = api.unsubscribeNewsletter(NewsletterSubscriptionRequest(groupId, email.trim())) + if (!response.isSuccessful) error("Newsletter-Abmeldung fehlgeschlagen.") + response.body() ?: AuthMessageResponse(success = true, message = "Sie wurden abgemeldet.") + } + + suspend fun confirm(token: String): Result = runCatching { + val response = api.confirmNewsletter(token) + if (!response.isSuccessful) error("Newsletter-Bestätigung fehlgeschlagen.") + response.body() ?: AuthMessageResponse(success = true, message = "Newsletter-Anmeldung bestätigt.") + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/ProfileRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/ProfileRepository.kt new file mode 100644 index 0000000..d5ee5ca --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/repositories/ProfileRepository.kt @@ -0,0 +1,22 @@ +package de.harheimertc.repositories + +import de.harheimertc.data.ApiService +import de.harheimertc.data.ProfileResponse +import de.harheimertc.data.ProfileUpdateRequest +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ProfileRepository @Inject constructor(private val api: ApiService) { + suspend fun load(): Result = runCatching { + val response = api.profile() + if (!response.isSuccessful) error("Profil konnte nicht geladen werden.") + response.body() ?: error("Leere Antwort") + } + + suspend fun save(request: ProfileUpdateRequest): Result = runCatching { + val response = api.updateProfile(request) + if (!response.isSuccessful) error("Profil konnte nicht gespeichert werden.") + response.body() ?: error("Leere Antwort") + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt b/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt index d7351ee..327c9b2 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt @@ -246,8 +246,16 @@ private fun menuSection(route: String?): MenuSection? = when (route) { Destinations.MemberNews.route, Destinations.Profile.route, Destinations.MemberApi.route, + Destinations.CmsStartseite.route, + Destinations.CmsInhalte.route, + Destinations.CmsVereinsmeisterschaften.route, + Destinations.CmsSportbetrieb.route, + Destinations.CmsMitgliederverwaltung.route, Destinations.CmsNewsletter.route, Destinations.CmsContactRequests.route, + Destinations.CmsEinstellungen.route, + Destinations.CmsBenutzer.route, + Destinations.CmsPasswordResetDiagnostics.route, Destinations.Cms.route -> MenuSection.INTERN else -> null }.let { section -> @@ -286,9 +294,17 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List emptyList() } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/navigation/Destinations.kt b/android-app/app/src/main/java/de/harheimertc/ui/navigation/Destinations.kt index 3ce736e..310f287 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/navigation/Destinations.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/navigation/Destinations.kt @@ -32,7 +32,15 @@ sealed class Destinations(val route: String) { object MemberNews : Destinations("intern/news") object Profile : Destinations("intern/profil") object MemberApi : Destinations("intern/api") + object CmsStartseite : Destinations("cms/startseite") + object CmsInhalte : Destinations("cms/inhalte") + object CmsVereinsmeisterschaften : Destinations("cms/vereinsmeisterschaften") + object CmsSportbetrieb : Destinations("cms/sportbetrieb") + object CmsMitgliederverwaltung : Destinations("cms/mitgliederverwaltung") object CmsNewsletter : Destinations("cms/newsletter") object CmsContactRequests : Destinations("cms/kontaktanfragen") + object CmsEinstellungen : Destinations("cms/einstellungen") + object CmsBenutzer : Destinations("cms/benutzer") + object CmsPasswordResetDiagnostics : Destinations("cms/passwort-reset-diagnose") object Cms : Destinations("cms") } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt b/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt index 07421f6..c8bcade 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt @@ -182,28 +182,67 @@ fun NavGraph( ) } composable(Destinations.MemberArea.route) { - PendingPage(navController, "Intern", "/mitgliederbereich", !persistentNavigation) + de.harheimertc.ui.screens.memberarea.MemberAreaScreen( + navController = navController, + showBackNavigation = !persistentNavigation, + ) } composable(Destinations.Members.route) { - PendingPage(navController, "Mitgliederliste", "/mitgliederbereich/mitglieder", !persistentNavigation) + de.harheimertc.ui.screens.memberarea.MembersScreen( + navController = navController, + showBackNavigation = !persistentNavigation, + ) } composable(Destinations.MemberNews.route) { - PendingPage(navController, "News", "/mitgliederbereich/news", !persistentNavigation) + de.harheimertc.ui.screens.memberarea.MemberNewsScreen( + navController = navController, + showBackNavigation = !persistentNavigation, + ) } composable(Destinations.Profile.route) { - PendingPage(navController, "Mein Profil", "/mitgliederbereich/profil", !persistentNavigation) + de.harheimertc.ui.screens.profile.ProfileScreen( + navController = navController, + showBackNavigation = !persistentNavigation, + ) } composable(Destinations.MemberApi.route) { - PendingPage(navController, "API-Dokumentation", "/mitgliederbereich/api", !persistentNavigation) + de.harheimertc.ui.screens.memberarea.MemberApiScreen( + navController = navController, + showBackNavigation = !persistentNavigation, + ) + } + composable(Destinations.CmsStartseite.route) { + de.harheimertc.ui.screens.cms.CmsStartseiteScreen(navController, !persistentNavigation) + } + composable(Destinations.CmsInhalte.route) { + de.harheimertc.ui.screens.cms.CmsInhalteScreen(navController, !persistentNavigation) + } + composable(Destinations.CmsVereinsmeisterschaften.route) { + de.harheimertc.ui.screens.cms.CmsVereinsmeisterschaftenScreen(navController, !persistentNavigation) + } + composable(Destinations.CmsSportbetrieb.route) { + de.harheimertc.ui.screens.cms.CmsSportbetriebScreen(navController, !persistentNavigation) + } + composable(Destinations.CmsMitgliederverwaltung.route) { + de.harheimertc.ui.screens.cms.CmsMitgliederverwaltungScreen(navController, !persistentNavigation) } composable(Destinations.CmsNewsletter.route) { - PendingPage(navController, "Newsletter", "/cms/newsletter", !persistentNavigation) + de.harheimertc.ui.screens.cms.CmsNewsletterScreen(navController, !persistentNavigation) } composable(Destinations.CmsContactRequests.route) { - PendingPage(navController, "Kontaktanfragen", "/cms/kontaktanfragen", !persistentNavigation) + de.harheimertc.ui.screens.cms.CmsContactRequestsScreen(navController, !persistentNavigation) + } + composable(Destinations.CmsEinstellungen.route) { + de.harheimertc.ui.screens.cms.CmsEinstellungenScreen(navController, !persistentNavigation) + } + composable(Destinations.CmsBenutzer.route) { + de.harheimertc.ui.screens.cms.CmsBenutzerScreen(navController, !persistentNavigation) + } + composable(Destinations.CmsPasswordResetDiagnostics.route) { + de.harheimertc.ui.screens.cms.CmsPasswordResetDiagnosticsScreen(navController, !persistentNavigation) } composable(Destinations.Cms.route) { - PendingPage(navController, "CMS", "/cms", !persistentNavigation) + de.harheimertc.ui.screens.cms.CmsDashboardScreen(navController, !persistentNavigation) } } } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt new file mode 100644 index 0000000..febeaa9 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt @@ -0,0 +1,305 @@ +package de.harheimertc.ui.screens.cms + +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.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.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.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import de.harheimertc.data.CmsUserDto +import de.harheimertc.data.ConfigResponse +import de.harheimertc.data.ContactRequestDto +import de.harheimertc.data.NewsletterDto +import de.harheimertc.data.NewsletterGroupDto +import de.harheimertc.data.PasswordResetAttemptDto +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 + +@Composable +fun CmsDashboardScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsState() + CmsPage(navController, showBackNavigation, "CMS", "Verwaltung und interne Werkzeuge") { + if (state.loading) item { CircularProgressIndicator(color = Primary600) } + item { CmsSummaryGrid(navController, state) } + } +} + +@Composable +fun CmsStartseiteScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsState() + CmsConfigPage(navController, showBackNavigation, "Startseite", "Öffentliche Startseiteninhalte", state.config) { + InfoRow("Öffentliche News", "${state.newsletters.count { it.sentAt != null }} versendete Newsletter") + InfoRow("Kontaktanfragen", "${state.contactRequests.size} Einträge") + } +} + +@Composable +fun CmsInhalteScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsState() + CmsConfigPage(navController, showBackNavigation, "Inhalte", "Vereinsseiten und strukturierte Inhalte", state.config) { config -> + InfoRow("Über uns", textState(config.seiten.ueberUns)) + InfoRow("Geschichte", textState(config.seiten.geschichte)) + InfoRow("Satzung", if (config.seiten.satzung.pdfUrl.isNotBlank()) config.seiten.satzung.pdfUrl else textState(config.seiten.satzung.content)) + InfoRow("Links", "${config.seiten.linksStructured.size} strukturierte Gruppen") + } +} + +@Composable +fun CmsVereinsmeisterschaftenScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsState() + CmsConfigPage(navController, showBackNavigation, "Vereinsmeisterschaften", "CSV- und Personenbild-Inhalte", state.config) { + InfoRow("Datenquelle", "/api/vereinsmeisterschaften") + InfoRow("Hinweis", "Die Ergebnisliste ist nativ im öffentlichen Bereich portiert.") + } +} + +@Composable +fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsState() + CmsConfigPage(navController, showBackNavigation, "Sportbetrieb", "Training, Trainer und Mannschaftsinhalte", state.config) { config -> + InfoRow("Trainingszeiten", "${config.training.zeiten.size} Einträge") + InfoRow("Trainer", "${config.trainer.size} Personen") + InfoRow("Spielsysteme", "/data/spielsysteme.csv") + } +} + +@Composable +fun CmsMitgliederverwaltungScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsState() + CmsUserListPage(navController, showBackNavigation, "Mitgliederverwaltung", state.users) +} + +@Composable +fun CmsContactRequestsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsState() + CmsPage(navController, showBackNavigation, "Kontaktanfragen", "Eingegangene Nachrichten") { + if (state.loading) item { CircularProgressIndicator(color = Primary600) } + if (!state.loading && state.contactRequests.isEmpty()) item { EmptyCard("Keine Kontaktanfragen gefunden.") } + items(state.contactRequests.size) { index -> ContactRequestCard(state.contactRequests[index]) } + } +} + +@Composable +fun CmsNewsletterScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsState() + CmsPage(navController, showBackNavigation, "Newsletter", "Newsletter und Gruppen") { + if (state.loading) item { CircularProgressIndicator(color = Primary600) } + item { SectionTitle("Newsletter") } + if (!state.loading && state.newsletters.isEmpty()) item { EmptyCard("Keine Newsletter gefunden.") } + items(state.newsletters.size) { index -> NewsletterCard(state.newsletters[index]) } + item { SectionTitle("Gruppen") } + if (!state.loading && state.newsletterGroups.isEmpty()) item { EmptyCard("Keine Gruppen gefunden.") } + items(state.newsletterGroups.size) { index -> NewsletterGroupCard(state.newsletterGroups[index]) } + } +} + +@Composable +fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsState() + CmsConfigPage(navController, showBackNavigation, "Einstellungen", "Konfiguration und Systemstatus", state.config) { config -> + InfoRow("Vorstand", listOf(config.vorstand.vorsitzender, config.vorstand.stellvertreter, config.vorstand.kassenwart).count { it.email.isNotBlank() }.toString()) + InfoRow("Trainer", config.trainer.size.toString()) + InfoRow("Trainingsort", config.training.ort.name.ifBlank { "Nicht gesetzt" }) + } +} + +@Composable +fun CmsBenutzerScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsState() + CmsUserListPage(navController, showBackNavigation, "Benutzer", state.users) +} + +@Composable +fun CmsPasswordResetDiagnosticsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsState() + CmsPage(navController, showBackNavigation, "Passwort-Reset-Diagnose", "Fehlgeschlagene Reset-Versuche") { + if (state.loading) item { CircularProgressIndicator(color = Primary600) } + if (!state.loading && state.passwordResetAttempts.isEmpty()) item { EmptyCard("Keine Diagnoseeinträge gefunden.") } + items(state.passwordResetAttempts.size) { index -> PasswordAttemptCard(state.passwordResetAttempts[index]) } + } +} + +@Composable +private fun CmsSummaryGrid(navController: NavController, state: CmsUiState) { + val cards = listOf( + Triple("Startseite", "Öffentliche Startseite", Destinations.CmsStartseite.route), + Triple("Inhalte", "Vereinsseiten", Destinations.CmsInhalte.route), + Triple("Sportbetrieb", "Training und Sportdaten", Destinations.CmsSportbetrieb.route), + Triple("Mitgliederverwaltung", "${state.users.size} Benutzer", Destinations.CmsMitgliederverwaltung.route), + Triple("Kontaktanfragen", "${state.contactRequests.size} Anfragen", Destinations.CmsContactRequests.route), + Triple("Newsletter", "${state.newsletters.size} Newsletter", Destinations.CmsNewsletter.route), + Triple("Einstellungen", "Systemkonfiguration", Destinations.CmsEinstellungen.route), + Triple("Benutzer", "Rollen und Zugänge", Destinations.CmsBenutzer.route), + Triple("Vereinsmeisterschaften", "Ergebnisdaten", Destinations.CmsVereinsmeisterschaften.route), + ) + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + cards.forEach { (title, subtitle, route) -> + Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp, modifier = Modifier.fillMaxWidth().clickable { navController.navigate(route) }) { + Column(Modifier.fillMaxWidth().padding(18.dp)) { + Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900) + Text(subtitle, color = Accent500, modifier = Modifier.padding(top = 4.dp)) + } + } + } + } +} + +@Composable +private fun CmsPage( + navController: NavController, + showBackNavigation: Boolean, + title: String, + subtitle: String, + content: androidx.compose.foundation.lazy.LazyListScope.() -> Unit, +) { + 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("< CMS", color = Primary600, fontWeight = FontWeight.SemiBold) + } + } + Text(title, style = MaterialTheme.typography.displayLarge, color = Accent900) + Text(subtitle, color = Accent500, modifier = Modifier.padding(top = 8.dp)) + } + content() + } +} + +@Composable +private fun CmsConfigPage( + navController: NavController, + showBackNavigation: Boolean, + title: String, + subtitle: String, + config: ConfigResponse?, + content: @Composable (ConfigResponse) -> Unit, +) { + CmsPage(navController, showBackNavigation, title, subtitle) { + if (config == null) { + 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(10.dp)) { + content(config) + } + } + } + } + } +} + +@Composable +private fun CmsUserListPage(navController: NavController, showBackNavigation: Boolean, title: String, users: List) { + CmsPage(navController, showBackNavigation, title, "Registrierte Zugänge und Rollen") { + if (users.isEmpty()) item { EmptyCard("Keine Benutzer gefunden oder keine Berechtigung.") } + items(users.size) { index -> UserCard(users[index]) } + } +} + +@Composable +private fun UserCard(user: CmsUserDto) { + DataCard(user.name.ifBlank { user.email.orEmpty() }) { + InfoRow("E-Mail", user.email ?: "-") + InfoRow("Rollen", user.roles.ifEmpty { listOfNotNull(user.role) }.joinToString(", ").ifBlank { "mitglied" }) + InfoRow("Status", if (user.active) "Aktiv" else "Inaktiv") + InfoRow("Letzter Login", user.lastLogin ?: "-") + } +} + +@Composable +private fun ContactRequestCard(request: ContactRequestDto) { + DataCard(request.name.ifBlank { request.email }) { + InfoRow("E-Mail", request.email) + InfoRow("Status", request.status.ifBlank { "offen" }) + InfoRow("Nachricht", request.message) + } +} + +@Composable +private fun NewsletterCard(newsletter: NewsletterDto) { + DataCard(newsletter.subject.ifBlank { newsletter.title.ifBlank { newsletter.id } }) { + InfoRow("Status", newsletter.status ?: if (newsletter.sentAt != null) "versendet" else "Entwurf") + InfoRow("Erstellt", newsletter.createdAt ?: "-") + InfoRow("Versendet", newsletter.sentAt ?: "-") + } +} + +@Composable +private fun NewsletterGroupCard(group: NewsletterGroupDto) { + DataCard(group.name.ifBlank { group.id }) { + InfoRow("Beschreibung", group.description.ifBlank { "-" }) + InfoRow("Abonnenten", group.subscribers.size.toString()) + } +} + +@Composable +private fun PasswordAttemptCard(attempt: PasswordResetAttemptDto) { + DataCard(attempt.emailMasked ?: attempt.requestId) { + InfoRow("Gestartet", attempt.startedAt ?: "-") + InfoRow("Status", if (attempt.failed) "Fehler" else "OK") + InfoRow("Schritte", attempt.steps.size.toString()) + } +} + +@Composable +private fun DataCard(title: String, content: @Composable ColumnScope.() -> Unit) { + Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) { + Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900) + content() + } + } +} + +@Composable +private fun InfoRow(label: String, value: String) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(label, color = Accent500, modifier = Modifier.weight(0.8f)) + Text(value, color = Accent900, modifier = Modifier.weight(1.2f)) + } +} + +@Composable +private fun SectionTitle(title: String) { + Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900) +} + +@Composable +private fun EmptyCard(message: String) { + Surface(color = Accent100, shape = RoundedCornerShape(12.dp)) { + Text(message, color = Accent700, modifier = Modifier.fillMaxWidth().padding(16.dp)) + } +} + +private fun textState(value: String): String = if (value.isBlank()) "Nicht gesetzt" else "${value.length} Zeichen" diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt new file mode 100644 index 0000000..4e4a5ab --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt @@ -0,0 +1,62 @@ +package de.harheimertc.ui.screens.cms + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import de.harheimertc.data.CmsUserDto +import de.harheimertc.data.ConfigResponse +import de.harheimertc.data.ContactRequestDto +import de.harheimertc.data.NewsletterDto +import de.harheimertc.data.NewsletterGroupDto +import de.harheimertc.data.PasswordResetAttemptDto +import de.harheimertc.repositories.CmsRepository +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class CmsUiState( + val loading: Boolean = true, + val error: String? = null, + val config: ConfigResponse? = null, + val users: List = emptyList(), + val contactRequests: List = emptyList(), + val newsletters: List = emptyList(), + val newsletterGroups: List = emptyList(), + val passwordResetAttempts: List = emptyList(), +) + +@HiltViewModel +class CmsViewModel @Inject constructor( + private val repository: CmsRepository, +) : ViewModel() { + private val _state = MutableStateFlow(CmsUiState()) + val state: StateFlow = _state + + init { + load() + } + + fun load() { + viewModelScope.launch { + _state.value = _state.value.copy(loading = true, error = null) + val config = async { repository.config().getOrNull() } + val users = async { repository.users().getOrNull()?.users.orEmpty() } + val requests = async { repository.contactRequests().getOrNull().orEmpty() } + val newsletters = async { repository.newsletters().getOrNull()?.newsletters.orEmpty() } + val groups = async { repository.newsletterGroups().getOrNull()?.groups.orEmpty() } + val diagnostics = async { repository.passwordResetDiagnostics().getOrNull()?.attempts.orEmpty() } + + _state.value = CmsUiState( + loading = false, + config = config.await(), + users = users.await(), + contactRequests = requests.await(), + newsletters = newsletters.await(), + newsletterGroups = groups.await(), + passwordResetAttempts = diagnostics.await(), + ) + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/login/LoginScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/login/LoginScreen.kt index b0c4439..63f6a6b 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/login/LoginScreen.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/login/LoginScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -46,6 +47,17 @@ fun LoginScreen( ) { val state by viewModel.state.collectAsState() + LaunchedEffect(state.loggedIn, state.restoring) { + if (state.loggedIn && !state.restoring) { + navController.navigate(Destinations.MemberArea.route) { + launchSingleTop = true + popUpTo(Destinations.Login.route) { + inclusive = true + } + } + } + } + LazyColumn( modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)), contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp), diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailScreens.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailScreens.kt new file mode 100644 index 0000000..2a29df5 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailScreens.kt @@ -0,0 +1,202 @@ +package de.harheimertc.ui.screens.memberarea + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import de.harheimertc.data.MemberDto +import de.harheimertc.data.NewsDto +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 MembersScreen( + navController: NavController, + showBackNavigation: Boolean, + viewModel: MembersViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState() + val query = state.query.trim() + val members = state.members + .filter { member -> + query.isBlank() || + member.name.contains(query, ignoreCase = true) || + member.email.orEmpty().contains(query, ignoreCase = true) + } + .sortedWith(compareBy { it.lastName.ifBlank { it.name } }.thenBy { it.firstName }) + + MemberAreaPage(navController, showBackNavigation, "Mitgliederliste", "Kontaktdaten der Vereinsmitglieder") { + item { + OutlinedTextField( + value = state.query, + onValueChange = viewModel::updateQuery, + label = { Text("Suchen") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + when { + state.loading -> item { CircularProgressIndicator(color = Primary600) } + state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) } + members.isEmpty() -> item { Text("Keine Mitglieder gefunden.", color = Accent700) } + else -> items(members.size) { index -> MemberCard(members[index]) } + } + } +} + +@Composable +fun MemberNewsScreen( + navController: NavController, + showBackNavigation: Boolean, + viewModel: MemberNewsViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState() + MemberAreaPage(navController, showBackNavigation, "News", "Neuigkeiten und Ankündigungen im Mitgliederbereich") { + when { + state.loading -> item { CircularProgressIndicator(color = Primary600) } + state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) } + state.news.isEmpty() -> item { Text("Noch keine News vorhanden.", color = Accent700) } + else -> items(state.news.size) { index -> NewsCard(state.news[index]) } + } + } +} + +@Composable +fun MemberApiScreen(navController: NavController, showBackNavigation: Boolean) { + val groups = listOf( + "Authentifizierung" to listOf("POST /api/auth/login", "POST /api/auth/refresh", "GET /api/auth/status", "POST /api/auth/logout"), + "Mitgliederbereich" to listOf("GET /api/members", "GET /api/news", "GET /api/profile", "PUT /api/profile"), + "CMS" to listOf("GET /api/cms/users/list", "GET /api/cms/contact-requests", "GET /api/newsletter/list", "GET /api/config"), + ) + MemberAreaPage(navController, showBackNavigation, "API-Dokumentation", "Kurzüberblick der wichtigsten App-Endpunkte") { + item { + Surface(color = Primary100, shape = RoundedCornerShape(12.dp)) { + Text( + "Android nutzt Authorization: Bearer . Abgelaufene Tokens werden über /api/auth/refresh automatisch erneuert.", + color = Primary900, + modifier = Modifier.fillMaxWidth().padding(16.dp), + ) + } + } + items(groups.size) { index -> + val (title, endpoints) = groups[index] + Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) { + Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900) + endpoints.forEach { endpoint -> + Surface(color = Accent100, shape = RoundedCornerShape(8.dp)) { + Text(endpoint, color = Accent900, modifier = Modifier.fillMaxWidth().padding(10.dp)) + } + } + } + } + } + } +} + +@Composable +private fun MemberAreaPage( + navController: NavController, + showBackNavigation: Boolean, + title: String, + subtitle: String, + content: androidx.compose.foundation.lazy.LazyListScope.() -> Unit, +) { + 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("< Intern", color = Primary600, fontWeight = FontWeight.SemiBold) + } + } + Text(title, style = MaterialTheme.typography.displayLarge, color = Accent900) + Text(subtitle, color = Accent500, modifier = Modifier.padding(top = 8.dp)) + } + content() + } +} + +@Composable +private fun MemberCard(member: MemberDto) { + Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) { + Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(7.dp)) { + Text(member.name.ifBlank { "${member.firstName} ${member.lastName}".trim() }, style = MaterialTheme.typography.titleLarge, color = Accent900) + if (!member.email.isNullOrBlank()) Text(member.email, color = Primary600) + if (!member.phone.isNullOrBlank()) Text(member.phone, color = Accent700) + if (!member.birthday.isNullOrBlank()) Text("Geburtstag: ${member.birthday}", color = Accent500) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Badge(if (member.hasLogin) "Login" else member.source.ifBlank { "Mitglied" }) + if (member.isMannschaftsspieler) Badge("Mannschaft") + if (member.hasHallKey) Badge("Hallenschlüssel") + } + if (member.email.isNullOrBlank() && member.phone.isNullOrBlank()) { + Text("Kontaktdaten sind für dich nicht freigegeben.", color = Accent500) + } + } + } +} + +@Composable +private fun NewsCard(item: NewsDto) { + Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) { + Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(item.title, style = MaterialTheme.typography.titleLarge, color = Accent900) + Text(listOfNotNull(item.author, item.created).joinToString(" | "), color = Accent500) + if (item.isPublic || item.isHidden) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (item.isPublic) Badge("Öffentlich") + if (item.isHidden) Badge("Ausgeblendet") + } + } + Text(item.content, color = Accent700) + } + } +} + +@Composable +private fun Badge(label: String) { + Surface(color = Primary100, shape = RoundedCornerShape(20.dp)) { + Text(label, color = Primary600, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 9.dp, vertical = 5.dp)) + } +} + +@Composable +private fun ErrorCard(message: String, onRetry: () -> Unit) { + Surface(color = Color(0xFFFEE2E2), shape = RoundedCornerShape(12.dp)) { + Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(message, color = Color(0xFF991B1B)) + Button(onClick = onRetry) { Text("Erneut laden") } + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailViewModels.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailViewModels.kt new file mode 100644 index 0000000..1a24309 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailViewModels.kt @@ -0,0 +1,71 @@ +package de.harheimertc.ui.screens.memberarea + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import de.harheimertc.data.MemberDto +import de.harheimertc.data.NewsDto +import de.harheimertc.repositories.MemberAreaRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class MembersUiState( + val members: List = emptyList(), + val loading: Boolean = true, + val error: String? = null, + val query: String = "", +) + +@HiltViewModel +class MembersViewModel @Inject constructor( + private val repository: MemberAreaRepository, +) : ViewModel() { + private val _state = MutableStateFlow(MembersUiState()) + val state: StateFlow = _state + + init { + load() + } + + fun updateQuery(query: String) { + _state.value = _state.value.copy(query = query) + } + + fun load() { + viewModelScope.launch { + _state.value = _state.value.copy(loading = true, error = null) + repository.members() + .onSuccess { response -> _state.value = _state.value.copy(members = response.members, loading = false) } + .onFailure { _state.value = _state.value.copy(loading = false, error = it.message ?: "Mitglieder konnten nicht geladen werden.") } + } + } +} + +data class MemberNewsUiState( + val news: List = emptyList(), + val loading: Boolean = true, + val error: String? = null, +) + +@HiltViewModel +class MemberNewsViewModel @Inject constructor( + private val repository: MemberAreaRepository, +) : ViewModel() { + private val _state = MutableStateFlow(MemberNewsUiState()) + val state: StateFlow = _state + + init { + load() + } + + fun load() { + viewModelScope.launch { + _state.value = _state.value.copy(loading = true, error = null) + repository.news() + .onSuccess { response -> _state.value = _state.value.copy(news = response.news, loading = false) } + .onFailure { _state.value = _state.value.copy(loading = false, error = it.message ?: "News konnten nicht geladen werden.") } + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaScreen.kt new file mode 100644 index 0000000..97063f0 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaScreen.kt @@ -0,0 +1,190 @@ +package de.harheimertc.ui.screens.memberarea + +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.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.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +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.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import de.harheimertc.data.BirthdayDto +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 + +@Composable +fun MemberAreaScreen( + navController: NavController, + showBackNavigation: Boolean, + viewModel: MemberAreaViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState() + + 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("Mitgliederbereich", style = MaterialTheme.typography.displayLarge, color = Accent900) + Text("Alles Wichtige für Vereinsmitglieder.", color = Accent500, modifier = Modifier.padding(top = 8.dp)) + } + + item { + MemberAreaCardGrid(navController) + } + + item { + BirthdayCard( + birthdays = state.birthdays, + loading = state.loadingBirthdays, + error = state.birthdayError, + onRetry = viewModel::loadBirthdays, + ) + } + } +} + +@Composable +private fun MemberAreaCardGrid(navController: NavController) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + MemberAreaCard( + title = "Mein Profil", + description = "Persönliche Daten und Passwort verwalten", + marker = "P", + onClick = { navController.navigate(Destinations.Profile.route) }, + ) + MemberAreaCard( + title = "Mitglieder", + description = "Kontaktdaten der Vereinsmitglieder", + marker = "M", + onClick = { navController.navigate(Destinations.Members.route) }, + ) + MemberAreaCard( + title = "News", + description = "Neuigkeiten und Ankündigungen", + marker = "N", + onClick = { navController.navigate(Destinations.MemberNews.route) }, + ) + } +} + +@Composable +private fun MemberAreaCard(title: String, description: String, marker: String, onClick: () -> Unit) { + Surface( + color = Color.White, + shape = RoundedCornerShape(14.dp), + shadowElevation = 3.dp, + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(18.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface(color = Primary100, shape = RoundedCornerShape(10.dp), modifier = Modifier.size(48.dp)) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(marker, color = Primary600, fontWeight = FontWeight.Bold) + } + } + Column(modifier = Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900) + Text(description, color = Accent700, modifier = Modifier.padding(top = 4.dp)) + } + } + } +} + +@Composable +private fun BirthdayCard( + birthdays: List, + loading: Boolean, + error: String?, + onRetry: () -> Unit, +) { + Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) { + Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically) { + Surface(color = Color(0xFFFCE7F3), shape = RoundedCornerShape(10.dp), modifier = Modifier.size(48.dp)) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text("G", color = Color(0xFFDB2777), fontWeight = FontWeight.Bold) + } + } + Text("Geburtstage (nächste 4 Wochen)", style = MaterialTheme.typography.titleLarge, color = Accent900) + } + + when { + loading -> { + CircularProgressIndicator(color = Primary600, modifier = Modifier.size(28.dp)) + Text("Lade...", color = Accent500) + } + error != null -> { + Text(error, color = MaterialTheme.colorScheme.error) + TextButton(onClick = onRetry) { Text("Erneut laden") } + } + birthdays.isEmpty() -> Text("Keine Geburtstage in den nächsten 4 Wochen.", color = Accent700) + else -> birthdays.forEach { birthday -> BirthdayRow(birthday) } + } + } + } +} + +@Composable +private fun BirthdayRow(birthday: BirthdayDto) { + Surface(color = Accent100, shape = RoundedCornerShape(9.dp)) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(birthday.name, color = Accent900, fontWeight = FontWeight.SemiBold) + Text(birthday.dayMonth, color = Accent500, style = MaterialTheme.typography.labelSmall) + } + Text(relativeBirthdayLabel(birthday.inDays), color = Accent500) + } + } +} + +private fun relativeBirthdayLabel(inDays: Int): String = when (inDays) { + 0 -> "Heute" + 1 -> "Morgen" + else -> "in $inDays Tagen" +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaViewModel.kt new file mode 100644 index 0000000..26d4e8e --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaViewModel.kt @@ -0,0 +1,48 @@ +package de.harheimertc.ui.screens.memberarea + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import de.harheimertc.data.BirthdayDto +import de.harheimertc.repositories.MemberAreaRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class MemberAreaUiState( + val birthdays: List = emptyList(), + val loadingBirthdays: Boolean = true, + val birthdayError: String? = null, +) + +@HiltViewModel +class MemberAreaViewModel @Inject constructor( + private val repository: MemberAreaRepository, +) : ViewModel() { + private val _state = MutableStateFlow(MemberAreaUiState()) + val state: StateFlow = _state + + init { + loadBirthdays() + } + + fun loadBirthdays() { + viewModelScope.launch { + _state.value = _state.value.copy(loadingBirthdays = true, birthdayError = null) + repository.birthdays() + .onSuccess { response -> + _state.value = _state.value.copy( + birthdays = response.birthdays, + loadingBirthdays = false, + ) + } + .onFailure { + _state.value = _state.value.copy( + loadingBirthdays = false, + birthdayError = it.message ?: "Geburtstage konnten nicht geladen werden.", + ) + } + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/profile/ProfileScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/profile/ProfileScreen.kt new file mode 100644 index 0000000..c307d83 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/profile/ProfileScreen.kt @@ -0,0 +1,182 @@ +package de.harheimertc.ui.screens.profile + +import androidx.compose.foundation.background +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.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import de.harheimertc.ui.theme.Accent500 +import de.harheimertc.ui.theme.Accent900 +import de.harheimertc.ui.theme.Primary600 + +@Composable +fun ProfileScreen( + navController: NavController, + showBackNavigation: Boolean, + viewModel: ProfileViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState() + val form = state.form + + LazyColumn( + modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + if (showBackNavigation) { + TextButton(onClick = { navController.popBackStack() }) { + Text("< Intern", color = Primary600, fontWeight = FontWeight.SemiBold) + } + } + Text("Mein Profil", style = MaterialTheme.typography.displayLarge, color = Accent900) + Text("Kontaktdaten, Sichtbarkeit und Passwort verwalten.", color = Accent500, modifier = Modifier.padding(top = 8.dp)) + } + + if (state.loading) { + item { + Column(Modifier.fillMaxWidth().padding(28.dp), horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator(color = Primary600) + } + } + } else { + item { + ProfileCard("Persönliche Daten") { + OutlinedTextField( + value = form.name, + onValueChange = { viewModel.update(form.copy(name = it)) }, + label = { Text("Name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = form.email, + onValueChange = { viewModel.update(form.copy(email = it)) }, + label = { Text("E-Mail") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = form.phone, + onValueChange = { viewModel.update(form.copy(phone = it)) }, + label = { Text("Telefon") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = form.birthDate, + onValueChange = { viewModel.update(form.copy(birthDate = it)) }, + label = { Text("Geburtsdatum (JJJJ-MM-TT)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + } + + item { + ProfileCard("Sichtbarkeit im Mitgliederbereich") { + VisibilityRow("E-Mail anzeigen", form.showEmail) { viewModel.update(form.copy(showEmail = it)) } + VisibilityRow("Telefon anzeigen", form.showPhone) { viewModel.update(form.copy(showPhone = it)) } + VisibilityRow("Adresse anzeigen", form.showAddress) { viewModel.update(form.copy(showAddress = it)) } + VisibilityRow("Geburtstag anzeigen", form.showBirthday) { viewModel.update(form.copy(showBirthday = it)) } + } + } + + item { + ProfileCard("Passwort ändern") { + OutlinedTextField( + value = form.currentPassword, + onValueChange = { viewModel.update(form.copy(currentPassword = it)) }, + label = { Text("Aktuelles Passwort") }, + visualTransformation = PasswordVisualTransformation(), + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = form.newPassword, + onValueChange = { viewModel.update(form.copy(newPassword = it)) }, + label = { Text("Neues Passwort") }, + visualTransformation = PasswordVisualTransformation(), + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = form.confirmPassword, + onValueChange = { viewModel.update(form.copy(confirmPassword = it)) }, + label = { Text("Neues Passwort wiederholen") }, + visualTransformation = PasswordVisualTransformation(), + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Text("Leer lassen, wenn das Passwort unverändert bleiben soll.", color = Accent500) + } + } + + item { + Button( + onClick = viewModel::save, + enabled = !state.saving, + modifier = Modifier.fillMaxWidth(), + ) { + if (state.saving) CircularProgressIndicator(color = Color.White, strokeWidth = 2.dp, modifier = Modifier.size(18.dp).padding(end = 8.dp)) + Text(if (state.saving) "Speichert..." else "Profil speichern") + } + } + } + + state.error?.let { message -> + item { Text(message, color = MaterialTheme.colorScheme.error) } + } + state.message?.let { message -> + item { Text(message, color = Color(0xFF166534)) } + } + } +} + +@Composable +private fun ProfileCard(title: String, content: @Composable ColumnScope.() -> Unit) { + Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 2.dp) { + Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900) + content() + } + } +} + +@Composable +private fun VisibilityRow(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Checkbox(checked = checked, onCheckedChange = onCheckedChange) + Text(label, color = Accent900) + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/profile/ProfileViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/profile/ProfileViewModel.kt new file mode 100644 index 0000000..45bf9ad --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/profile/ProfileViewModel.kt @@ -0,0 +1,152 @@ +package de.harheimertc.ui.screens.profile + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import de.harheimertc.data.ProfileUpdateRequest +import de.harheimertc.data.ProfileVisibilityDto +import de.harheimertc.repositories.ProfileRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class ProfileFormState( + val name: String = "", + val email: String = "", + val phone: String = "", + val birthDate: String = "", + val showEmail: Boolean = true, + val showPhone: Boolean = true, + val showAddress: Boolean = false, + val showBirthday: Boolean = true, + val currentPassword: String = "", + val newPassword: String = "", + val confirmPassword: String = "", +) + +data class ProfileUiState( + val form: ProfileFormState = ProfileFormState(), + val loading: Boolean = true, + val saving: Boolean = false, + val error: String? = null, + val message: String? = null, +) + +@HiltViewModel +class ProfileViewModel @Inject constructor( + private val repository: ProfileRepository, +) : ViewModel() { + private val _state = MutableStateFlow(ProfileUiState()) + val state: StateFlow = _state + + init { + load() + } + + fun load() { + viewModelScope.launch { + _state.value = _state.value.copy(loading = true, error = null, message = null) + repository.load() + .onSuccess { response -> + val user = response.user + val visibility = user?.visibility ?: ProfileVisibilityDto() + _state.value = ProfileUiState( + loading = false, + form = ProfileFormState( + name = user?.name.orEmpty(), + email = user?.email.orEmpty(), + phone = user?.phone.orEmpty(), + birthDate = user?.geburtsdatum.orEmpty(), + showEmail = visibility.showEmail, + showPhone = visibility.showPhone, + showAddress = visibility.showAddress, + showBirthday = visibility.showBirthday, + ), + ) + } + .onFailure { + _state.value = _state.value.copy( + loading = false, + error = it.message ?: "Profil konnte nicht geladen werden.", + ) + } + } + } + + fun update(form: ProfileFormState) { + _state.value = _state.value.copy(form = form, error = null, message = null) + } + + fun save() { + val form = _state.value.form + val validationError = when { + form.name.isBlank() || form.email.isBlank() -> "Name und E-Mail sind erforderlich." + !form.email.contains("@") -> "Bitte eine gültige E-Mail-Adresse eingeben." + form.currentPassword.isNotBlank() || form.newPassword.isNotBlank() || form.confirmPassword.isNotBlank() -> { + when { + form.currentPassword.isBlank() -> "Bitte geben Sie Ihr aktuelles Passwort ein." + form.newPassword.isBlank() -> "Bitte geben Sie ein neues Passwort ein." + form.newPassword != form.confirmPassword -> "Die neuen Passwörter stimmen nicht überein." + form.newPassword.length < 6 -> "Das neue Passwort muss mindestens 6 Zeichen lang sein." + else -> null + } + } + else -> null + } + if (validationError != null) { + _state.value = _state.value.copy(error = validationError, message = null) + return + } + + viewModelScope.launch { + _state.value = _state.value.copy(saving = true, error = null, message = null) + repository.save( + ProfileUpdateRequest( + name = form.name.trim(), + email = form.email.trim(), + phone = form.phone.trim().takeIf(String::isNotBlank), + geburtsdatum = form.birthDate.trim().takeIf(String::isNotBlank), + visibility = ProfileVisibilityDto( + showEmail = form.showEmail, + showPhone = form.showPhone, + showAddress = form.showAddress, + showBirthday = form.showBirthday, + ), + currentPassword = form.currentPassword.takeIf(String::isNotBlank), + newPassword = form.newPassword.takeIf(String::isNotBlank), + ), + ).onSuccess { response -> + val next = response.user + val visibility = next?.visibility ?: ProfileVisibilityDto( + form.showEmail, + form.showPhone, + form.showAddress, + form.showBirthday, + ) + _state.value = ProfileUiState( + loading = false, + message = response.message ?: "Profil erfolgreich aktualisiert.", + form = form.copy( + name = next?.name ?: form.name.trim(), + email = next?.email ?: form.email.trim(), + phone = next?.phone ?: form.phone.trim(), + birthDate = next?.geburtsdatum ?: form.birthDate.trim(), + showEmail = visibility.showEmail, + showPhone = visibility.showPhone, + showAddress = visibility.showAddress, + showBirthday = visibility.showBirthday, + currentPassword = "", + newPassword = "", + confirmPassword = "", + ), + ) + }.onFailure { + _state.value = _state.value.copy( + saving = false, + error = it.message ?: "Profil konnte nicht gespeichert werden.", + ) + } + } + } +} diff --git a/components/cms/CmsMannschaften.vue b/components/cms/CmsMannschaften.vue index 4009705..6c81935 100644 --- a/components/cms/CmsMannschaften.vue +++ b/components/cms/CmsMannschaften.vue @@ -117,6 +117,22 @@ + +