feat: Add CMS and Member Area screens with ViewModels
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m23s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m18s

- 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:
Torsten Schulz (local)
2026-05-28 08:01:35 +02:00
parent e195d5d189
commit e033d716dd
34 changed files with 1809 additions and 72 deletions

View File

@@ -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>
}

View File

@@ -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.")
}
}

View File

@@ -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.")
}
}

View File

@@ -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.")
}
}

View File

@@ -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")
}
}

View File

@@ -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()
}

View File

@@ -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")
}

View File

@@ -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)
}
}
}

View File

@@ -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"

View File

@@ -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(),
)
}
}
}

View File

@@ -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),

View File

@@ -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") }
}
}
}

View File

@@ -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.") }
}
}
}

View File

@@ -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"
}

View File

@@ -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.",
)
}
}
}
}

View File

@@ -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)
}
}

View File

@@ -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.",
)
}
}
}
}