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.
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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<NewsDto> = 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<String> = 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<BirthdayDto> = 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<String> = emptyList(),
|
||||
)
|
||||
data class MembersResponse(
|
||||
val success: Boolean = false,
|
||||
val members: List<MemberDto> = 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<TrainerDto> = 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<String> = 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<CmsUserDto> = 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<NewsletterDto> = emptyList(),
|
||||
)
|
||||
data class NewsletterGroupDto(
|
||||
val id: String = "",
|
||||
val name: String = "",
|
||||
val description: String = "",
|
||||
val subscribers: List<String> = emptyList(),
|
||||
val createdAt: String? = null,
|
||||
)
|
||||
data class NewsletterGroupsResponse(
|
||||
val success: Boolean = false,
|
||||
val groups: List<NewsletterGroupDto> = 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<PasswordResetStepDto> = emptyList(),
|
||||
)
|
||||
data class PasswordResetDiagnosticsResponse(
|
||||
val retentionHours: Int = 0,
|
||||
val attempts: List<PasswordResetAttemptDto> = emptyList(),
|
||||
)
|
||||
|
||||
interface ApiService {
|
||||
@POST("/api/contact")
|
||||
@@ -243,6 +409,15 @@ interface ApiService {
|
||||
@GET("/api/news-public")
|
||||
suspend fun publicNews(): Response<NewsPublicResponse>
|
||||
|
||||
@GET("/api/news")
|
||||
suspend fun memberNews(): Response<NewsResponse>
|
||||
|
||||
@POST("/api/news")
|
||||
suspend fun saveNews(@Body request: NewsSaveRequest): Response<AuthMessageResponse>
|
||||
|
||||
@DELETE("/api/news")
|
||||
suspend fun deleteNews(@Query("id") id: Int): Response<AuthMessageResponse>
|
||||
|
||||
@GET("/api/mannschaften")
|
||||
suspend fun mannschaften(@Query("season") season: String? = null): Response<ResponseBody>
|
||||
|
||||
@@ -279,4 +454,43 @@ interface ApiService {
|
||||
|
||||
@POST("/api/auth/register")
|
||||
suspend fun register(@Body request: RegistrationRequest): Response<AuthMessageResponse>
|
||||
|
||||
@GET("/api/profile")
|
||||
suspend fun profile(): Response<ProfileResponse>
|
||||
|
||||
@retrofit2.http.PUT("/api/profile")
|
||||
suspend fun updateProfile(@Body request: ProfileUpdateRequest): Response<ProfileResponse>
|
||||
|
||||
@GET("/api/birthdays")
|
||||
suspend fun birthdays(): Response<BirthdaysResponse>
|
||||
|
||||
@GET("/api/members")
|
||||
suspend fun members(): Response<MembersResponse>
|
||||
|
||||
@GET("/api/cms/users/list")
|
||||
suspend fun cmsUsers(): Response<CmsUsersResponse>
|
||||
|
||||
@GET("/api/cms/contact-requests")
|
||||
suspend fun contactRequests(): Response<List<ContactRequestDto>>
|
||||
|
||||
@GET("/api/newsletter/list")
|
||||
suspend fun newsletters(): Response<NewsletterListResponse>
|
||||
|
||||
@GET("/api/newsletter/groups/list")
|
||||
suspend fun newsletterGroups(): Response<NewsletterGroupsResponse>
|
||||
|
||||
@GET("/api/newsletter/groups/public-list")
|
||||
suspend fun publicNewsletterGroups(): Response<NewsletterGroupsResponse>
|
||||
|
||||
@POST("/api/newsletter/subscribe")
|
||||
suspend fun subscribeNewsletter(@Body request: NewsletterSubscriptionRequest): Response<AuthMessageResponse>
|
||||
|
||||
@POST("/api/newsletter/unsubscribe-by-email")
|
||||
suspend fun unsubscribeNewsletter(@Body request: NewsletterSubscriptionRequest): Response<AuthMessageResponse>
|
||||
|
||||
@GET("/api/newsletter/confirm")
|
||||
suspend fun confirmNewsletter(@Query("token") token: String): Response<AuthMessageResponse>
|
||||
|
||||
@GET("/api/cms/password-reset-diagnostics")
|
||||
suspend fun passwordResetDiagnostics(): Response<PasswordResetDiagnosticsResponse>
|
||||
}
|
||||
|
||||
@@ -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<ConfigResponse> = 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<CmsUsersResponse> = 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<List<ContactRequestDto>> = runCatching {
|
||||
val response = api.contactRequests()
|
||||
if (!response.isSuccessful) error("Kontaktanfragen konnten nicht geladen werden.")
|
||||
response.body() ?: emptyList()
|
||||
}
|
||||
|
||||
suspend fun newsletters(): Result<NewsletterListResponse> = 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<NewsletterGroupsResponse> = 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<PasswordResetDiagnosticsResponse> = runCatching {
|
||||
val response = api.passwordResetDiagnostics()
|
||||
if (!response.isSuccessful) error("Passwort-Reset-Diagnose konnte nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
}
|
||||
}
|
||||
@@ -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<BirthdaysResponse> = 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<MembersResponse> = 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<NewsResponse> = 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<Unit> = runCatching {
|
||||
val response = api.saveNews(request)
|
||||
if (!response.isSuccessful) error("News konnten nicht gespeichert werden.")
|
||||
}
|
||||
|
||||
suspend fun deleteNews(id: Int): Result<Unit> = runCatching {
|
||||
val response = api.deleteNews(id)
|
||||
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
|
||||
}
|
||||
}
|
||||
@@ -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<NewsletterGroupsResponse> = 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<AuthMessageResponse> = 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<AuthMessageResponse> = 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<AuthMessageResponse> = runCatching {
|
||||
val response = api.confirmNewsletter(token)
|
||||
if (!response.isSuccessful) error("Newsletter-Bestätigung fehlgeschlagen.")
|
||||
response.body() ?: AuthMessageResponse(success = true, message = "Newsletter-Anmeldung bestätigt.")
|
||||
}
|
||||
}
|
||||
@@ -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<ProfileResponse> = 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<ProfileResponse> = runCatching {
|
||||
val response = api.updateProfile(request)
|
||||
if (!response.isSuccessful) error("Profil konnte nicht gespeichert werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
}
|
||||
@@ -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<MenuT
|
||||
add(MenuTarget("News", Destinations.MemberNews.route))
|
||||
add(MenuTarget("Mein Profil", Destinations.Profile.route))
|
||||
add(MenuTarget("API-Dokumentation", Destinations.MemberApi.route))
|
||||
if (state.isAdmin || state.canAccessContactRequests || state.canAccessNewsletter) add(MenuTarget("CMS", Destinations.Cms.route))
|
||||
if (state.isAdmin) add(MenuTarget("Startseite", Destinations.CmsStartseite.route))
|
||||
if (state.isAdmin) add(MenuTarget("Inhalte", Destinations.CmsInhalte.route))
|
||||
if (state.isAdmin) add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route))
|
||||
if (state.isAdmin) add(MenuTarget("Sportbetrieb", Destinations.CmsSportbetrieb.route))
|
||||
if (state.isAdmin) add(MenuTarget("Mitgliederverwaltung", Destinations.CmsMitgliederverwaltung.route))
|
||||
if (state.canAccessNewsletter) add(MenuTarget("Newsletter", Destinations.CmsNewsletter.route))
|
||||
if (state.canAccessContactRequests) add(MenuTarget("Kontaktanfragen", Destinations.CmsContactRequests.route))
|
||||
if (state.isAdmin) add(MenuTarget("CMS", Destinations.Cms.route))
|
||||
if (state.isAdmin) add(MenuTarget("Einstellungen", Destinations.CmsEinstellungen.route))
|
||||
if (state.isAdmin) add(MenuTarget("Benutzer", Destinations.CmsBenutzer.route))
|
||||
if (state.isAdmin) add(MenuTarget("Reset-Diagnose", Destinations.CmsPasswordResetDiagnostics.route))
|
||||
}
|
||||
null -> emptyList()
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<CmsUserDto>) {
|
||||
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"
|
||||
@@ -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<CmsUserDto> = emptyList(),
|
||||
val contactRequests: List<ContactRequestDto> = emptyList(),
|
||||
val newsletters: List<NewsletterDto> = emptyList(),
|
||||
val newsletterGroups: List<NewsletterGroupDto> = emptyList(),
|
||||
val passwordResetAttempts: List<PasswordResetAttemptDto> = emptyList(),
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class CmsViewModel @Inject constructor(
|
||||
private val repository: CmsRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(CmsUiState())
|
||||
val state: StateFlow<CmsUiState> = _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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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<MemberDto> { 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 <Access-Token>. 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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<MemberDto> = 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<MembersUiState> = _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<NewsDto> = 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<MemberNewsUiState> = _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.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<BirthdayDto>,
|
||||
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"
|
||||
}
|
||||
@@ -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<BirthdayDto> = 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<MemberAreaUiState> = _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.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<ProfileUiState> = _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.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,22 @@
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium space-x-3">
|
||||
<button
|
||||
class="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-600 disabled:opacity-50 disabled:cursor-not-allowed mr-1"
|
||||
title="Nach oben"
|
||||
:disabled="isSaving || index === 0"
|
||||
@click="moveMannschaftUp(index)"
|
||||
>
|
||||
<ChevronUp :size="18" />
|
||||
</button>
|
||||
<button
|
||||
class="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-600 disabled:opacity-50 disabled:cursor-not-allowed mr-2"
|
||||
title="Nach unten"
|
||||
:disabled="isSaving || index === mannschaften.length - 1"
|
||||
@click="moveMannschaftDown(index)"
|
||||
>
|
||||
<ChevronDown :size="18" />
|
||||
</button>
|
||||
<button
|
||||
class="text-gray-600 hover:text-gray-900"
|
||||
title="Bearbeiten"
|
||||
@@ -577,6 +593,29 @@ const saveCSV = async () => {
|
||||
})
|
||||
}
|
||||
|
||||
const moveMannschaft = async (index, delta) => {
|
||||
const to = index + delta
|
||||
if (index < 0 || to < 0 || to >= mannschaften.value.length) return
|
||||
isSaving.value = true
|
||||
try {
|
||||
const arr = mannschaften.value
|
||||
const item = arr[index]
|
||||
arr.splice(index, 1)
|
||||
arr.splice(to, 0, item)
|
||||
await saveCSV()
|
||||
await loadMannschaften().catch(() => {})
|
||||
if (window.showSuccessModal) window.showSuccessModal('Erfolg', 'Reihenfolge gespeichert')
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Verschieben der Mannschaft:', err)
|
||||
if (window.showErrorModal) window.showErrorModal('Fehler', 'Reihenfolge konnte nicht gespeichert werden')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const moveMannschaftUp = (index) => moveMannschaft(index, -1)
|
||||
const moveMannschaftDown = (index) => moveMannschaft(index, 1)
|
||||
|
||||
const createNextSeason = async () => {
|
||||
const baseSeason = selectedSeason.value || seasons.value[0]
|
||||
const nextSeason = getNextSeasonSlug(baseSeason)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "harheimertc-website",
|
||||
"version": "1.6.1",
|
||||
"version": "1.6.2",
|
||||
"description": "Moderne Webseite für den Harheimer Tischtennis Club",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -155,10 +155,11 @@
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { AlertCircle, Check, Loader2, Lock } from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const formData = ref({
|
||||
@@ -199,16 +200,15 @@ const handleLogin = async () => {
|
||||
if (response.success) {
|
||||
successMessage.value = 'Anmeldung erfolgreich! Sie werden weitergeleitet...'
|
||||
|
||||
// Redirect based on role
|
||||
// Redirect: prefer `redirect` query (only same-origin paths), otherwise open the member area.
|
||||
setTimeout(() => {
|
||||
const roles = response.user.roles || (response.user.role ? [response.user.role] : [])
|
||||
if (roles.includes('trainer')) {
|
||||
router.push('/cms/kontaktanfragen')
|
||||
} else if (roles.includes('admin') || roles.includes('vorstand') || roles.includes('newsletter')) {
|
||||
router.push('/cms')
|
||||
} else {
|
||||
router.push('/mitgliederbereich')
|
||||
const requested = route.query.redirect
|
||||
if (typeof requested === 'string' && requested.startsWith('/')) {
|
||||
router.push(requested)
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/mitgliederbereich')
|
||||
}, 1000)
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -230,14 +230,13 @@ const handlePasskeyLogin = async () => {
|
||||
successMessage.value = 'Passkey-Anmeldung erfolgreich! Sie werden weitergeleitet...'
|
||||
|
||||
setTimeout(() => {
|
||||
const roles = response.user.roles || (response.user.role ? [response.user.role] : [])
|
||||
if (roles.includes('trainer')) {
|
||||
router.push('/cms/kontaktanfragen')
|
||||
} else if (roles.includes('admin') || roles.includes('vorstand') || roles.includes('newsletter')) {
|
||||
router.push('/cms')
|
||||
} else {
|
||||
router.push('/mitgliederbereich')
|
||||
const requested = route.query.redirect
|
||||
if (typeof requested === 'string' && requested.startsWith('/')) {
|
||||
router.push(requested)
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/mitgliederbereich')
|
||||
}, 1000)
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -256,4 +255,3 @@ useHead({
|
||||
title: 'Login - Harheimer TC',
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { readMembers, normalizeDate } from '../utils/members.js'
|
||||
import { readUsers, migrateUserRoles, getUserFromToken, verifyToken } from '../utils/auth.js'
|
||||
import { readMembers } from '../utils/members.js'
|
||||
import { readUsers, getUserFromToken, verifyToken } from '../utils/auth.js'
|
||||
|
||||
// Helper: returns array of upcoming birthdays within daysAhead (inclusive)
|
||||
function getUpcomingBirthdays(entries, daysAhead = 28) {
|
||||
@@ -41,7 +41,7 @@ function getUpcomingBirthdays(entries, daysAhead = 28) {
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// Determine viewer for visibility rules; token optional
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
let currentUser = null
|
||||
if (token) {
|
||||
const decoded = verifyToken(token)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { getUserFromToken, hasAnyRole } from '../../utils/auth.js'
|
||||
import { readContactRequests } from '../../utils/contact-requests.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
const currentUser = token ? await getUserFromToken(token) : null
|
||||
|
||||
if (!currentUser || !hasAnyRole(currentUser, 'admin', 'vorstand', 'trainer')) {
|
||||
|
||||
@@ -48,7 +48,7 @@ function summarizeAttempts(entries) {
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
const currentUser = token ? await getUserFromToken(token) : null
|
||||
|
||||
if (!currentUser || !hasRole(currentUser, 'admin')) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { getUserFromToken, readUsers, hasAnyRole, hasRole, migrateUserRoles } from '../../../utils/auth.js'
|
||||
import { getUserFromToken, readUsers, hasAnyRole, migrateUserRoles } from '../../../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
const currentUser = await getUserFromToken(token)
|
||||
|
||||
// Seite darf von Admin ODER Vorstand genutzt werden
|
||||
@@ -15,8 +15,6 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const users = await readUsers()
|
||||
|
||||
const isVorstand = hasRole(currentUser, 'vorstand')
|
||||
|
||||
// Nur Admin oder Vorstand duerfen vollen Benutzer-Contact und Rollen sehen.
|
||||
const canSeePrivate = hasAnyRole(currentUser, 'admin', 'vorstand')
|
||||
|
||||
@@ -53,4 +51,3 @@ export default defineEventHandler(async (event) => {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ const getDataPath = (filename) => {
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
|
||||
if (!token) {
|
||||
throw createError({
|
||||
@@ -58,4 +58,3 @@ export default defineEventHandler(async (event) => {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { readUsers, migrateUserRoles } from '../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
|
||||
if (!token) {
|
||||
throw createError({
|
||||
@@ -203,7 +203,6 @@ export default defineEventHandler(async (event) => {
|
||||
mergedMembers.sort((a, b) => a.name.localeCompare(b.name))
|
||||
// Die Mitgliederliste ist nur für authentifizierte Nutzer sichtbar (siehe oben).
|
||||
// Respektiere individuelle Sichtbarkeitspräferenzen (user.visibility)
|
||||
const currentUserToken = token
|
||||
const isViewerAuthenticated = !!currentUser
|
||||
// Only 'vorstand' may override member visibility
|
||||
const isPrivilegedViewer = currentUser ? hasRole(currentUser, 'vorstand') : false
|
||||
@@ -218,16 +217,11 @@ export default defineEventHandler(async (event) => {
|
||||
const showPhone = visibility.showPhone === undefined ? true : Boolean(visibility.showPhone)
|
||||
const showAddress = visibility.showAddress === undefined ? false : Boolean(visibility.showAddress)
|
||||
|
||||
// Determine if contact info existed but was hidden to the viewer
|
||||
const hadEmail = !!member.email
|
||||
const hadPhone = !!member.phone
|
||||
const hadAddress = !!member.address
|
||||
const hadBirthday = !!member.geburtsdatum
|
||||
const emailVisible = (isPrivilegedViewer || (isViewerAuthenticated && showEmail))
|
||||
const phoneVisible = (isPrivilegedViewer || (isViewerAuthenticated && showPhone))
|
||||
const addressVisible = (isPrivilegedViewer || (isViewerAuthenticated && showAddress))
|
||||
const birthdayVisible = (isPrivilegedViewer || (isViewerAuthenticated && (member.visibility && member.visibility.showBirthday !== undefined ? Boolean(member.visibility.showBirthday) : true)))
|
||||
const contactHidden = (!emailVisible && hadEmail) || (!phoneVisible && hadPhone) || (!addressVisible && hadAddress)
|
||||
|
||||
return {
|
||||
id: member.id,
|
||||
@@ -260,7 +254,7 @@ export default defineEventHandler(async (event) => {
|
||||
const day = `${d.getDate()}`.padStart(2, '0')
|
||||
const month = `${d.getMonth()+1}`.padStart(2, '0')
|
||||
return `${day}.${month}`
|
||||
} catch (_e) {
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})() : undefined,
|
||||
@@ -277,4 +271,3 @@ export default defineEventHandler(async (event) => {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { deleteNews } from '../utils/news.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
|
||||
if (!token) {
|
||||
throw createError({
|
||||
@@ -53,4 +53,3 @@ export default defineEventHandler(async (event) => {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { readNews } from '../utils/news.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
|
||||
if (!token) {
|
||||
throw createError({
|
||||
@@ -35,4 +35,3 @@ export default defineEventHandler(async (event) => {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { saveNews } from '../utils/news.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
|
||||
if (!token) {
|
||||
throw createError({
|
||||
@@ -60,4 +60,3 @@ export default defineEventHandler(async (event) => {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { verifyToken, getUserFromToken } from '../utils/auth.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
|
||||
if (!token) {
|
||||
throw createError({ statusCode: 401, message: 'Nicht authentifiziert.' })
|
||||
@@ -27,7 +27,7 @@ export default defineEventHandler(async (event) => {
|
||||
email: user.email,
|
||||
phone: user.phone || '',
|
||||
geburtsdatum: user.geburtsdatum || '',
|
||||
visibility: Object.assign({ showBirthday: true }, (user.visibility || {}))
|
||||
visibility: Object.assign({ showBirthday: true }, (user.visibility || {}))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -35,4 +35,3 @@ export default defineEventHandler(async (event) => {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { verifyToken, readUsers, writeUsers, verifyPassword, hashPassword, migrateUserRoles, revokeRefreshSessionsForUser } from '../utils/auth.js'
|
||||
import { verifyToken, getUserFromToken, readUsers, writeUsers, verifyPassword, hashPassword, migrateUserRoles, revokeRefreshSessionsForUser } from '../utils/auth.js'
|
||||
import { assertPasswordNotPwned } from '../utils/hibp.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
|
||||
if (!token) {
|
||||
throw createError({
|
||||
@@ -21,6 +21,16 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
if (decoded.sid) {
|
||||
const sessionUser = await getUserFromToken(token)
|
||||
if (!sessionUser) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: 'Ungültige Sitzung.'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
const { name, email, phone, geburtsdatum, currentPassword, newPassword } = body
|
||||
|
||||
@@ -31,7 +41,7 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
const users = await readUsers()
|
||||
const users = await readUsers()
|
||||
const userIndex = users.findIndex(u => u.id === decoded.id)
|
||||
|
||||
if (userIndex === -1) {
|
||||
|
||||
@@ -141,6 +141,20 @@ describe('Config & Profil Endpoints', () => {
|
||||
expect(result.user.email).toBe('max@test.de')
|
||||
expect(result.user).not.toHaveProperty('password')
|
||||
})
|
||||
|
||||
it('akzeptiert Bearer-Token für Android-Clients', async () => {
|
||||
const event = createEvent({ headers: { authorization: 'Bearer android-token' } })
|
||||
authUtils.verifyToken.mockReturnValue({ id: '1' })
|
||||
authUtils.getUserFromToken.mockResolvedValue({
|
||||
id: '1', name: 'Android User', email: 'android@test.de', roles: ['mitglied']
|
||||
})
|
||||
|
||||
const result = await profileGetHandler(event)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(authUtils.verifyToken).toHaveBeenCalledWith('android-token')
|
||||
expect(result.user.email).toBe('android@test.de')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /api/profile', () => {
|
||||
@@ -206,6 +220,32 @@ describe('Config & Profil Endpoints', () => {
|
||||
expect(authUtils.revokeRefreshSessionsForUser).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('aktualisiert Profil per Bearer-Token für Android-Clients', async () => {
|
||||
const event = createEvent({ headers: { authorization: 'Bearer android-token' } })
|
||||
authUtils.verifyToken.mockReturnValue({ id: '1' })
|
||||
authUtils.readUsers.mockResolvedValue([
|
||||
{ id: '1', name: 'Alt', email: 'max@test.de', password: 'hash', roles: ['mitglied'] }
|
||||
])
|
||||
authUtils.writeUsers.mockResolvedValue(undefined)
|
||||
authUtils.migrateUserRoles.mockImplementation(u => ({ ...u, roles: u.roles || ['mitglied'] }))
|
||||
mockSuccessReadBody({ name: 'Android Neu', email: 'android@test.de', phone: '0987' })
|
||||
|
||||
const result = await profilePutHandler(event)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(authUtils.verifyToken).toHaveBeenCalledWith('android-token')
|
||||
expect(result.user.name).toBe('Android Neu')
|
||||
})
|
||||
|
||||
it('lehnt widerrufene Android-Sessions beim Profil-Update ab', async () => {
|
||||
const event = createEvent({ headers: { authorization: 'Bearer android-token' } })
|
||||
authUtils.verifyToken.mockReturnValue({ id: '1', sid: 'session-1' })
|
||||
authUtils.getUserFromToken.mockResolvedValue(null)
|
||||
mockSuccessReadBody({ name: 'Android Neu', email: 'android@test.de' })
|
||||
|
||||
await expect(profilePutHandler(event)).rejects.toMatchObject({ statusCode: 401 })
|
||||
})
|
||||
|
||||
it('prüft aktuelles Passwort bei Passwortänderung', async () => {
|
||||
const event = createEvent({ cookies: { auth_token: 'token' } })
|
||||
authUtils.verifyToken.mockReturnValue({ id: '1' })
|
||||
|
||||
@@ -233,6 +233,26 @@ describe('Spielplan, Mannschaften & öffentliche Endpoints', () => {
|
||||
expect(result.birthdays).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('akzeptiert Bearer-Token für Android-Clients', async () => {
|
||||
const event = createEvent({ headers: { authorization: 'Bearer android-token' } })
|
||||
const inDays = 7
|
||||
const targetDate = new Date()
|
||||
targetDate.setDate(targetDate.getDate() + inDays)
|
||||
const geburtsdatum = `${targetDate.getFullYear() - 30}-${String(targetDate.getMonth() + 1).padStart(2, '0')}-${String(targetDate.getDate()).padStart(2, '0')}`
|
||||
|
||||
authUtils.verifyToken.mockReturnValue({ id: 'v1' })
|
||||
authUtils.getUserFromToken.mockResolvedValue({ id: 'v1', roles: ['vorstand'], active: true })
|
||||
memberUtils.readMembers.mockResolvedValue([
|
||||
{ firstName: 'Android', lastName: 'Privat', geburtsdatum, visibility: { showBirthday: false } }
|
||||
])
|
||||
authUtils.readUsers.mockResolvedValue([])
|
||||
|
||||
const result = await birthdaysHandler(event)
|
||||
|
||||
expect(authUtils.verifyToken).toHaveBeenCalledWith('android-token')
|
||||
expect(result.birthdays).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('ignoriert Mitglieder ohne Geburtsdatum', async () => {
|
||||
const event = createEvent()
|
||||
memberUtils.readMembers.mockResolvedValue([
|
||||
|
||||
Reference in New Issue
Block a user