feat: add homepage components and API for settings and spielplan options

- Introduced new Vue components for homepage teasers: HomeLinksTeaser, HomeSpielplanTeamWidget, HomeTrainingTeaser, and HomeVereinsmeisterschaftenTeaser.
- Created XML layout for tablet app window dump.
- Implemented API endpoints for fetching and updating homepage settings.
- Added API for retrieving spielplan options, including team extraction logic.
This commit is contained in:
Torsten Schulz (local)
2026-05-29 15:37:45 +02:00
parent 1ea9596006
commit b8bdbf0a8d
39 changed files with 3867 additions and 163 deletions

View File

@@ -135,6 +135,7 @@ dependencies {
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.5.0")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")
// Hilt testing
androidTestImplementation("com.google.dagger:hilt-android-testing:2.59.2")
// Ensure Hilt runtime is available in the test APK so HiltTestApplication can be instantiated

View File

@@ -1,9 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:name="dagger.hilt.android.testing.HiltTestApplication"
android:allowBackup="false">
<application android:allowBackup="false">
</application>
</manifest>

View File

@@ -41,6 +41,7 @@ object TestHiltModules {
"authStatus" -> Response.success(AuthStatusResponse(isLoggedIn = false))
"publicNews" -> Response.success(de.harheimertc.data.NewsPublicResponse(news = listOf()))
"memberNews" -> Response.success(de.harheimertc.data.NewsResponse(success = true, news = listOf()))
"passwordResetDiagnostics" -> Response.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
else -> throw UnsupportedOperationException("ApiService method not implemented in test double: ${method.name}")
}
}

View File

@@ -0,0 +1,390 @@
package de.harheimertc.ui.screens.cms
import android.content.Context
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.navigation.compose.rememberNavController
import com.squareup.moshi.Moshi
import de.harheimertc.data.ApiService
import de.harheimertc.data.AuthMessageResponse
import de.harheimertc.data.AuthStatusResponse
import de.harheimertc.data.BirthdaysResponse
import de.harheimertc.data.CmsUserDto
import de.harheimertc.data.CmsUsersResponse
import de.harheimertc.data.ConfigResponse
import de.harheimertc.data.ContactRequest
import de.harheimertc.data.ContactRequestDto
import de.harheimertc.data.ContactResponse
import de.harheimertc.data.LoginRequest
import de.harheimertc.data.LoginResponse
import de.harheimertc.data.LogoutRequest
import de.harheimertc.data.MembersResponse
import de.harheimertc.data.MembershipRequest
import de.harheimertc.data.MembershipResponse
import de.harheimertc.data.NewsletterCreateRequest
import de.harheimertc.data.NewsletterCreateResponse
import de.harheimertc.data.NewsletterDto
import de.harheimertc.data.NewsletterGroupDto
import de.harheimertc.data.NewsletterGroupsResponse
import de.harheimertc.data.NewsletterListResponse
import de.harheimertc.data.NewsletterSendResponse
import de.harheimertc.data.NewsletterSubscriptionRequest
import de.harheimertc.data.NewsPublicResponse
import de.harheimertc.data.NewsResponse
import de.harheimertc.data.NewsSaveRequest
import de.harheimertc.data.PasskeyAuthenticationOptionsRequest
import de.harheimertc.data.PasskeyRegistrationOptionsRequest
import de.harheimertc.data.PasskeysResponse
import de.harheimertc.data.PasswordResetAttemptDto
import de.harheimertc.data.PasswordResetDiagnosticsResponse
import de.harheimertc.data.ProfileResponse
import de.harheimertc.data.ProfileUpdateRequest
import de.harheimertc.data.PublicGalleryImageDto
import de.harheimertc.data.GalleryListResponse
import de.harheimertc.data.GalleryUploadResponse
import de.harheimertc.data.RefreshRequest
import de.harheimertc.data.RegistrationRequest
import de.harheimertc.data.RemovePasskeyRequest
import de.harheimertc.data.ResetPasswordRequest
import de.harheimertc.data.SaveCsvRequest
import de.harheimertc.data.SaveCsvResponse
import de.harheimertc.data.SecureOfflineCache
import de.harheimertc.data.SpielplanResponse
import de.harheimertc.data.TeamTableResponse
import de.harheimertc.data.TermineResponse
import de.harheimertc.repositories.CmsRepository
import de.harheimertc.repositories.MeisterschaftResult
import kotlinx.coroutines.flow.MutableStateFlow
import okhttp3.MultipartBody
import okhttp3.RequestBody
import okhttp3.ResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import retrofit2.Response
@RunWith(AndroidJUnit4::class)
class CmsExistingScreensSmokeTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun cmsDashboard_renders() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsDashboardScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Vereinsmeisterschaften", substring = true).assertExists()
}
@Test
fun cmsStartseite_saveWorks() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsStartseiteScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Speichern").performClick()
composeTestRule.waitForIdle()
assertTrue(api.updateConfigCalls >= 1)
}
@Test
fun cmsInhalte_saveWorks() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsInhalteScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Inhalte speichern").performClick()
composeTestRule.waitForIdle()
assertTrue(api.updateConfigCalls >= 1)
}
@Test
fun cmsVereinsmeisterschaften_saveWorks() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsVereinsmeisterschaftenScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Speichern").performClick()
composeTestRule.waitForIdle()
assertEquals(1, api.saveCsvCalls)
}
@Test
fun cmsSportbetrieb_saveWorks() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsSportbetriebScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Speichern").performClick()
composeTestRule.waitForIdle()
assertTrue(api.updateConfigCalls >= 1)
}
@Test
fun cmsEinstellungen_saveWorks() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsEinstellungenScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Speichern").performClick()
composeTestRule.waitForIdle()
assertTrue(api.updateConfigCalls >= 1)
}
@Test
fun cmsMitgliederverwaltung_clickFreischalten() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsMitgliederverwaltungScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Freischalten").performClick()
composeTestRule.waitForIdle()
assertEquals(1, api.updateUserActiveCalls)
}
@Test
fun cmsBenutzer_clickRollenSpeichern() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsBenutzerScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Rollen").performClick()
composeTestRule.onNodeWithText("Speichern").performClick()
composeTestRule.waitForIdle()
assertEquals(1, api.updateUserRolesCalls)
}
@Test
fun cmsContactRequests_replySenden() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsContactRequestsScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Antworten").performClick()
composeTestRule.onNode(hasSetTextAction()).performTextInput("Kurze Testantwort")
composeTestRule.onNodeWithText("Senden").performClick()
composeTestRule.waitForIdle()
assertEquals(1, api.replyContactCalls)
}
@Test
fun cmsNewsletter_createAndSave() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsNewsletterScreen(nav, showBackNavigation = false, viewModel = viewModel, canWriteOverride = true)
}
composeTestRule.onNodeWithText("Newsletter erstellen").performClick()
composeTestRule.onNode(hasSetTextAction()).performTextInput("Testnewsletter")
composeTestRule.onAllNodes(hasText("Speichern")).onFirst().performClick()
composeTestRule.waitForIdle()
assertEquals(1, api.createNewsletterCalls)
}
@Test
fun cmsPasswordResetDiagnostics_renders() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsPasswordResetDiagnosticsScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Passwort-Reset-Diagnose", substring = true).assertExists()
}
private fun createViewModel(api: RecordingApiService): CmsViewModel {
val context = ApplicationProvider.getApplicationContext<Context>()
val cache = SecureOfflineCache(context, Moshi.Builder().build())
val repository = CmsRepository(api, cache)
return CmsViewModel(repository)
}
private fun renderWithState(viewModel: CmsViewModel) {
val readyState = CmsUiState(
loading = false,
saving = false,
error = null,
message = null,
config = ConfigResponse(),
users = listOf(
CmsUserDto(id = "pending-1", name = "Pending User", email = "pending@example.com", active = false, roles = emptyList()),
CmsUserDto(id = "active-1", name = "Active User", email = "active@example.com", active = true, roles = listOf("vorstand")),
),
contactRequests = listOf(
ContactRequestDto(id = "request-1", name = "Kontakt Test", email = "kontakt@example.com", message = "Bitte melden", status = "offen"),
),
newsletters = listOf(
NewsletterDto(id = "newsletter-1", title = "Sommerinfo", subject = "Sommerinfo", status = "draft"),
),
newsletterGroups = listOf(
NewsletterGroupDto(id = "group-1", name = "Mitglieder", description = "Alle Mitglieder"),
),
passwordResetAttempts = listOf(
PasswordResetAttemptDto(requestId = "diag-1", emailMasked = "m***@example.com", failed = true),
),
news = emptyList(),
meisterschaften = listOf(
MeisterschaftResult(year = "2025", category = "Herren", rank = "1", playerOne = "Erika Muster", playerTwo = "", note = "Titel verteidigt", imageOne = "", imageTwo = ""),
),
)
val field = CmsViewModel::class.java.getDeclaredField("_state")
field.isAccessible = true
@Suppress("UNCHECKED_CAST")
(field.get(viewModel) as MutableStateFlow<CmsUiState>).value = readyState
}
}
private class RecordingApiService : ApiService {
var updateConfigCalls = 0
var saveCsvCalls = 0
var updateUserActiveCalls = 0
var updateUserRolesCalls = 0
var replyContactCalls = 0
var createNewsletterCalls = 0
private val config = ConfigResponse()
private val users = listOf(
CmsUserDto(id = "pending-1", name = "Pending User", email = "pending@example.com", active = false, roles = emptyList()),
CmsUserDto(id = "active-1", name = "Active User", email = "active@example.com", active = true, roles = listOf("vorstand")),
)
private val contactRequests = listOf(
ContactRequestDto(id = "request-1", name = "Kontakt Test", email = "kontakt@example.com", message = "Bitte melden", status = "offen"),
)
private val newsletters = listOf(
NewsletterDto(id = "newsletter-1", title = "Sommerinfo", subject = "Sommerinfo", status = "draft"),
)
private val groups = listOf(
NewsletterGroupDto(id = "group-1", name = "Mitglieder", description = "Alle Mitglieder"),
)
private val diagnostics = listOf(
PasswordResetAttemptDto(requestId = "diag-1", emailMasked = "m***@example.com", failed = true),
)
override suspend fun publicGalleryImages(): Response<List<PublicGalleryImageDto>> = Response.success(emptyList())
override suspend fun postContact(req: ContactRequest): Response<ContactResponse> = Response.success(ContactResponse(ok = true))
override suspend fun galerieList(page: Int, perPage: Int): Response<GalleryListResponse> = Response.success(GalleryListResponse())
override suspend fun uploadGalleryImage(image: MultipartBody.Part, title: RequestBody, description: RequestBody, isPublic: RequestBody): Response<GalleryUploadResponse> = Response.success(GalleryUploadResponse())
override suspend fun termine(): Response<TermineResponse> = Response.success(TermineResponse())
override suspend fun spielplan(season: String?): Response<SpielplanResponse> = Response.success(SpielplanResponse())
override suspend fun spielplanTable(team: String, season: String?): Response<TeamTableResponse> = Response.success(TeamTableResponse())
override suspend fun publicNews(): Response<NewsPublicResponse> = Response.success(NewsPublicResponse())
override suspend fun memberNews(): Response<NewsResponse> = Response.success(NewsResponse(success = true, news = emptyList()))
override suspend fun saveNews(request: NewsSaveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true, message = "ok"))
override suspend fun deleteNews(id: Int): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun mannschaften(season: String?): Response<ResponseBody> = Response.success(null)
override suspend fun config(): Response<ConfigResponse> = Response.success(config)
override suspend fun updateConfig(request: ConfigResponse): Response<ConfigResponse> {
updateConfigCalls++
return Response.success(request)
}
override suspend fun spielsysteme(): Response<ResponseBody> = Response.success(null)
override suspend fun vereinsmeisterschaften(): Response<ResponseBody> = Response.success(ResponseBody.create(null, "Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung,imageFilename1,imageFilename2\n\"2025\",\"Herren\",\"1\",\"Erika Muster\",\"\",\"Titel verteidigt\",\"\",\"\""))
override suspend fun saveCsv(request: SaveCsvRequest): Response<SaveCsvResponse> {
saveCsvCalls++
return Response.success(SaveCsvResponse(success = true, message = "CSV gespeichert"))
}
override suspend fun generateMembershipPdf(request: MembershipRequest): Response<MembershipResponse> = Response.success(MembershipResponse())
override suspend fun downloadMembershipPdf(downloadUrl: String): Response<ResponseBody> = Response.success(null)
override suspend fun login(request: LoginRequest): Response<LoginResponse> = Response.success(LoginResponse())
override suspend fun logout(request: LogoutRequest): Response<Unit> = Response.success(Unit)
override suspend fun refresh(request: RefreshRequest): Response<LoginResponse> = Response.success(LoginResponse())
override suspend fun authStatus(): Response<AuthStatusResponse> = Response.success(AuthStatusResponse())
override suspend fun resetPassword(request: ResetPasswordRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun register(request: RegistrationRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun passkeyAuthenticationOptions(request: PasskeyAuthenticationOptionsRequest): Response<ResponseBody> = Response.success(null)
override suspend fun passkeyLogin(request: RequestBody): Response<LoginResponse> = Response.success(LoginResponse())
override suspend fun passkeys(): Response<PasskeysResponse> = Response.success(PasskeysResponse())
override suspend fun passkeyRegistrationOptions(request: PasskeyRegistrationOptionsRequest): Response<ResponseBody> = Response.success(null)
override suspend fun registerPasskey(request: RequestBody): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun removePasskey(request: RemovePasskeyRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun profile(): Response<ProfileResponse> = Response.success(ProfileResponse())
override suspend fun updateProfile(request: ProfileUpdateRequest): Response<ProfileResponse> = Response.success(ProfileResponse())
override suspend fun birthdays(): Response<BirthdaysResponse> = Response.success(BirthdaysResponse())
override suspend fun members(): Response<MembersResponse> = Response.success(MembersResponse())
override suspend fun saveMember(request: ApiService.MemberSaveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun deleteMember(body: Map<String, String>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun bulkImportMembers(request: ApiService.BulkImportRequest): Response<ApiService.BulkImportResponse> = Response.success(ApiService.BulkImportResponse())
override suspend fun toggleMannschaftsspieler(body: Map<String, String>): Response<Map<String, Any>> = Response.success(emptyMap())
override suspend fun cmsUsers(): Response<CmsUsersResponse> = Response.success(CmsUsersResponse(users = users))
override suspend fun updateUserRoles(request: ApiService.UpdateUserRolesRequest): Response<AuthMessageResponse> {
updateUserRolesCalls++
return Response.success(AuthMessageResponse(success = true, message = "Rollen aktualisiert"))
}
override suspend fun updateUserActive(request: ApiService.UpdateUserActiveRequest): Response<AuthMessageResponse> {
updateUserActiveCalls++
return Response.success(AuthMessageResponse(success = true, message = "Status aktualisiert"))
}
override suspend fun resendInvite(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun contactRequests(): Response<List<ContactRequestDto>> = Response.success(contactRequests)
override suspend fun replyToContactRequest(id: String, request: ApiService.ContactReplyRequest): Response<ContactResponse> {
replyContactCalls++
return Response.success(ContactResponse(ok = true, message = "Antwort versendet"))
}
override suspend fun toggleContactRequestStatus(id: String): Response<ContactResponse> = Response.success(ContactResponse(ok = true, message = "Status aktualisiert"))
override suspend fun newsletters(): Response<NewsletterListResponse> = Response.success(NewsletterListResponse(success = true, newsletters = newsletters))
override suspend fun newsletterGroups(): Response<NewsletterGroupsResponse> = Response.success(NewsletterGroupsResponse(success = true, groups = groups))
override suspend fun createNewsletterGroup(request: Map<String, Any?>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun updateNewsletterGroup(id: String, request: Map<String, Any?>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun deleteNewsletterGroup(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun createNewsletter(request: NewsletterCreateRequest): Response<NewsletterCreateResponse> {
createNewsletterCalls++
return Response.success(NewsletterCreateResponse(success = true, message = "Newsletter gespeichert"))
}
override suspend fun updateNewsletter(id: String, request: Map<String, Any?>): Response<NewsletterCreateResponse> = Response.success(NewsletterCreateResponse(success = true))
override suspend fun sendNewsletter(id: String): Response<NewsletterSendResponse> = Response.success(NewsletterSendResponse(success = true))
override suspend fun deleteNewsletter(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun publicNewsletterGroups(): Response<NewsletterGroupsResponse> = Response.success(NewsletterGroupsResponse(success = true, groups = groups))
override suspend fun subscribeNewsletter(request: NewsletterSubscriptionRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun unsubscribeNewsletter(request: NewsletterSubscriptionRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun confirmNewsletter(token: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun passwordResetDiagnostics(
email: String?,
failedOnly: Boolean,
): Response<PasswordResetDiagnosticsResponse> = Response.success(PasswordResetDiagnosticsResponse(attempts = diagnostics))
}

View File

@@ -0,0 +1,122 @@
package de.harheimertc.ui.screens.cms
import android.content.Context
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.navigation.compose.rememberNavController
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.squareup.moshi.Moshi
import de.harheimertc.data.ApiService
import de.harheimertc.data.CmsUsersResponse
import de.harheimertc.data.ConfigResponse
import de.harheimertc.data.ContactRequestDto
import de.harheimertc.data.NewsDto
import de.harheimertc.data.NewsResponse
import de.harheimertc.data.NewsletterGroupsResponse
import de.harheimertc.data.NewsletterListResponse
import de.harheimertc.data.PasswordResetAttemptDto
import de.harheimertc.data.PasswordResetDiagnosticsResponse
import de.harheimertc.data.PasswordResetStepDto
import de.harheimertc.data.SecureOfflineCache
import de.harheimertc.repositories.CmsRepository
import okhttp3.ResponseBody
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import retrofit2.Response
import java.lang.reflect.InvocationHandler
import java.lang.reflect.Method
import java.lang.reflect.Proxy
@RunWith(AndroidJUnit4::class)
class CmsPasswordResetDiagnosticsScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun diagnosticsScreen_showsFilterAndAttemptDetails() {
val api = createDiagnosticsApiService()
val context = ApplicationProvider.getApplicationContext<Context>()
val cache = SecureOfflineCache(context, Moshi.Builder().build())
val repo = CmsRepository(api, cache)
val viewModel = CmsViewModel(repo)
composeTestRule.setContent {
val navController = rememberNavController()
CmsPasswordResetDiagnosticsScreen(navController, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.waitUntil(15_000) {
try {
composeTestRule.onNodeWithText("Nur Auffälligkeiten", useUnmergedTree = true).assertExists()
true
} catch (_: Throwable) {
false
}
}
composeTestRule.onNodeWithText("Nur Auffälligkeiten", useUnmergedTree = true).assertExists()
composeTestRule.onNodeWithText("Prüfen", useUnmergedTree = true).assertExists()
composeTestRule.onNodeWithText("Reset-Vorgänge", useUnmergedTree = true).assertExists()
composeTestRule.onNodeWithText("Aktualisieren", useUnmergedTree = true).assertExists()
// Trigger a manual refresh to validate the main interaction path.
composeTestRule.onNodeWithText("Prüfen", useUnmergedTree = true).performClick()
composeTestRule.waitForIdle()
}
private fun createDiagnosticsApiService(): ApiService {
val attempt = PasswordResetAttemptDto(
requestId = "req-1",
startedAt = "2026-05-29T10:15:00Z",
emailMasked = "m***@example.com",
ip = "127.0.0.1",
failed = true,
steps = listOf(
PasswordResetStepDto(
ts = "2026-05-29T10:15:01Z",
step = "mail_configuration",
status = "failed",
reason = "smtp_credentials_missing",
),
),
)
val handler = InvocationHandler { _, method: Method, _ ->
when (method.name) {
"config" -> Response.success(ConfigResponse())
"users" -> Response.success(CmsUsersResponse())
"cmsUsers" -> Response.success(CmsUsersResponse())
"contactRequests" -> Response.success(listOf<ContactRequestDto>())
"newsletters" -> Response.success(NewsletterListResponse(success = true, newsletters = emptyList()))
"newsletterGroups" -> Response.success(NewsletterGroupsResponse(success = true, groups = emptyList()))
"memberNews" -> Response.success(NewsResponse(success = true, news = listOf(NewsDto(id = 1, title = "N", content = "C"))))
"passwordResetDiagnostics" -> Response.success(
PasswordResetDiagnosticsResponse(
retentionHours = 72,
searchedEmail = "",
matchingUsers = listOf(
de.harheimertc.data.PasswordResetMatchingUserDto(
id = "u1",
name = "Max Muster",
email = "max@example.com",
active = true,
),
),
attempts = listOf(attempt),
),
)
"vereinsmeisterschaften" -> Response.success(ResponseBody.create(null, "Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung\n"))
else -> throw UnsupportedOperationException("Unhandled ApiService method in test: ${method.name}")
}
}
return Proxy.newProxyInstance(
ApiService::class.java.classLoader,
arrayOf(ApiService::class.java),
handler,
) as ApiService
}
}

View File

@@ -0,0 +1,156 @@
package de.harheimertc.ui.screens.cms
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.*
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.core.app.ApplicationProvider
import com.squareup.moshi.Moshi
import kotlinx.coroutines.runBlocking
import org.junit.Rule
import org.junit.Test
import org.junit.Assert.assertTrue
import org.junit.runner.RunWith
import retrofit2.Response
import okhttp3.MultipartBody
import okhttp3.RequestBody
import de.harheimertc.data.*
import kotlinx.coroutines.flow.MutableStateFlow
import de.harheimertc.repositories.CmsRepository
import androidx.navigation.compose.rememberNavController
@RunWith(AndroidJUnit4::class)
class CmsStartseiteSmokeTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun cmsStartseite_rendersWithDefaultState() {
// prepare a minimal fake ApiService that returns empty/neutral responses
val fakeApi = object : ApiService {
override suspend fun publicGalleryImages(): Response<List<PublicGalleryImageDto>> = Response.success(emptyList())
override suspend fun postContact(req: ContactRequest): Response<ContactResponse> = Response.success(ContactResponse(ok = true))
override suspend fun galerieList(page: Int, perPage: Int): Response<GalleryListResponse> = Response.success(GalleryListResponse())
override suspend fun uploadGalleryImage(image: MultipartBody.Part, title: RequestBody, description: RequestBody, isPublic: RequestBody): Response<GalleryUploadResponse> = Response.success(GalleryUploadResponse())
override suspend fun termine(): Response<TermineResponse> = Response.success(TermineResponse())
override suspend fun spielplan(season: String?): Response<SpielplanResponse> = Response.success(SpielplanResponse())
override suspend fun spielplanTable(team: String, season: String?): Response<TeamTableResponse> = Response.success(TeamTableResponse())
override suspend fun publicNews(): Response<NewsPublicResponse> = Response.success(NewsPublicResponse())
override suspend fun memberNews(): Response<NewsResponse> = Response.success(NewsResponse(success = true, news = emptyList()))
override suspend fun saveNews(request: NewsSaveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true, message = "ok"))
override suspend fun deleteNews(id: Int): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun mannschaften(season: String?): Response<okhttp3.ResponseBody> = Response.success(null)
override suspend fun config(): Response<ConfigResponse> = Response.success(ConfigResponse())
override suspend fun updateConfig(request: ConfigResponse): Response<ConfigResponse> = Response.success(request)
override suspend fun spielsysteme(): Response<okhttp3.ResponseBody> = Response.success(null)
override suspend fun vereinsmeisterschaften(): Response<okhttp3.ResponseBody> = Response.success(null)
override suspend fun saveCsv(request: SaveCsvRequest): Response<SaveCsvResponse> = Response.success(SaveCsvResponse(success = true))
override suspend fun generateMembershipPdf(request: MembershipRequest): Response<MembershipResponse> = Response.success(MembershipResponse())
override suspend fun downloadMembershipPdf(downloadUrl: String): Response<okhttp3.ResponseBody> = Response.success(null)
override suspend fun login(request: LoginRequest): Response<LoginResponse> = Response.success(LoginResponse())
override suspend fun logout(request: LogoutRequest): Response<Unit> = Response.success(Unit)
override suspend fun refresh(request: RefreshRequest): Response<LoginResponse> = Response.success(LoginResponse())
override suspend fun authStatus(): Response<AuthStatusResponse> = Response.success(AuthStatusResponse())
override suspend fun resetPassword(request: ResetPasswordRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun register(request: RegistrationRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun passkeyAuthenticationOptions(request: PasskeyAuthenticationOptionsRequest): Response<okhttp3.ResponseBody> = Response.success(null)
override suspend fun passkeyLogin(request: okhttp3.RequestBody): Response<LoginResponse> = Response.success(LoginResponse())
override suspend fun passkeys(): Response<PasskeysResponse> = Response.success(PasskeysResponse())
override suspend fun passkeyRegistrationOptions(request: PasskeyRegistrationOptionsRequest): Response<okhttp3.ResponseBody> = Response.success(null)
override suspend fun registerPasskey(request: okhttp3.RequestBody): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun removePasskey(request: RemovePasskeyRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun profile(): Response<ProfileResponse> = Response.success(ProfileResponse())
override suspend fun updateProfile(request: ProfileUpdateRequest): Response<ProfileResponse> = Response.success(ProfileResponse())
override suspend fun birthdays(): Response<BirthdaysResponse> = Response.success(BirthdaysResponse())
override suspend fun members(): Response<MembersResponse> = Response.success(MembersResponse())
override suspend fun saveMember(request: ApiService.MemberSaveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun deleteMember(body: Map<String, String>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun bulkImportMembers(request: ApiService.BulkImportRequest): Response<ApiService.BulkImportResponse> = Response.success(ApiService.BulkImportResponse())
override suspend fun toggleMannschaftsspieler(body: Map<String, String>): Response<Map<String, Any>> = Response.success(emptyMap())
override suspend fun cmsUsers(): Response<CmsUsersResponse> = Response.success(CmsUsersResponse())
override suspend fun updateUserRoles(request: ApiService.UpdateUserRolesRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun updateUserActive(request: ApiService.UpdateUserActiveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun resendInvite(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun contactRequests(): Response<List<ContactRequestDto>> = Response.success(emptyList())
override suspend fun replyToContactRequest(id: String, request: ApiService.ContactReplyRequest): Response<de.harheimertc.data.ContactResponse> = Response.success(de.harheimertc.data.ContactResponse(ok = true))
override suspend fun toggleContactRequestStatus(id: String): Response<de.harheimertc.data.ContactResponse> = Response.success(de.harheimertc.data.ContactResponse(ok = true))
override suspend fun newsletters(): Response<NewsletterListResponse> = Response.success(NewsletterListResponse(success = true, newsletters = emptyList()))
override suspend fun newsletterGroups(): Response<NewsletterGroupsResponse> = Response.success(NewsletterGroupsResponse(success = true, groups = emptyList()))
override suspend fun createNewsletterGroup(request: Map<String, Any?>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun updateNewsletterGroup(id: String, request: Map<String, Any?>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun deleteNewsletterGroup(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun createNewsletter(request: NewsletterCreateRequest): Response<NewsletterCreateResponse> = Response.success(NewsletterCreateResponse(success = true))
override suspend fun updateNewsletter(id: String, request: Map<String, Any?>): Response<NewsletterCreateResponse> = Response.success(NewsletterCreateResponse(success = true))
override suspend fun sendNewsletter(id: String): Response<NewsletterSendResponse> = Response.success(NewsletterSendResponse(success = true))
override suspend fun deleteNewsletter(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun publicNewsletterGroups(): Response<NewsletterGroupsResponse> = Response.success(NewsletterGroupsResponse(success = true))
override suspend fun subscribeNewsletter(request: NewsletterSubscriptionRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun unsubscribeNewsletter(request: NewsletterSubscriptionRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun confirmNewsletter(token: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun passwordResetDiagnostics(
email: String?,
failedOnly: Boolean,
): Response<PasswordResetDiagnosticsResponse> = Response.success(PasswordResetDiagnosticsResponse())
}
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
val moshi = Moshi.Builder().build()
val cache = SecureOfflineCache(context, moshi)
val repo = CmsRepository(fakeApi, cache)
val vm = de.harheimertc.ui.screens.cms.CmsViewModel(repo)
// set a ready state to avoid waiting for async network loads in Vm.init
val readyState = de.harheimertc.ui.screens.cms.CmsUiState(
loading = false,
saving = false,
error = null,
message = null,
config = ConfigResponse(),
users = emptyList(),
contactRequests = emptyList(),
newsletters = emptyList(),
newsletterGroups = emptyList(),
passwordResetAttempts = emptyList(),
news = emptyList(),
)
try {
val field = de.harheimertc.ui.screens.cms.CmsViewModel::class.java.getDeclaredField("_state")
field.isAccessible = true
val current = field.get(vm) as? MutableStateFlow<*>
if (current is MutableStateFlow<*>) {
@Suppress("UNCHECKED_CAST")
(current as MutableStateFlow<de.harheimertc.ui.screens.cms.CmsUiState>).value = readyState
}
} catch (_: Throwable) { /* best-effort, continue */ }
composeTestRule.setContent {
val nav = rememberNavController()
CmsStartseiteScreen(nav, showBackNavigation = false, viewModel = vm)
}
// dump semantics tree for debugging
try {
composeTestRule.onRoot().printToLog("CmsStartseiteSmokeTest-SEMTREE")
} catch (_: Throwable) { }
// wait for the main title and info rows to appear
fun waitForText(text: String, timeoutMs: Long = 20000L) {
composeTestRule.waitUntil(timeoutMs) {
try {
composeTestRule.onAllNodes(hasText(text, substring = true)).fetchSemanticsNodes().isNotEmpty()
} catch (_: AssertionError) { false }
}
}
waitForText("Startseite")
waitForText("Öffentliche")
// basic assertions (use substring matching)
assertTrue(composeTestRule.onAllNodes(hasText("Startseite", substring = true)).fetchSemanticsNodes().isNotEmpty())
assertTrue(composeTestRule.onAllNodes(hasText("Öffentliche", substring = true)).fetchSemanticsNodes().isNotEmpty())
}
}

View File

@@ -0,0 +1,144 @@
package de.harheimertc.ui.screens.cms
import android.content.Intent
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2
import androidx.test.uiautomator.Until
import org.junit.Assert.assertNotNull
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class CmsUiAutomatorClickTest {
@Test
fun clickThroughExistingCmsPages_andTrySave() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val context = instrumentation.targetContext
val device = UiDevice.getInstance(instrumentation)
val packageName = "de.harheimertc.local"
val launchIntent = context.packageManager.getLaunchIntentForPackage(packageName)
?.apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
assertNotNull("Launch-Intent fuer de.harheimertc.local nicht gefunden", launchIntent)
context.startActivity(launchIntent)
device.wait(Until.hasObject(By.pkg(packageName).depth(0)), 15000)
clickText(device, "Intern")
clickText(device, "CMS")
openCmsCard(device, "Startseite")
clickIfPresent(device, "Speichern")
backToCmsDashboard(device)
openCmsCard(device, "Inhalte")
clickIfPresent(device, "Inhalte speichern")
backToCmsDashboard(device)
openCmsCard(device, "Vereinsmeisterschaften")
clickIfPresent(device, "Speichern")
backToCmsDashboard(device)
openCmsCard(device, "Sportbetrieb")
clickIfPresent(device, "Speichern")
backToCmsDashboard(device)
openCmsCard(device, "Einstellungen")
clickIfPresent(device, "Speichern")
backToCmsDashboard(device)
openCmsCard(device, "Mitgliederverwaltung")
clickIfPresent(device, "Freischalten")
backToCmsDashboard(device)
openCmsCard(device, "Kontaktanfragen")
clickIfPresent(device, "Antworten")
clickIfPresent(device, "Abbrechen")
backToCmsDashboard(device)
openCmsCard(device, "Newsletter")
clickIfPresent(device, "Newsletter erstellen")
clickIfPresent(device, "Abbrechen")
backToCmsDashboard(device)
if (openCmsCardIfAvailable(device, "Benutzer")) {
clickIfPresent(device, "Rollen")
clickIfPresent(device, "Abbrechen")
backToCmsDashboard(device)
}
openCmsCardIfAvailable(device, "Passwort-Reset-Diagnose")
// Wenn wir am Ende noch im App-Paket sind, ist der Flow nicht gecrasht.
device.wait(Until.hasObject(By.pkg(packageName).depth(0)), 5000)
}
private fun openCmsCard(device: UiDevice, label: String) {
if (!clickIfPresent(device, label, 2500) && !clickTextWithScroll(device, label)) {
clickText(device, label)
}
}
private fun openCmsCardIfAvailable(device: UiDevice, label: String): Boolean {
if (clickIfPresent(device, label, 1500)) return true
return clickTextWithScroll(device, label)
}
private fun backToCmsDashboard(device: UiDevice) {
if (!clickIfPresent(device, "CMS", 3000)) {
device.pressBack()
device.waitForIdle()
}
}
private fun clickText(device: UiDevice, text: String, timeoutMs: Long = 10000): UiObject2 {
val obj = device.wait(Until.findObject(By.textContains(text)), timeoutMs)
requireNotNull(obj) { "Text nicht gefunden: $text" }
obj.click()
device.waitForIdle()
return obj
}
private fun clickIfPresent(device: UiDevice, text: String, timeoutMs: Long = 1500): Boolean {
val obj = device.wait(Until.findObject(By.textContains(text)), timeoutMs)
if (obj != null) {
obj.click()
device.waitForIdle()
return true
}
return false
}
private fun clickTextWithScroll(device: UiDevice, text: String, maxSwipes: Int = 5): Boolean {
if (clickIfPresent(device, text, 1500)) return true
repeat(maxSwipes) {
device.swipe(
device.displayWidth / 2,
(device.displayHeight * 0.8).toInt(),
device.displayWidth / 2,
(device.displayHeight * 0.25).toInt(),
24,
)
device.waitForIdle()
if (clickIfPresent(device, text, 1200)) return true
}
repeat(maxSwipes) {
device.swipe(
device.displayWidth / 2,
(device.displayHeight * 0.25).toInt(),
device.displayWidth / 2,
(device.displayHeight * 0.8).toInt(),
24,
)
device.waitForIdle()
if (clickIfPresent(device, text, 1200)) return true
}
return false
}
}

View File

@@ -9,6 +9,7 @@ import dagger.hilt.android.HiltAndroidApp
import io.sentry.Sentry
import okhttp3.OkHttpClient
import javax.inject.Inject
import android.util.Log
@HiltAndroidApp
class HarheimerApplication : Application(), ImageLoaderFactory {
@@ -16,6 +17,7 @@ class HarheimerApplication : Application(), ImageLoaderFactory {
lateinit var okHttpClient: OkHttpClient
override fun onCreate() {
Log.d("HILT", "HarheimerApplication.onCreate called")
super.onCreate()
if (BuildConfig.SENTRY_DSN.isNotBlank()) {
Sentry.init { options ->

View File

@@ -6,9 +6,14 @@ import androidx.activity.compose.setContent
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import androidx.navigation.compose.rememberNavController
import dagger.hilt.android.AndroidEntryPoint
import de.harheimertc.ui.navigation.NavGraph
import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.ui.theme.HarheimerTheme
import androidx.hilt.navigation.compose.hiltViewModel
import de.harheimertc.ui.navigation.NavigationViewModel
import dagger.hilt.android.AndroidEntryPoint
import android.util.Log
import androidx.compose.ui.platform.LocalContext
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@@ -24,7 +29,11 @@ class MainActivity : ComponentActivity() {
fun App() {
HarheimerTheme {
val navController = rememberNavController()
NavGraph(navController = navController)
val ctx = LocalContext.current
val activity = ctx as? ComponentActivity
Log.i("HILT_FACTORY", "defaultViewModelProviderFactory=${activity?.defaultViewModelProviderFactory?.javaClass?.name}")
val navigationViewModel: NavigationViewModel = hiltViewModel()
NavGraph(navController = navController, navigationViewModelParam = navigationViewModel)
}
}

View File

@@ -191,6 +191,16 @@ data class AuthStatusResponse(
)
data class ResetPasswordRequest(val email: String)
data class AuthMessageResponse(val success: Boolean = false, val message: String? = null)
data class SaveCsvRequest(
val filename: String,
val content: String,
)
data class SaveCsvResponse(
val success: Boolean = false,
val message: String? = null,
val writtenTo: List<String> = emptyList(),
val jsonWrittenTo: List<String> = emptyList(),
)
data class PasskeyAuthenticationOptionsRequest(
val email: String? = null,
val client: String = "android",
@@ -344,14 +354,38 @@ data class SatzungDto(
val pdfUrl: String = "",
val content: String = "",
)
data class MembershipTierDto(
val id: String = "",
val typ: String = "",
val beschreibung: String? = null,
val preis: Int = 0,
val features: List<String> = emptyList(),
)
data class LinkItemDto(
val label: String = "",
val href: String = "",
val description: String = "",
val id: String = "",
)
data class LinkSectionDto(
val title: String = "",
val items: List<LinkItemDto> = emptyList(),
val id: String = "",
)
data class HomepageSectionDto(
val id: String = "",
val enabled: Boolean = true,
val key: String? = null,
val marker: String? = null,
val config: HomepageSectionConfigDto? = null,
)
data class HomepageSectionConfigDto(
val season: String? = null,
val teamName: String? = null,
val teamAgeGroup: String? = null,
)
data class HomepageDto(
val sections: List<HomepageSectionDto> = emptyList(),
)
data class SeitenDto(
val ueberUns: String = "",
@@ -364,10 +398,12 @@ data class SeitenDto(
data class ConfigResponse(
val training: TrainingDto = TrainingDto(),
val trainer: List<TrainerDto> = emptyList(),
val mitgliedschaft: List<MembershipTierDto> = emptyList(),
val verein: VereinDto = VereinDto(),
val vorstand: VorstandDto = VorstandDto(),
val website: WebsiteDto = WebsiteDto(),
val seiten: SeitenDto = SeitenDto(),
val homepage: HomepageDto = HomepageDto(),
)
data class CmsUserDto(
val id: String = "",
@@ -454,8 +490,17 @@ data class PasswordResetAttemptDto(
val failed: Boolean = false,
val steps: List<PasswordResetStepDto> = emptyList(),
)
data class PasswordResetMatchingUserDto(
val id: String = "",
val name: String = "",
val email: String = "",
val active: Boolean = true,
val lastLogin: String? = null,
)
data class PasswordResetDiagnosticsResponse(
val retentionHours: Int = 0,
val searchedEmail: String? = null,
val matchingUsers: List<PasswordResetMatchingUserDto> = emptyList(),
val attempts: List<PasswordResetAttemptDto> = emptyList(),
)
@@ -520,6 +565,9 @@ interface ApiService {
@GET("/api/vereinsmeisterschaften")
suspend fun vereinsmeisterschaften(): Response<ResponseBody>
@POST("/api/cms/save-csv")
suspend fun saveCsv(@Body request: SaveCsvRequest): Response<SaveCsvResponse>
@POST("/api/membership/generate-pdf")
suspend fun generateMembershipPdf(@Body request: MembershipRequest): Response<MembershipResponse>
@@ -669,5 +717,8 @@ interface ApiService {
suspend fun confirmNewsletter(@Query("token") token: String): Response<AuthMessageResponse>
@GET("/api/cms/password-reset-diagnostics")
suspend fun passwordResetDiagnostics(): Response<PasswordResetDiagnosticsResponse>
suspend fun passwordResetDiagnostics(
@Query("email") email: String? = null,
@Query("failedOnly") failedOnly: Boolean = true,
): Response<PasswordResetDiagnosticsResponse>
}

View File

@@ -14,6 +14,19 @@ class SecureOfflineCache @Inject constructor(
@param:ApplicationContext private val context: Context,
private val moshi: Moshi,
) {
private companion object {
const val KEY_BIRTHDAYS = "birthdays"
const val KEY_MEMBERS = "members"
const val KEY_MEMBER_NEWS = "member_news"
const val KEY_CMS_CONFIG = "cms_config"
const val KEY_CMS_USERS = "cms_users"
const val KEY_CONTACT_REQUESTS = "contact_requests"
const val KEY_NEWSLETTERS = "newsletters"
const val KEY_NEWSLETTER_GROUPS = "newsletter_groups"
const val KEY_PASSWORD_RESET_DIAGNOSTICS = "password_reset_diagnostics"
const val TIMESTAMP_SUFFIX = "_ts"
}
private val preferences by lazy {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
@@ -27,51 +40,99 @@ class SecureOfflineCache @Inject constructor(
)
}
fun putBirthdays(response: BirthdaysResponse) = put("birthdays", response, BirthdaysResponse::class.java)
fun getBirthdays(): BirthdaysResponse? = get("birthdays", BirthdaysResponse::class.java)
fun putBirthdays(response: BirthdaysResponse) = put(KEY_BIRTHDAYS, response, BirthdaysResponse::class.java)
fun getBirthdays(maxAgeMillis: Long? = null): BirthdaysResponse? = get(KEY_BIRTHDAYS, BirthdaysResponse::class.java, maxAgeMillis)
fun putMembers(response: MembersResponse) = put("members", response, MembersResponse::class.java)
fun getMembers(): MembersResponse? = get("members", MembersResponse::class.java)
fun putMembers(response: MembersResponse) = put(KEY_MEMBERS, response, MembersResponse::class.java)
fun getMembers(maxAgeMillis: Long? = null): MembersResponse? = get(KEY_MEMBERS, MembersResponse::class.java, maxAgeMillis)
fun putNews(response: NewsResponse) = put("member_news", response, NewsResponse::class.java)
fun getNews(): NewsResponse? = get("member_news", NewsResponse::class.java)
fun putNews(response: NewsResponse) = put(KEY_MEMBER_NEWS, response, NewsResponse::class.java)
fun getNews(maxAgeMillis: Long? = null): NewsResponse? = get(KEY_MEMBER_NEWS, NewsResponse::class.java, maxAgeMillis)
fun putConfig(response: ConfigResponse) = put("cms_config", response, ConfigResponse::class.java)
fun getConfig(): ConfigResponse? = get("cms_config", ConfigResponse::class.java)
fun putConfig(response: ConfigResponse) = put(KEY_CMS_CONFIG, response, ConfigResponse::class.java)
fun getConfig(maxAgeMillis: Long? = null): ConfigResponse? = get(KEY_CMS_CONFIG, ConfigResponse::class.java, maxAgeMillis)
fun putCmsUsers(response: CmsUsersResponse) = put("cms_users", response, CmsUsersResponse::class.java)
fun getCmsUsers(): CmsUsersResponse? = get("cms_users", CmsUsersResponse::class.java)
fun putCmsUsers(response: CmsUsersResponse) = put(KEY_CMS_USERS, response, CmsUsersResponse::class.java)
fun getCmsUsers(maxAgeMillis: Long? = null): CmsUsersResponse? = get(KEY_CMS_USERS, CmsUsersResponse::class.java, maxAgeMillis)
fun putContactRequests(response: List<ContactRequestDto>) {
val type = Types.newParameterizedType(List::class.java, ContactRequestDto::class.java)
val json = moshi.adapter<List<ContactRequestDto>>(type).toJson(response)
preferences.edit().putString("contact_requests", json).apply()
preferences.edit()
.putString(KEY_CONTACT_REQUESTS, json)
.putLong(timestampKey(KEY_CONTACT_REQUESTS), System.currentTimeMillis())
.apply()
}
fun getContactRequests(): List<ContactRequestDto>? {
val json = preferences.getString("contact_requests", null) ?: return null
fun getContactRequests(maxAgeMillis: Long? = null): List<ContactRequestDto>? {
if (isExpired(KEY_CONTACT_REQUESTS, maxAgeMillis)) return null
val json = preferences.getString(KEY_CONTACT_REQUESTS, null) ?: return null
val type = Types.newParameterizedType(List::class.java, ContactRequestDto::class.java)
return runCatching { moshi.adapter<List<ContactRequestDto>>(type).fromJson(json) }.getOrNull()
}
fun putNewsletters(response: NewsletterListResponse) = put("newsletters", response, NewsletterListResponse::class.java)
fun getNewsletters(): NewsletterListResponse? = get("newsletters", NewsletterListResponse::class.java)
fun putNewsletters(response: NewsletterListResponse) = put(KEY_NEWSLETTERS, response, NewsletterListResponse::class.java)
fun getNewsletters(maxAgeMillis: Long? = null): NewsletterListResponse? =
get(KEY_NEWSLETTERS, NewsletterListResponse::class.java, maxAgeMillis)
fun putNewsletterGroups(response: NewsletterGroupsResponse) = put("newsletter_groups", response, NewsletterGroupsResponse::class.java)
fun getNewsletterGroups(): NewsletterGroupsResponse? = get("newsletter_groups", NewsletterGroupsResponse::class.java)
fun putNewsletterGroups(response: NewsletterGroupsResponse) = put(KEY_NEWSLETTER_GROUPS, response, NewsletterGroupsResponse::class.java)
fun getNewsletterGroups(maxAgeMillis: Long? = null): NewsletterGroupsResponse? =
get(KEY_NEWSLETTER_GROUPS, NewsletterGroupsResponse::class.java, maxAgeMillis)
fun putPasswordResetDiagnostics(response: PasswordResetDiagnosticsResponse) =
put("password_reset_diagnostics", response, PasswordResetDiagnosticsResponse::class.java)
fun getPasswordResetDiagnostics(): PasswordResetDiagnosticsResponse? =
get("password_reset_diagnostics", PasswordResetDiagnosticsResponse::class.java)
put(KEY_PASSWORD_RESET_DIAGNOSTICS, response, PasswordResetDiagnosticsResponse::class.java)
fun getPasswordResetDiagnostics(maxAgeMillis: Long? = null): PasswordResetDiagnosticsResponse? =
get(KEY_PASSWORD_RESET_DIAGNOSTICS, PasswordResetDiagnosticsResponse::class.java, maxAgeMillis)
fun clearCmsProtectedCaches() {
clear(
KEY_CMS_CONFIG,
KEY_CMS_USERS,
KEY_CONTACT_REQUESTS,
KEY_NEWSLETTERS,
KEY_NEWSLETTER_GROUPS,
KEY_PASSWORD_RESET_DIAGNOSTICS,
KEY_MEMBER_NEWS,
)
}
fun clearCmsUsersCache() = clear(KEY_CMS_USERS)
fun clearContactRequestsCache() = clear(KEY_CONTACT_REQUESTS)
fun clearNewslettersCache() = clear(KEY_NEWSLETTERS)
fun clearNewsletterGroupsCache() = clear(KEY_NEWSLETTER_GROUPS)
fun clearPasswordResetDiagnosticsCache() = clear(KEY_PASSWORD_RESET_DIAGNOSTICS)
fun clearCmsConfigCache() = clear(KEY_CMS_CONFIG)
fun clearCmsNewsCache() = clear(KEY_MEMBER_NEWS)
private fun <T> put(key: String, value: T, type: Class<T>) {
val json = moshi.adapter(type).toJson(value)
preferences.edit().putString(key, json).apply()
preferences.edit()
.putString(key, json)
.putLong(timestampKey(key), System.currentTimeMillis())
.apply()
}
private fun <T> get(key: String, type: Class<T>): T? {
private fun <T> get(key: String, type: Class<T>, maxAgeMillis: Long? = null): T? {
if (isExpired(key, maxAgeMillis)) return null
val json = preferences.getString(key, null) ?: return null
return runCatching { moshi.adapter(type).fromJson(json) }.getOrNull()
}
private fun clear(vararg keys: String) {
val editor = preferences.edit()
keys.forEach { key ->
editor.remove(key)
editor.remove(timestampKey(key))
}
editor.apply()
}
private fun isExpired(key: String, maxAgeMillis: Long?): Boolean {
if (maxAgeMillis == null) return false
val savedAt = preferences.getLong(timestampKey(key), 0L)
if (savedAt <= 0L) return true
return (System.currentTimeMillis() - savedAt) > maxAgeMillis
}
private fun timestampKey(key: String): String = key + TIMESTAMP_SUFFIX
}

View File

@@ -7,6 +7,8 @@ import de.harheimertc.data.ContactRequestDto
import de.harheimertc.data.NewsletterGroupsResponse
import de.harheimertc.data.NewsletterListResponse
import de.harheimertc.data.PasswordResetDiagnosticsResponse
import de.harheimertc.data.SaveCsvRequest
import de.harheimertc.data.SaveCsvResponse
import de.harheimertc.data.SecureOfflineCache
import javax.inject.Inject
@@ -14,6 +16,11 @@ class CmsRepository @Inject constructor(
private val api: ApiService,
private val cache: SecureOfflineCache,
) {
private companion object {
const val CMS_CACHE_MAX_AGE_MS = 24L * 60L * 60L * 1000L
const val PASSWORD_RESET_DIAGNOSTICS_MAX_AGE_MS = 6L * 60L * 60L * 1000L
}
suspend fun config(): Result<ConfigResponse> =
fetchEncryptedFallback(
load = {
@@ -22,14 +29,58 @@ class CmsRepository @Inject constructor(
response.body() ?: error("Leere Antwort vom Server.")
},
save = cache::putConfig,
cached = cache::getConfig,
cached = { cache.getConfig(CMS_CACHE_MAX_AGE_MS) },
fallbackMessage = "Konfiguration konnte nicht geladen werden.",
)
suspend fun saveConfig(config: ConfigResponse): Result<ConfigResponse> = runCatching {
val response = api.updateConfig(config)
if (!response.isSuccessful) error("Konfiguration konnte nicht gespeichert werden.")
response.body() ?: error("Leere Antwort vom Server.")
val saved = response.body() ?: error("Leere Antwort vom Server.")
cache.putConfig(saved)
saved
}
suspend fun vereinsmeisterschaften(): Result<List<MeisterschaftResult>> = runCatching {
val response = api.vereinsmeisterschaften()
if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht geladen werden.")
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
if (values.size < 6) return@mapNotNull null
MeisterschaftResult(
year = values[0],
category = values[1],
rank = values[2],
playerOne = values[3],
playerTwo = values[4],
note = values[5],
imageOne = values.getOrElse(6) { "" },
imageTwo = values.getOrElse(7) { "" },
)
}
}
suspend fun saveVereinsmeisterschaften(results: List<MeisterschaftResult>): Result<SaveCsvResponse> = runCatching {
val csvHeader = "Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung,imageFilename1,imageFilename2"
val csvRows = results.map { result ->
listOf(
result.year,
result.category,
result.rank,
result.playerOne,
result.playerTwo,
result.note,
result.imageOne,
result.imageTwo,
).joinToString(",") { value -> "\"${value.replace("\"", "\"\"")}\"" }
}
val response = api.saveCsv(
SaveCsvRequest(
filename = "vereinsmeisterschaften.csv",
content = listOf(csvHeader).plus(csvRows).joinToString("\n"),
),
)
if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht gespeichert werden.")
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
}
suspend fun users(): Result<CmsUsersResponse> =
@@ -40,7 +91,7 @@ class CmsRepository @Inject constructor(
response.body() ?: error("Leere Antwort vom Server.")
},
save = cache::putCmsUsers,
cached = cache::getCmsUsers,
cached = { cache.getCmsUsers(CMS_CACHE_MAX_AGE_MS) },
fallbackMessage = "Benutzer konnten nicht geladen werden.",
)
@@ -48,6 +99,7 @@ class CmsRepository @Inject constructor(
val req = de.harheimertc.data.ApiService.UpdateUserRolesRequest(id, roles)
val response = api.updateUserRoles(req)
if (!response.isSuccessful) error("Benutzerrollen konnten nicht aktualisiert werden.")
cache.clearCmsUsersCache()
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
@@ -55,12 +107,14 @@ class CmsRepository @Inject constructor(
val req = de.harheimertc.data.ApiService.UpdateUserActiveRequest(id, active)
val response = api.updateUserActive(req)
if (!response.isSuccessful) error("Benutzerstatus konnte nicht aktualisiert werden.")
cache.clearCmsUsersCache()
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
suspend fun resendInvite(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.resendInvite(id)
if (!response.isSuccessful) error("Einladung konnte nicht erneut gesendet werden.")
cache.clearCmsUsersCache()
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
@@ -72,7 +126,7 @@ class CmsRepository @Inject constructor(
response.body() ?: emptyList()
},
save = cache::putContactRequests,
cached = cache::getContactRequests,
cached = { cache.getContactRequests(CMS_CACHE_MAX_AGE_MS) },
fallbackMessage = "Kontaktanfragen konnten nicht geladen werden.",
)
@@ -80,12 +134,14 @@ class CmsRepository @Inject constructor(
val req = ApiService.ContactReplyRequest(message)
val response = api.replyToContactRequest(id, req)
if (!response.isSuccessful) error("Antwort konnte nicht gesendet werden.")
cache.clearContactRequestsCache()
response.body() ?: de.harheimertc.data.ContactResponse(ok = false)
}
suspend fun toggleContactRequestStatus(id: String): Result<de.harheimertc.data.ContactResponse> = runCatching {
val response = api.toggleContactRequestStatus(id)
if (!response.isSuccessful) error("Status konnte nicht geändert werden.")
cache.clearContactRequestsCache()
response.body() ?: de.harheimertc.data.ContactResponse(ok = false)
}
@@ -97,7 +153,7 @@ class CmsRepository @Inject constructor(
response.body() ?: error("Leere Antwort vom Server.")
},
save = cache::putNewsletters,
cached = cache::getNewsletters,
cached = { cache.getNewsletters(CMS_CACHE_MAX_AGE_MS) },
fallbackMessage = "Newsletter konnten nicht geladen werden.",
)
@@ -109,21 +165,38 @@ class CmsRepository @Inject constructor(
response.body() ?: error("Leere Antwort vom Server.")
},
save = cache::putNewsletterGroups,
cached = cache::getNewsletterGroups,
cached = { cache.getNewsletterGroups(CMS_CACHE_MAX_AGE_MS) },
fallbackMessage = "Newsletter-Gruppen konnten nicht geladen werden.",
)
suspend fun passwordResetDiagnostics(): Result<PasswordResetDiagnosticsResponse> =
fetchEncryptedFallback(
suspend fun passwordResetDiagnostics(
email: String? = null,
failedOnly: Boolean = true,
): Result<PasswordResetDiagnosticsResponse> {
val normalizedEmail = email?.trim().orEmpty()
val canUseSharedCache = normalizedEmail.isBlank() && failedOnly
return fetchEncryptedFallback(
load = {
val response = api.passwordResetDiagnostics()
val response = api.passwordResetDiagnostics(
email = normalizedEmail.takeIf { it.isNotBlank() },
failedOnly = failedOnly,
)
if (!response.isSuccessful) error("Passwort-Reset-Diagnose konnte nicht geladen werden.")
response.body() ?: error("Leere Antwort vom Server.")
},
save = cache::putPasswordResetDiagnostics,
cached = cache::getPasswordResetDiagnostics,
save = { response ->
if (canUseSharedCache) cache.putPasswordResetDiagnostics(response)
},
cached = {
if (canUseSharedCache) {
cache.getPasswordResetDiagnostics(PASSWORD_RESET_DIAGNOSTICS_MAX_AGE_MS)
} else {
null
}
},
fallbackMessage = "Passwort-Reset-Diagnose konnte nicht geladen werden.",
)
}
suspend fun news(): Result<de.harheimertc.data.NewsResponse> =
fetchEncryptedFallback(
@@ -133,37 +206,42 @@ class CmsRepository @Inject constructor(
response.body() ?: de.harheimertc.data.NewsResponse()
},
save = cache::putNews,
cached = cache::getNews,
cached = { cache.getNews(CMS_CACHE_MAX_AGE_MS) },
fallbackMessage = "News konnten nicht geladen werden.",
)
suspend fun saveNews(request: de.harheimertc.data.NewsSaveRequest): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.saveNews(request)
if (!response.isSuccessful) error("News konnten nicht gespeichert werden.")
cache.clearCmsNewsCache()
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
suspend fun createNewsletter(request: de.harheimertc.data.NewsletterCreateRequest): Result<de.harheimertc.data.NewsletterCreateResponse> = runCatching {
val response = api.createNewsletter(request)
if (!response.isSuccessful) error("Newsletter konnte nicht erstellt werden.")
cache.clearNewslettersCache()
response.body() ?: de.harheimertc.data.NewsletterCreateResponse(success = false, message = "Leere Antwort")
}
suspend fun updateNewsletter(id: String, patch: Map<String, Any?>): Result<de.harheimertc.data.NewsletterCreateResponse> = runCatching {
val response = api.updateNewsletter(id, patch)
if (!response.isSuccessful) error("Newsletter konnte nicht aktualisiert werden.")
cache.clearNewslettersCache()
response.body() ?: de.harheimertc.data.NewsletterCreateResponse(success = false, message = "Leere Antwort")
}
suspend fun sendNewsletter(id: String): Result<de.harheimertc.data.NewsletterSendResponse> = runCatching {
val response = api.sendNewsletter(id)
if (!response.isSuccessful) error("Newsletter konnte nicht versendet werden.")
cache.clearNewslettersCache()
response.body() ?: de.harheimertc.data.NewsletterSendResponse(success = false, message = "Leere Antwort")
}
suspend fun deleteNewsletter(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.deleteNewsletter(id)
if (!response.isSuccessful) error("Newsletter konnte nicht gelöscht werden.")
cache.clearNewslettersCache()
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
@@ -171,24 +249,28 @@ class CmsRepository @Inject constructor(
// use generic POST via Retrofit? build request through create endpoint
val response = api.createNewsletterGroup(payload)
if (!response.isSuccessful) error("Gruppe konnte nicht erstellt werden.")
cache.clearNewsletterGroupsCache()
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
suspend fun updateNewsletterGroup(id: String, patch: Map<String, Any?>): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.updateNewsletterGroup(id, patch)
if (!response.isSuccessful) error("Gruppe konnte nicht aktualisiert werden.")
cache.clearNewsletterGroupsCache()
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
suspend fun deleteNewsletterGroup(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.deleteNewsletterGroup(id)
if (!response.isSuccessful) error("Gruppe konnte nicht gelöscht werden.")
cache.clearNewsletterGroupsCache()
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
suspend fun deleteNews(id: Int): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.deleteNews(id)
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
cache.clearCmsNewsCache()
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
@@ -205,3 +287,33 @@ class CmsRepository @Inject constructor(
}
}
}
private fun parseCsv(csv: String): List<List<String>> =
csv.lineSequence().filter(String::isNotBlank).map(::parseCsvLine).toList()
private fun parseCsvLine(line: String): List<String> {
val values = mutableListOf<String>()
val value = StringBuilder()
var quoted = false
var index = 0
while (index < line.length) {
when (val char = line[index]) {
'"' -> {
if (quoted && index + 1 < line.length && line[index + 1] == '"') {
value.append('"')
index++
} else {
quoted = !quoted
}
}
',' -> if (quoted) value.append(char) else {
values += value.toString().trim()
value.clear()
}
else -> value.append(char)
}
index++
}
values += value.toString().trim()
return values
}

View File

@@ -0,0 +1,51 @@
package de.harheimertc.repositories
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import dagger.hilt.android.qualifiers.ApplicationContext
import de.harheimertc.data.HomepageSectionDto
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class HomeLayoutPreferences @Inject constructor(
@param:ApplicationContext private val context: Context,
private val moshi: Moshi,
) {
private val sectionListType = Types.newParameterizedType(List::class.java, HomepageSectionDto::class.java)
private val sectionListAdapter = moshi.adapter<List<HomepageSectionDto>>(sectionListType)
private val preferences by lazy {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"harheimertc_home_layout",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
fun getSections(): List<HomepageSectionDto>? {
val json = preferences.getString(HOME_SECTIONS_KEY, null) ?: return null
return runCatching { sectionListAdapter.fromJson(json) }.getOrNull()
}
fun setSections(sections: List<HomepageSectionDto>) {
val json = sectionListAdapter.toJson(sections)
preferences.edit().putString(HOME_SECTIONS_KEY, json).apply()
}
fun clearSections() {
preferences.edit().remove(HOME_SECTIONS_KEY).apply()
}
private companion object {
const val HOME_SECTIONS_KEY = "home_sections"
}
}

View File

@@ -1,8 +1,11 @@
package de.harheimertc.repositories
import de.harheimertc.data.ApiService
import de.harheimertc.data.HomepageSectionDto
import de.harheimertc.data.NewsDto
import de.harheimertc.data.SeasonDto
import de.harheimertc.data.SpielDto
import de.harheimertc.data.SpielplanResponse
import de.harheimertc.data.TerminDto
import javax.inject.Inject
import javax.inject.Singleton
@@ -10,15 +13,37 @@ import javax.inject.Singleton
data class HomeData(
val termine: List<TerminDto>,
val spiele: List<SpielDto>,
val spielplanSeasons: List<SeasonDto>,
val selectedSpielplanSeason: String?,
val news: List<NewsDto>,
val homepageSections: List<HomepageSectionDto>,
)
@Singleton
class HomeRepository @Inject constructor(private val api: ApiService) {
suspend fun fetchHomeData(): Result<HomeData> = runCatching {
val termine = api.termine().body()?.termine.orEmpty()
val spiele = api.spielplan().body()?.data.orEmpty()
val spielplanResponse = api.spielplan().body()
val spiele = spielplanResponse?.data.orEmpty()
val news = api.publicNews().body()?.news.orEmpty()
HomeData(termine, spiele, news)
val homepageSections = runCatching {
val configResponse = api.config()
if (!configResponse.isSuccessful) return@runCatching emptyList()
configResponse.body()?.homepage?.sections.orEmpty()
}.getOrDefault(emptyList())
HomeData(
termine = termine,
spiele = spiele,
spielplanSeasons = spielplanResponse?.seasons.orEmpty(),
selectedSpielplanSeason = spielplanResponse?.season,
news = news,
homepageSections = homepageSections,
)
}
suspend fun fetchSpielplanForSeason(season: String): Result<SpielplanResponse> = runCatching {
val response = api.spielplan(season)
if (!response.isSuccessful) error("HTTP ${response.code()}")
response.body() ?: error("Leere Antwort")
}
}

View File

@@ -344,6 +344,7 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuT
add(MenuTarget("API-Dokumentation", Destinations.MemberApi.route))
if (state.isAdmin || state.canAccessContactRequests || state.canAccessNewsletter) add(MenuTarget("CMS", Destinations.Cms.route))
// CMS child items (will be rendered when CMS parent is expanded)
if (state.isAdmin || state.canAccessContactRequests || state.canAccessNewsletter) add(MenuTarget("Übersicht", 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))

View File

@@ -20,8 +20,9 @@ import de.harheimertc.ui.components.AppNavigationHeader
fun NavGraph(
navController: NavHostController,
startDestination: String = Destinations.Home.route,
navigationViewModel: NavigationViewModel = hiltViewModel(),
navigationViewModelParam: NavigationViewModel? = null,
) {
val navigationViewModel: NavigationViewModel = navigationViewModelParam ?: hiltViewModel()
val backStackEntry = navController.currentBackStackEntryAsState().value
val route = backStackEntry?.destination?.route
val currentRoute = if (route == Destinations.MannschaftDetail.route) {

View File

@@ -1,5 +1,6 @@
package de.harheimertc.ui.screens.cms
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Spacer
@@ -30,28 +31,37 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedButton
import androidx.navigation.NavController
import de.harheimertc.data.CmsUserDto
import de.harheimertc.data.ConfigResponse
import de.harheimertc.data.ContactRequestDto
import de.harheimertc.data.HomepageSectionDto
import de.harheimertc.data.NewsletterDto
import de.harheimertc.data.NewsletterGroupDto
import de.harheimertc.data.PasswordResetMatchingUserDto
import de.harheimertc.data.PasswordResetAttemptDto
import de.harheimertc.data.PasswordResetStepDto
import de.harheimertc.ui.components.FormMessages
import de.harheimertc.ui.components.NativeRichTextEditor
import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.repositories.MeisterschaftResult
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 java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
@Composable
fun CmsDashboardScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
@@ -65,9 +75,79 @@ fun CmsDashboardScreen(navController: NavController, showBackNavigation: Boolean
@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")
val config = state.config
val sections = remember { mutableStateListOf<HomepageSectionDto>() }
LaunchedEffect(config) {
sections.clear()
sections.addAll(normalizedHomepageSections(config))
}
CmsPage(navController, showBackNavigation, "Startseite konfigurieren", "Legen Sie die Reihenfolge der Elemente auf der Startseite fest.") {
when {
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) }
else -> {
item {
Button(
onClick = {
viewModel.saveConfig(
config.copy(
homepage = config.homepage.copy(sections = sections.toList()),
),
)
},
enabled = !state.saving,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (state.saving) "Speichert..." else "Speichern")
}
}
item {
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
Column(
modifier = Modifier.fillMaxWidth().padding(18.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text("Verfügbare Elemente", style = MaterialTheme.typography.titleLarge, color = Accent900)
Text(
"Verwenden Sie die Positions-Buttons, um die Reihenfolge zu ändern, oder blenden Sie Elemente aus.",
color = Accent500,
)
sections.forEachIndexed { index, section ->
HomepageSectionCard(
section = section,
index = index,
lastIndex = sections.lastIndex,
onMoveUp = {
if (index > 0) {
val current = sections.removeAt(index)
sections.add(index - 1, current)
}
},
onMoveDown = {
if (index < sections.lastIndex) {
val current = sections.removeAt(index)
sections.add(index + 1, current)
}
},
onEnabledChange = { enabled ->
sections[index] = section.copy(enabled = enabled)
},
)
}
Surface(color = Primary100, shape = RoundedCornerShape(12.dp)) {
Text(
"Deaktivierte Elemente bleiben in der Konfiguration erhalten, werden aber auf der Startseite nicht angezeigt.",
color = Primary600,
modifier = Modifier.fillMaxWidth().padding(14.dp),
)
}
FormMessages(state.error, state.message)
}
}
}
}
}
}
}
@@ -138,26 +218,337 @@ fun CmsInhalteScreen(navController: NavController, showBackNavigation: Boolean,
@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.")
val results = remember { mutableStateListOf<MeisterschaftResult>() }
var selectedYear by remember { mutableStateOf("Alle Jahre") }
var editDialogOpen by remember { mutableStateOf(false) }
var noteDialogOpen by remember { mutableStateOf(false) }
var editingOriginal by remember { mutableStateOf<MeisterschaftResult?>(null) }
var noteYear by remember { mutableStateOf("") }
var year by remember { mutableStateOf("") }
var category by remember { mutableStateOf("") }
var rank by remember { mutableStateOf("") }
var playerOne by remember { mutableStateOf("") }
var playerTwo by remember { mutableStateOf("") }
var note by remember { mutableStateOf("") }
var imageOne by remember { mutableStateOf("") }
var imageTwo by remember { mutableStateOf("") }
LaunchedEffect(state.meisterschaften) {
results.clear()
results.addAll(state.meisterschaften)
}
val years = results.map { it.year }.filter { it.isNotBlank() }.distinct().sortedDescending()
val visibleResults = if (selectedYear == "Alle Jahre") results.toList() else results.filter { it.year == selectedYear }
val groupedResults = visibleResults
.groupBy { it.year }
.toSortedMap(compareByDescending { it })
fun resetEditor() {
editingOriginal = null
year = ""
category = ""
rank = ""
playerOne = ""
playerTwo = ""
note = ""
imageOne = ""
imageTwo = ""
}
fun saveAll() {
viewModel.saveVereinsmeisterschaften(results.toList())
}
CmsPage(navController, showBackNavigation, "Vereinsmeisterschaften", "Ergebnisse und Hinweise als CSV-Inhalt bearbeiten") {
when {
state.loading -> item { CircularProgressIndicator(color = Primary600) }
else -> {
item {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
Button(onClick = {
resetEditor()
editDialogOpen = true
}, modifier = Modifier.weight(1f)) {
Text("Ergebnis hinzufügen")
}
Button(onClick = { saveAll() }, enabled = !state.saving, modifier = Modifier.weight(1f)) {
Text(if (state.saving) "Speichert..." else "Speichern")
}
}
}
item {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedButton(onClick = { selectedYear = "Alle Jahre" }) { Text("Alle Jahre") }
years.forEach { yearValue ->
OutlinedButton(onClick = { selectedYear = yearValue }) { Text(yearValue) }
}
}
}
if (groupedResults.isEmpty()) {
item { EmptyCard("Keine Vereinsmeisterschaften gefunden.") }
}
groupedResults.forEach { (yearValue, yearResults) ->
item {
val yearNote = yearResults.firstOrNull { it.category.isBlank() && it.rank.isBlank() && it.playerOne.isBlank() && it.playerTwo.isBlank() && it.note.isNotBlank() }
DataCard(yearValue) {
yearNote?.let { noteEntry ->
Surface(color = Color(0xFFFEF3C7), shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth()) {
Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(noteEntry.note, color = Color(0xFF92400E))
TextButton(onClick = {
noteYear = yearValue
note = noteEntry.note
noteDialogOpen = true
}) { Text("Bemerkung bearbeiten") }
}
}
} ?: TextButton(onClick = {
noteYear = yearValue
note = ""
noteDialogOpen = true
}) { Text("Jahresbemerkung hinzufügen") }
yearResults
.filter { it.category.isNotBlank() }
.groupBy { it.category }
.forEach { (categoryValue, categoryResults) ->
Text(categoryValue, style = MaterialTheme.typography.titleMedium, color = Accent900, modifier = Modifier.padding(top = 8.dp))
categoryResults.sortedBy { it.rank.toIntOrNull() ?: Int.MAX_VALUE }.forEach { entry ->
Surface(color = Accent100, shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth()) {
Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("${entry.rank}. ${listOf(entry.playerOne, entry.playerTwo).filter { it.isNotBlank() }.joinToString(" / ")}", color = Accent900, fontWeight = FontWeight.SemiBold)
if (entry.note.isNotBlank()) Text(entry.note, color = Accent500)
if (entry.imageOne.isNotBlank() || entry.imageTwo.isNotBlank()) {
Text("Bilder: ${listOf(entry.imageOne, entry.imageTwo).filter { it.isNotBlank() }.joinToString(", ")}", color = Accent700)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = {
editingOriginal = entry
year = entry.year
category = entry.category
rank = entry.rank
playerOne = entry.playerOne
playerTwo = entry.playerTwo
note = entry.note
imageOne = entry.imageOne
imageTwo = entry.imageTwo
editDialogOpen = true
}) { Text("Bearbeiten") }
TextButton(onClick = {
results.remove(entry)
saveAll()
}) { Text("Löschen") }
}
}
}
}
}
}
}
}
item { FormMessages(state.error, state.message) }
}
}
}
if (editDialogOpen) {
AlertDialog(
onDismissRequest = { editDialogOpen = false },
title = { Text(if (editingOriginal == null) "Neues Ergebnis" else "Ergebnis bearbeiten") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedTextField(value = year, onValueChange = { year = it }, label = { Text("Jahr") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = category, onValueChange = { category = it }, label = { Text("Kategorie") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = rank, onValueChange = { rank = it }, label = { Text("Platz") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = playerOne, onValueChange = { playerOne = it }, label = { Text("Spieler 1") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = playerTwo, onValueChange = { playerTwo = it }, label = { Text("Spieler 2") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = note, onValueChange = { note = it }, label = { Text("Bemerkung") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = imageOne, onValueChange = { imageOne = it }, label = { Text("Bilddatei Spieler 1") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = imageTwo, onValueChange = { imageTwo = it }, label = { Text("Bilddatei Spieler 2") }, modifier = Modifier.fillMaxWidth())
}
},
confirmButton = {
Button(onClick = {
val updated = MeisterschaftResult(
year = year,
category = category,
rank = rank,
playerOne = playerOne,
playerTwo = playerTwo,
note = note,
imageOne = imageOne,
imageTwo = imageTwo,
)
editingOriginal?.let { original -> results.remove(original) }
results.add(updated)
editDialogOpen = false
saveAll()
}) { Text("Speichern") }
},
dismissButton = {
TextButton(onClick = { editDialogOpen = false }) { Text("Abbrechen") }
},
)
}
if (noteDialogOpen) {
AlertDialog(
onDismissRequest = { noteDialogOpen = false },
title = { Text("Jahresbemerkung") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedTextField(value = noteYear, onValueChange = { noteYear = it }, label = { Text("Jahr") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = note, onValueChange = { note = it }, label = { Text("Bemerkung") }, modifier = Modifier.fillMaxWidth(), minLines = 3)
}
},
confirmButton = {
Button(onClick = {
results.removeAll { it.year == noteYear && it.category.isBlank() && it.rank.isBlank() && it.playerOne.isBlank() && it.playerTwo.isBlank() }
if (note.isNotBlank()) {
results.add(
MeisterschaftResult(
year = noteYear,
category = "",
rank = "",
playerOne = "",
playerTwo = "",
note = note,
imageOne = "",
imageTwo = "",
),
)
}
noteDialogOpen = false
saveAll()
}) { Text("Speichern") }
},
dismissButton = {
TextButton(onClick = { noteDialogOpen = false }) { Text("Abbrechen") }
},
)
}
}
@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")
val config = state.config
var ortName by remember { mutableStateOf("") }
var ortStrasse by remember { mutableStateOf("") }
var ortPlz by remember { mutableStateOf("") }
var ortOrt by remember { mutableStateOf("") }
val trainingTimes = remember { mutableStateListOf<de.harheimertc.data.TrainingTimeDto>() }
val trainers = remember { mutableStateListOf<de.harheimertc.data.TrainerDto>() }
LaunchedEffect(config) {
config?.let {
ortName = it.training.ort.name
ortStrasse = it.training.ort.strasse
ortPlz = it.training.ort.plz
ortOrt = it.training.ort.ort
trainingTimes.clear()
trainingTimes.addAll(it.training.zeiten)
trainers.clear()
trainers.addAll(it.trainer)
}
}
CmsPage(navController, showBackNavigation, "Sportbetrieb", "Trainingszeiten, Trainingsort und Trainer pflegen") {
when {
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) }
else -> {
item {
Button(
onClick = {
viewModel.saveConfig(
config.copy(
training = config.training.copy(
ort = config.training.ort.copy(
name = ortName,
strasse = ortStrasse,
plz = ortPlz,
ort = ortOrt,
),
zeiten = trainingTimes.toList(),
),
trainer = trainers.toList(),
),
)
},
enabled = !state.saving,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (state.saving) "Speichert..." else "Speichern")
}
}
item {
DataCard("Trainingsort") {
OutlinedTextField(value = ortName, onValueChange = { ortName = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = ortStrasse, onValueChange = { ortStrasse = it }, label = { Text("Straße") }, modifier = Modifier.fillMaxWidth())
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(value = ortPlz, onValueChange = { ortPlz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f))
OutlinedTextField(value = ortOrt, onValueChange = { ortOrt = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f))
}
}
}
item {
DataCard("Trainingszeiten") {
trainingTimes.forEachIndexed { index, zeit ->
TrainingTimeEditorCard(
zeit = zeit,
onChange = { updated -> trainingTimes[index] = updated },
onRemove = { trainingTimes.removeAt(index) },
)
}
Button(
onClick = {
trainingTimes.add(
de.harheimertc.data.TrainingTimeDto(
id = "training-${System.currentTimeMillis()}",
tag = "Montag",
),
)
},
modifier = Modifier.fillMaxWidth(),
) {
Text("Trainingszeit hinzufügen")
}
}
}
item {
DataCard("Trainer") {
trainers.forEachIndexed { index, trainer ->
TrainerEditorCard(
trainer = trainer,
onChange = { updated -> trainers[index] = updated },
onRemove = { trainers.removeAt(index) },
)
}
Button(
onClick = {
trainers.add(
de.harheimertc.data.TrainerDto(
id = "trainer-${System.currentTimeMillis()}",
),
)
},
modifier = Modifier.fillMaxWidth(),
) {
Text("Trainer hinzufügen")
}
}
}
item { FormMessages(state.error, state.message) }
}
}
}
}
@Composable
fun CmsMitgliederverwaltungScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsState()
CmsUserListPage(navController, showBackNavigation, "Mitgliederverwaltung", state.users)
CmsUserListPage(navController, showBackNavigation, "Mitgliederverwaltung", state.users, viewModel)
}
@Composable
@@ -166,16 +557,23 @@ fun CmsContactRequestsScreen(navController: NavController, showBackNavigation: B
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]) }
items(state.contactRequests.size) { index -> ContactRequestCard(state.contactRequests[index], viewModel) }
}
}
@Composable
fun CmsNewsletterScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
fun CmsNewsletterScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: CmsViewModel = hiltViewModel(),
canWriteOverride: Boolean? = null,
) {
val state by viewModel.state.collectAsState()
val loginVm: de.harheimertc.ui.screens.login.LoginViewModel = hiltViewModel()
val loginState by loginVm.state.collectAsState()
val canWrite = loginState.roles.any { it == "admin" || it == "vorstand" }
val canWrite = canWriteOverride ?: run {
val loginVm: de.harheimertc.ui.screens.login.LoginViewModel = hiltViewModel()
val loginState by loginVm.state.collectAsState()
loginState.roles.any { it == "admin" || it == "vorstand" }
}
// dialog state for newsletters
var newsletterDialogOpen by remember { mutableStateOf(false) }
var editingNewsletter by remember { mutableStateOf<NewsletterDto?>(null) }
@@ -346,26 +744,204 @@ fun CmsNewsletterScreen(navController: NavController, showBackNavigation: Boolea
@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" })
val config = state.config
var vereinName by remember { mutableStateOf("") }
var useVorsitzenderAddress by remember { mutableStateOf(false) }
var vereinStrasse by remember { mutableStateOf("") }
var vereinPlz by remember { mutableStateOf("") }
var vereinOrt by remember { mutableStateOf("") }
var websiteVorname by remember { mutableStateOf("") }
var websiteNachname by remember { mutableStateOf("") }
var websiteEmail by remember { mutableStateOf("") }
LaunchedEffect(config) {
config?.let {
vereinName = it.verein.name
useVorsitzenderAddress = it.verein.useVorsitzenderAddress
vereinStrasse = it.verein.strasse
vereinPlz = it.verein.plz
vereinOrt = it.verein.ort
websiteVorname = it.website.verantwortlicher.vorname
websiteNachname = it.website.verantwortlicher.nachname
websiteEmail = it.website.verantwortlicher.email
}
}
CmsPage(navController, showBackNavigation, "Einstellungen", "Vereins- und Website-Konfiguration") {
when {
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) }
else -> {
item {
Button(
onClick = {
viewModel.saveConfig(
config.copy(
verein = config.verein.copy(
name = vereinName,
useVorsitzenderAddress = useVorsitzenderAddress,
strasse = vereinStrasse,
plz = vereinPlz,
ort = vereinOrt,
),
website = config.website.copy(
verantwortlicher = config.website.verantwortlicher.copy(
vorname = websiteVorname,
nachname = websiteNachname,
email = websiteEmail,
),
),
),
)
},
enabled = !state.saving,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (state.saving) "Speichert..." else "Speichern")
}
}
item {
DataCard("Vereinsdaten") {
OutlinedTextField(value = vereinName, onValueChange = { vereinName = it }, label = { Text("Vereinsname") }, modifier = Modifier.fillMaxWidth())
Row {
Checkbox(checked = useVorsitzenderAddress, onCheckedChange = { useVorsitzenderAddress = it })
Text("Adresse des 1. Vorsitzenden verwenden", modifier = Modifier.padding(top = 12.dp))
}
if (!useVorsitzenderAddress) {
OutlinedTextField(value = vereinStrasse, onValueChange = { vereinStrasse = it }, label = { Text("Straße") }, modifier = Modifier.fillMaxWidth())
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(value = vereinPlz, onValueChange = { vereinPlz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f))
OutlinedTextField(value = vereinOrt, onValueChange = { vereinOrt = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f))
}
}
}
}
item {
DataCard("Website") {
OutlinedTextField(value = websiteVorname, onValueChange = { websiteVorname = it }, label = { Text("Vorname") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = websiteNachname, onValueChange = { websiteNachname = it }, label = { Text("Nachname") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = websiteEmail, onValueChange = { websiteEmail = it }, label = { Text("E-Mail") }, modifier = Modifier.fillMaxWidth())
}
}
item {
DataCard("Systemstatus") {
InfoRow("Mitgliedschaftstarife", config.mitgliedschaft.size.toString())
InfoRow("Trainer", config.trainer.size.toString())
InfoRow("Trainingszeiten", config.training.zeiten.size.toString())
}
}
item { FormMessages(state.error, state.message) }
}
}
}
}
@Composable
fun CmsBenutzerScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsState()
CmsUserListPage(navController, showBackNavigation, "Benutzer", state.users)
CmsUserListPage(navController, showBackNavigation, "Benutzer", state.users, viewModel)
}
@Composable
fun CmsPasswordResetDiagnosticsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
var searchTerm by remember { mutableStateOf("") }
var failedOnly by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
searchTerm = state.passwordResetSearchTerm
failedOnly = state.passwordResetFailedOnly
}
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]) }
item {
DataCard("Filter") {
OutlinedTextField(
value = searchTerm,
onValueChange = { searchTerm = it },
label = { Text("E-Mail oder Name") },
modifier = Modifier.fillMaxWidth(),
)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
Row(modifier = Modifier.weight(1f)) {
Checkbox(checked = failedOnly, onCheckedChange = { failedOnly = it })
Text("Nur Auffälligkeiten", color = Accent700, modifier = Modifier.padding(top = 12.dp))
}
Button(
onClick = { viewModel.loadPasswordResetDiagnostics(searchTerm, failedOnly) },
enabled = !state.loading,
) {
Text(if (state.loading) "Lädt..." else "Prüfen")
}
}
Text(
"Diagnoseeinträge werden nach ${state.passwordResetRetentionHours} Stunden automatisch gelöscht. E-Mail-Adressen sind maskiert.",
color = Accent500,
)
FormMessages(state.error, state.message)
}
}
if (state.loading) {
item { CircularProgressIndicator(color = Primary600) }
}
if (state.passwordResetSearchTerm.isNotBlank()) {
item {
DataCard("Passende Benutzerkonten") {
if (state.passwordResetMatchingUsers.isEmpty()) {
Text("Kein Login-Benutzer zur Suche gefunden.", color = Accent500)
} else {
state.passwordResetMatchingUsers.forEach { user ->
MatchingUserRow(
user = user,
onSearchLogs = {
searchTerm = user.email
viewModel.loadPasswordResetDiagnostics(user.email, failedOnly)
},
)
}
}
}
}
}
item {
DataCard("Reset-Vorgänge") {
InfoRow("Einträge", state.passwordResetAttempts.size.toString())
OutlinedButton(
onClick = { viewModel.loadPasswordResetDiagnostics(searchTerm, failedOnly) },
enabled = !state.loading,
) {
Text("Aktualisieren")
}
OutlinedButton(
onClick = {
val shareText = buildPasswordResetShareText(
attempts = state.passwordResetAttempts,
failedOnly = failedOnly,
searchTerm = state.passwordResetSearchTerm,
)
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_SUBJECT, "Passwort-Reset-Diagnose")
putExtra(Intent.EXTRA_TEXT, shareText)
}
context.startActivity(Intent.createChooser(shareIntent, "Diagnose teilen"))
},
enabled = state.passwordResetAttempts.isNotEmpty(),
) {
Text("Export/Teilen")
}
}
}
if (!state.loading && state.passwordResetAttempts.isEmpty()) {
item { EmptyCard("Keine Diagnosevorgänge gefunden.") }
}
items(state.passwordResetAttempts.size) { index ->
PasswordAttemptCard(state.passwordResetAttempts[index])
}
}
}
@@ -394,6 +970,72 @@ private fun CmsSummaryGrid(navController: NavController, state: CmsUiState) {
}
}
private val defaultHomepageSections = listOf(
HomepageSectionDto(id = "banner", enabled = true),
HomepageSectionDto(id = "aktuelles", enabled = true),
HomepageSectionDto(id = "termine", enabled = true),
HomepageSectionDto(id = "spiele", enabled = true),
HomepageSectionDto(id = "kontakt", enabled = true),
HomepageSectionDto(id = "training", enabled = false),
HomepageSectionDto(id = "links", enabled = false),
HomepageSectionDto(id = "vereinsmeisterschaften", enabled = false),
)
private val homepageSectionLabels = mapOf(
"banner" to ("Banner (Willkommen)" to "Hero-Bereich mit Willkommensnachricht"),
"aktuelles" to ("Aktuelles" to "Öffentliche News und Ankündigungen"),
"termine" to ("Kommende Termine" to "Vorschau der nächsten Vereinstermine"),
"spiele" to ("Nächste Spiele" to "Vorschau der kommenden Punktspiele"),
"kontakt" to ("Kontakt-Boxen" to "Mitglied werden und Kontakt aufnehmen"),
"training" to ("Training-Teaser" to "Direktzugang zu Training, Trainern und Anfängerbereich"),
"links" to ("Links-Teaser" to "Direktzugang zu nützlichen Vereinslinks"),
"vereinsmeisterschaften" to ("Vereinsmeisterschaften-Teaser" to "Direktzugang zu Meisterschaftsergebnissen"),
)
private fun normalizedHomepageSections(config: ConfigResponse?): List<HomepageSectionDto> {
val configured = config?.homepage?.sections.orEmpty().filter { it.id.isNotBlank() }
val knownIds = configured.map { it.id }.toMutableSet()
return buildList {
addAll(configured)
defaultHomepageSections.forEach { section ->
if (knownIds.add(section.id)) add(section)
}
}
}
@Composable
private fun HomepageSectionCard(
section: HomepageSectionDto,
index: Int,
lastIndex: Int,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
onEnabledChange: (Boolean) -> Unit,
) {
val (label, description) = homepageSectionLabels[section.id]
?: (section.id to "Unbekanntes Element aus der bestehenden Konfiguration")
Surface(color = Color(0xFFF8FAFC), shape = RoundedCornerShape(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth().padding(14.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(label, style = MaterialTheme.typography.titleMedium, color = Accent900)
Text(description, color = Accent500)
}
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = onMoveUp, enabled = index > 0) { Text("Hoch") }
OutlinedButton(onClick = onMoveDown, enabled = index < lastIndex) { Text("Runter") }
}
Row {
Checkbox(checked = section.enabled, onCheckedChange = onEnabledChange)
Text("Anzeigen", color = Accent700, modifier = Modifier.padding(top = 12.dp))
}
}
}
}
@Composable
fun CmsPage(
navController: NavController,
@@ -445,8 +1087,13 @@ private fun CmsConfigPage(
}
@Composable
private fun CmsUserListPage(navController: NavController, showBackNavigation: Boolean, title: String, users: List<CmsUserDto>) {
val viewModel: CmsViewModel = hiltViewModel()
private fun CmsUserListPage(
navController: NavController,
showBackNavigation: Boolean,
title: String,
users: List<CmsUserDto>,
viewModel: CmsViewModel,
) {
val state by viewModel.state.collectAsState()
var sortAsc by remember { mutableStateOf(true) }
@@ -460,7 +1107,7 @@ private fun CmsUserListPage(navController: NavController, showBackNavigation: Bo
.let { if (sortAsc) it else it.asReversed() }
CmsPage(navController, showBackNavigation, title, "Registrierte Zugänge und Rollen") {
if (state.users.isEmpty()) item { EmptyCard("Keine Benutzer gefunden oder keine Berechtigung.") }
if (users.isEmpty()) item { EmptyCard("Keine Benutzer gefunden oder keine Berechtigung.") }
// Sort controls
item {
@@ -540,8 +1187,7 @@ private fun UserCard(user: CmsUserDto, viewModel: CmsViewModel) {
}
@Composable
private fun ContactRequestCard(request: ContactRequestDto) {
val viewModel: CmsViewModel = hiltViewModel()
private fun ContactRequestCard(request: ContactRequestDto, viewModel: CmsViewModel) {
var replyOpen by remember { mutableStateOf(false) }
var replyText by remember { mutableStateOf("") }
val state by viewModel.state.collectAsState()
@@ -584,7 +1230,6 @@ private fun ContactRequestCard(request: ContactRequestDto) {
@Composable
private fun NewsletterCard(newsletter: NewsletterDto, onEdit: (NewsletterDto) -> Unit = {}, onDelete: (String) -> Unit = {}, onSend: (String) -> Unit = {}) {
val viewModel: CmsViewModel = hiltViewModel()
DataCard(newsletter.subject.ifBlank { newsletter.title.ifBlank { newsletter.id } }) {
InfoRow("Status", newsletter.status ?: if (newsletter.sentAt != null) "versendet" else "Entwurf")
InfoRow("Erstellt", newsletter.createdAt ?: "-")
@@ -614,12 +1259,126 @@ private fun NewsletterGroupCard(group: NewsletterGroupDto, onEdit: (NewsletterGr
@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())
InfoRow("Status", if (attempt.failed) "Auffällig" else "Abgeschlossen")
InfoRow("Gestartet", formatDiagnosticsDateTime(attempt.startedAt))
InfoRow("IP", attempt.ip ?: "-")
attempt.steps.forEach { step ->
Surface(color = Color(0xFFF8FAFC), shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth()) {
Column(Modifier.fillMaxWidth().padding(10.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(formatDiagnosticsTime(step.ts), color = Accent500)
Text(stepLabel(step.step), color = Accent900, fontWeight = FontWeight.SemiBold)
Text(statusLabel(step.status), color = stepStatusColor(step.status))
val detail = reasonLabel(step.reason).ifBlank { stepErrorLabel(step) }
if (detail.isNotBlank()) {
Text(detail, color = Accent700)
}
}
}
}
}
}
@Composable
private fun MatchingUserRow(user: PasswordResetMatchingUserDto, onSearchLogs: () -> Unit) {
Surface(color = Color(0xFFF8FAFC), shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth()) {
Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(user.name.ifBlank { "Unbenannter Benutzer" }, color = Accent900, fontWeight = FontWeight.SemiBold)
Text("${user.email} · ${if (user.active) "Aktiv" else "Nicht freigeschaltet"}", color = Accent500)
OutlinedButton(onClick = onSearchLogs) {
Text("Logs dieser Adresse")
}
}
}
}
private fun formatDiagnosticsDateTime(value: String?): String {
if (value.isNullOrBlank()) return "-"
return runCatching {
val parsed = OffsetDateTime.parse(value)
parsed.format(DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss", Locale.GERMANY))
}.getOrElse { value }
}
private fun formatDiagnosticsTime(value: String?): String {
if (value.isNullOrBlank()) return "-"
return runCatching {
val parsed = OffsetDateTime.parse(value)
parsed.format(DateTimeFormatter.ofPattern("HH:mm:ss", Locale.GERMANY))
}.getOrElse { value }
}
private fun stepLabel(step: String): String = when (step) {
"request_received" -> "Anfrage"
"request_validation" -> "Validierung"
"rate_limit" -> "Rate Limit"
"user_lookup" -> "Benutzersuche"
"temporary_password" -> "Temporäres Passwort"
"password_storage" -> "Passwortspeicherung"
"session_revocation" -> "Sitzungen"
"mail_configuration" -> "Mail-Konfiguration"
"mail_send" -> "Mail-Versand"
"request_completed" -> "Abschluss"
else -> step
}
private fun statusLabel(status: String): String = when (status) {
"started" -> "Gestartet"
"checking" -> "Prüfung"
"passed" -> "OK"
"found" -> "Gefunden"
"not_found" -> "Nicht gefunden"
"generated" -> "Erzeugt"
"completed" -> "Erledigt"
"success" -> "Erfolgreich"
"no_account" -> "Kein Konto"
"failed" -> "Fehlgeschlagen"
else -> status
}
private fun stepStatusColor(status: String): Color = when (status) {
"failed", "not_found", "no_account" -> Color(0xFFB91C1C)
else -> Accent700
}
private fun reasonLabel(reason: String?): String = when (reason.orEmpty()) {
"email_missing" -> "E-Mail-Adresse fehlt"
"smtp_credentials_missing" -> "SMTP-Zugangsdaten fehlen"
"write_failed" -> "Passwort konnte nicht gespeichert werden"
else -> reason.orEmpty()
}
private fun stepErrorLabel(step: PasswordResetStepDto): String =
listOfNotNull(step.errorCode, step.errorMessage).joinToString(": ")
private fun buildPasswordResetShareText(
attempts: List<PasswordResetAttemptDto>,
failedOnly: Boolean,
searchTerm: String,
): String {
val header = buildString {
appendLine("Passwort-Reset-Diagnose")
appendLine("Filter: ${if (failedOnly) "Nur Auffälligkeiten" else "Alle"}")
appendLine("Suche: ${searchTerm.ifBlank { "-" }}")
appendLine("Einträge: ${attempts.size}")
appendLine()
}
val body = attempts.joinToString("\n\n") { attempt ->
buildString {
appendLine("Request: ${attempt.requestId}")
appendLine("Adresse: ${attempt.emailMasked ?: "-"}")
appendLine("Status: ${if (attempt.failed) "Auffällig" else "Abgeschlossen"}")
appendLine("Gestartet: ${formatDiagnosticsDateTime(attempt.startedAt)}")
appendLine("IP: ${attempt.ip ?: "-"}")
appendLine("Schritte:")
attempt.steps.forEach { step ->
val detail = reasonLabel(step.reason).ifBlank { stepErrorLabel(step) }
appendLine("- ${formatDiagnosticsTime(step.ts)} | ${stepLabel(step.step)} | ${statusLabel(step.status)}${if (detail.isNotBlank()) " | $detail" else ""}")
}
}
}
return header + body
}
@Composable
private fun DataCard(title: String, content: @Composable ColumnScope.() -> Unit) {
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
@@ -650,4 +1409,41 @@ private fun EmptyCard(message: String) {
}
}
@Composable
private fun TrainingTimeEditorCard(
zeit: de.harheimertc.data.TrainingTimeDto,
onChange: (de.harheimertc.data.TrainingTimeDto) -> Unit,
onRemove: () -> Unit,
) {
Surface(color = Color(0xFFF8FAFC), shape = RoundedCornerShape(12.dp)) {
Column(Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedTextField(value = zeit.tag, onValueChange = { onChange(zeit.copy(tag = it)) }, label = { Text("Tag") }, modifier = Modifier.fillMaxWidth())
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(value = zeit.von, onValueChange = { onChange(zeit.copy(von = it)) }, label = { Text("Von") }, modifier = Modifier.weight(1f))
OutlinedTextField(value = zeit.bis, onValueChange = { onChange(zeit.copy(bis = it)) }, label = { Text("Bis") }, modifier = Modifier.weight(1f))
}
OutlinedTextField(value = zeit.gruppe, onValueChange = { onChange(zeit.copy(gruppe = it)) }, label = { Text("Gruppe") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = zeit.info.orEmpty(), onValueChange = { onChange(zeit.copy(info = it)) }, label = { Text("Zusatzinfo") }, modifier = Modifier.fillMaxWidth())
OutlinedButton(onClick = onRemove, modifier = Modifier.fillMaxWidth()) { Text("Entfernen") }
}
}
}
@Composable
private fun TrainerEditorCard(
trainer: de.harheimertc.data.TrainerDto,
onChange: (de.harheimertc.data.TrainerDto) -> Unit,
onRemove: () -> Unit,
) {
Surface(color = Color(0xFFF8FAFC), shape = RoundedCornerShape(12.dp)) {
Column(Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedTextField(value = trainer.name, onValueChange = { onChange(trainer.copy(name = it)) }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = trainer.lizenz, onValueChange = { onChange(trainer.copy(lizenz = it)) }, label = { Text("Lizenz") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = trainer.schwerpunkt, onValueChange = { onChange(trainer.copy(schwerpunkt = it)) }, label = { Text("Schwerpunkt") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = trainer.zusatz.orEmpty(), onValueChange = { onChange(trainer.copy(zusatz = it)) }, label = { Text("Zusatz") }, modifier = Modifier.fillMaxWidth())
OutlinedButton(onClick = onRemove, modifier = Modifier.fillMaxWidth()) { Text("Entfernen") }
}
}
}
private fun textState(value: String): String = if (value.isBlank()) "Nicht gesetzt" else "${value.length} Zeichen"

View File

@@ -8,10 +8,12 @@ import de.harheimertc.data.ConfigResponse
import de.harheimertc.data.ContactRequestDto
import de.harheimertc.data.NewsletterDto
import de.harheimertc.data.NewsletterGroupDto
import de.harheimertc.data.PasswordResetMatchingUserDto
import de.harheimertc.data.PasswordResetAttemptDto
import de.harheimertc.data.NewsDto
import de.harheimertc.data.NewsSaveRequest
import de.harheimertc.repositories.CmsRepository
import de.harheimertc.repositories.MeisterschaftResult
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -30,7 +32,12 @@ data class CmsUiState(
val newsletters: List<NewsletterDto> = emptyList(),
val newsletterGroups: List<NewsletterGroupDto> = emptyList(),
val passwordResetAttempts: List<PasswordResetAttemptDto> = emptyList(),
val passwordResetMatchingUsers: List<PasswordResetMatchingUserDto> = emptyList(),
val passwordResetRetentionHours: Int = 72,
val passwordResetSearchTerm: String = "",
val passwordResetFailedOnly: Boolean = true,
val news: List<NewsDto> = emptyList(),
val meisterschaften: List<MeisterschaftResult> = emptyList(),
)
@HiltViewModel
@@ -54,7 +61,13 @@ class CmsViewModel @Inject constructor(
val newslettersRes = async { repository.newsletters() }
val groupsRes = async { repository.newsletterGroups() }
val newsRes = async { repository.news() }
val diagnosticsRes = async { repository.passwordResetDiagnostics() }
val diagnosticsRes = async {
repository.passwordResetDiagnostics(
email = _state.value.passwordResetSearchTerm.takeIf { it.isNotBlank() },
failedOnly = _state.value.passwordResetFailedOnly,
)
}
val meisterschaftenRes = async { repository.vereinsmeisterschaften() }
val configResult = configRes.await()
val usersResult = usersRes.await()
@@ -63,6 +76,7 @@ class CmsViewModel @Inject constructor(
val groupsResult = groupsRes.await()
val newsResult = newsRes.await()
val diagnosticsResult = diagnosticsRes.await()
val meisterschaftenResult = meisterschaftenRes.await()
val errors = listOfNotNull(
ErrorMapper.mapError(configResult.exceptionOrNull()),
@@ -72,6 +86,7 @@ class CmsViewModel @Inject constructor(
ErrorMapper.mapError(groupsResult.exceptionOrNull()),
ErrorMapper.mapError(newsResult.exceptionOrNull()),
ErrorMapper.mapError(diagnosticsResult.exceptionOrNull()),
ErrorMapper.mapError(meisterschaftenResult.exceptionOrNull()),
)
// Sort users so that pending (inactive) users come first,
@@ -92,10 +107,67 @@ class CmsViewModel @Inject constructor(
newsletterGroups = groupsResult.getOrNull()?.groups.orEmpty(),
news = newsResult.getOrNull()?.news.orEmpty(),
passwordResetAttempts = diagnosticsResult.getOrNull()?.attempts.orEmpty(),
passwordResetMatchingUsers = diagnosticsResult.getOrNull()?.matchingUsers.orEmpty(),
passwordResetRetentionHours = diagnosticsResult.getOrNull()?.retentionHours ?: 72,
passwordResetSearchTerm = diagnosticsResult.getOrNull()?.searchedEmail.orEmpty(),
passwordResetFailedOnly = _state.value.passwordResetFailedOnly,
meisterschaften = meisterschaftenResult.getOrNull().orEmpty(),
)
}
}
fun loadPasswordResetDiagnostics(email: String, failedOnly: Boolean) {
viewModelScope.launch {
_state.value = _state.value.copy(
loading = true,
error = null,
passwordResetSearchTerm = email.trim(),
passwordResetFailedOnly = failedOnly,
)
repository.passwordResetDiagnostics(
email = email.trim().takeIf { it.isNotBlank() },
failedOnly = failedOnly,
)
.onSuccess { response ->
_state.value = _state.value.copy(
loading = false,
passwordResetRetentionHours = response.retentionHours,
passwordResetMatchingUsers = response.matchingUsers,
passwordResetAttempts = response.attempts,
passwordResetSearchTerm = response.searchedEmail.orEmpty(),
)
}
.onFailure { err ->
_state.value = _state.value.copy(
loading = false,
passwordResetMatchingUsers = emptyList(),
passwordResetAttempts = emptyList(),
error = ErrorMapper.mapError(err) ?: "Passwort-Reset-Diagnose konnte nicht geladen werden.",
)
}
}
}
fun saveVereinsmeisterschaften(results: List<MeisterschaftResult>) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
repository.saveVereinsmeisterschaften(results)
.onSuccess { response ->
_state.value = _state.value.copy(
saving = false,
meisterschaften = results,
message = response.message ?: "Vereinsmeisterschaften gespeichert.",
)
}
.onFailure { err ->
_state.value = _state.value.copy(
saving = false,
error = ErrorMapper.mapError(err) ?: "Vereinsmeisterschaften konnten nicht gespeichert werden.",
)
}
}
}
fun saveConfig(config: ConfigResponse) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)

View File

@@ -21,16 +21,20 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -49,7 +53,9 @@ import de.harheimertc.ui.navigation.NavigationViewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
import de.harheimertc.BuildConfig
import de.harheimertc.data.HomepageSectionDto
import de.harheimertc.data.NewsDto
import de.harheimertc.data.SeasonDto
import de.harheimertc.data.SpielDto
import de.harheimertc.data.TerminDto
import de.harheimertc.ui.components.AppNavigationHeader
@@ -77,6 +83,7 @@ fun HomeScreen(
val navigationState by navigationViewModel.state.collectAsState()
val state by viewModel.state.collectAsState()
var selectedNews by remember { mutableStateOf<NewsDto?>(null) }
var editHomeSections by rememberSaveable { mutableStateOf(false) }
selectedNews?.let { item ->
AlertDialog(
@@ -104,36 +111,345 @@ fun HomeScreen(
)
}
}
item { WebHero() }
item {
HomeTermineSection(
termine = state.termine,
loading = state.loading,
onAll = { navController.navigate(Destinations.Termine.route) },
HomeCustomizationSection(
sections = state.homepageSections,
spielplanSeasons = state.spielplanSeasons,
spielplanTeamsBySeason = state.spielplanTeamsBySeason,
editEnabled = editHomeSections,
onToggleEdit = { editHomeSections = !editHomeSections },
onMoveUp = viewModel::moveSectionUp,
onMoveDown = viewModel::moveSectionDown,
onEnabledChange = viewModel::setSectionEnabled,
onAddSpielplanWidget = viewModel::addSpielplanTeamWidget,
onUpdateSpielplanWidget = viewModel::updateSpielplanTeamWidget,
onReset = viewModel::resetSections,
)
}
item {
HomeGamesSection(
spiele = state.spiele,
loading = state.loading,
onAll = { navController.navigate(Destinations.Spielplan.route) },
)
state.homepageSections.forEachIndexed { index, section ->
if (!section.enabled) return@forEachIndexed
val sectionKey = homeSectionKey(section)
when (section.id) {
"banner" -> item(key = "home_section_${sectionKey}_$index") { WebHero() }
"termine" -> item(key = "home_section_${sectionKey}_$index") {
HomeTermineSection(
termine = state.termine,
loading = state.loading,
onAll = { navController.navigate(Destinations.Termine.route) },
)
}
"spiele" -> item(key = "home_section_${sectionKey}_$index") {
HomeGamesSection(
spiele = state.spiele,
loading = state.loading,
onAll = { navController.navigate(Destinations.Spielplan.route) },
)
}
"aktuelles" -> {
if (state.news.isNotEmpty()) {
item(key = "home_section_${sectionKey}_$index") {
HomeNewsSection(
news = state.news,
onOpen = { selectedNews = it },
)
}
}
}
"kontakt" -> item(key = "home_section_${sectionKey}_$index") {
HomeActionSection(
onMembership = { navController.navigate(Destinations.Membership.route) },
onContact = { navController.navigate(Destinations.Contact.route) },
)
}
"training" -> item(key = "home_section_${sectionKey}_$index") {
HomeExtraActionSection(
title = "Training & Einstieg",
body = "Alle Infos zu Trainingszeiten, Trainern und unserem Anfängerangebot.",
action = "Zum Training",
onClick = { navController.navigate(Destinations.Training.route) },
)
}
"links" -> item(key = "home_section_${sectionKey}_$index") {
HomeExtraActionSection(
title = "Nützliche Links",
body = "Direkter Zugang zu Verbänden, Ergebnisdiensten und hilfreichen Portalen.",
action = "Links öffnen",
onClick = { navController.navigate(Destinations.Links.route) },
)
}
"vereinsmeisterschaften" -> item(key = "home_section_${sectionKey}_$index") {
HomeExtraActionSection(
title = "Vereinsmeisterschaften",
body = "Ergebnisse und Historie unserer Vereinsmeisterschaften.",
action = "Ergebnisse ansehen",
onClick = { navController.navigate(Destinations.Vereinsmeisterschaften.route) },
)
}
"spielplan_team" -> item(key = "home_section_${sectionKey}_$index") {
HomeSpielplanTeamWidgetSection(
section = section,
spiele = state.spielplanWidgetPreviews[sectionKey].orEmpty(),
error = state.spielplanWidgetErrors[sectionKey],
loading = state.widgetsLoading,
onOpenAll = { navController.navigate(Destinations.Spielplan.route) },
)
}
}
}
if (state.news.isNotEmpty()) {
item {
HomeNewsSection(
news = state.news,
onOpen = { selectedNews = it },
item { HomeFooter() }
}
}
@Composable
private fun HomeCustomizationSection(
sections: List<HomepageSectionDto>,
spielplanSeasons: List<SeasonDto>,
spielplanTeamsBySeason: Map<String, List<HomeSpielplanTeamOption>>,
editEnabled: Boolean,
onToggleEdit: () -> Unit,
onMoveUp: (String) -> Unit,
onMoveDown: (String) -> Unit,
onEnabledChange: (String, Boolean) -> Unit,
onAddSpielplanWidget: (season: String, teamName: String, teamAgeGroup: String) -> Unit,
onUpdateSpielplanWidget: (sectionKey: String, season: String, teamName: String, teamAgeGroup: String) -> Unit,
onReset: () -> Unit,
) {
var addSeason by rememberSaveable { mutableStateOf("") }
var addTeamKey by rememberSaveable { mutableStateOf("") }
val addTeamOptions = spielplanTeamsBySeason[addSeason].orEmpty()
LaunchedEffect(spielplanSeasons) {
if (addSeason.isBlank() || spielplanSeasons.none { it.slug == addSeason }) {
addSeason = spielplanSeasons.firstOrNull()?.slug.orEmpty()
}
}
LaunchedEffect(addSeason, addTeamOptions) {
if (addTeamOptions.none { teamOptionKey(it) == addTeamKey }) {
addTeamKey = addTeamOptions.firstOrNull()?.let(::teamOptionKey).orEmpty()
}
}
Surface(
color = Color(0xFFFAFAFA),
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
OutlinedButton(onClick = onToggleEdit) {
Text(if (editEnabled) "Startseiten-Editor schließen" else "Startseite anpassen")
}
if (editEnabled) {
Text(
"Elemente ein-/ausblenden und Reihenfolge festlegen.",
style = MaterialTheme.typography.bodyMedium,
color = Accent700,
)
sections.forEachIndexed { index, section ->
val label = homeSectionLabels[section.id] ?: section.id
val sectionKey = homeSectionKey(section)
Surface(color = Color.White, shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(label, style = MaterialTheme.typography.titleMedium, color = Accent900)
Text(section.id, style = MaterialTheme.typography.labelSmall, color = Accent500)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Anzeigen", color = Accent700, style = MaterialTheme.typography.labelSmall)
androidx.compose.material3.Checkbox(
checked = section.enabled,
onCheckedChange = { enabled -> onEnabledChange(sectionKey, enabled) },
)
}
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
OutlinedButton(onClick = { onMoveUp(sectionKey) }, enabled = index > 0) { Text("Hoch") }
OutlinedButton(onClick = { onMoveDown(sectionKey) }, enabled = index < sections.lastIndex) { Text("Runter") }
}
}
if (section.id == "spielplan_team") {
SpielplanWidgetConfigEditor(
section = section,
seasons = spielplanSeasons,
teamsBySeason = spielplanTeamsBySeason,
onUpdate = { season, teamName, teamAgeGroup ->
onUpdateSpielplanWidget(sectionKey, season, teamName, teamAgeGroup)
},
)
}
}
}
}
Surface(color = Color.White, shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Widget hinzufügen", style = MaterialTheme.typography.titleMedium, color = Accent900)
Text("Spielplan Mannschaft", style = MaterialTheme.typography.bodyMedium, color = Accent700)
SimpleSelector(
label = "Saison",
selected = spielplanSeasons.firstOrNull { it.slug == addSeason }?.let { formatSeasonLabel(it.slug) }
?: "Bitte wählen",
options = spielplanSeasons.map { season ->
SelectOption(
key = season.slug,
label = formatSeasonLabel(season.slug),
)
},
onSelect = { option ->
addSeason = option.key
},
)
SimpleSelector(
label = "Mannschaft",
selected = addTeamOptions.firstOrNull { teamOptionKey(it) == addTeamKey }?.label ?: "Bitte wählen",
options = addTeamOptions.map { team ->
SelectOption(
key = teamOptionKey(team),
label = team.label,
)
},
onSelect = { option ->
addTeamKey = option.key
},
)
Button(
onClick = {
val selectedTeam = addTeamOptions.firstOrNull { teamOptionKey(it) == addTeamKey } ?: return@Button
onAddSpielplanWidget(addSeason, selectedTeam.teamName, selectedTeam.teamAgeGroup)
},
enabled = addSeason.isNotBlank() && addTeamKey.isNotBlank(),
) {
Text("Widget hinzufügen")
}
}
}
TextButton(onClick = onReset) {
Text("Auf Server-Standard zurücksetzen")
}
}
}
}
}
private data class SelectOption(
val key: String,
val label: String,
)
@Composable
private fun SimpleSelector(
label: String,
selected: String,
options: List<SelectOption>,
onSelect: (SelectOption) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(label, style = MaterialTheme.typography.labelSmall, color = Accent500)
OutlinedButton(onClick = { expanded = true }, enabled = options.isNotEmpty()) {
Text(selected)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEach { option ->
DropdownMenuItem(
text = { Text(option.label) },
onClick = {
onSelect(option)
expanded = false
},
)
}
}
item {
HomeActionSection(
onMembership = { navController.navigate(Destinations.Membership.route) },
onContact = { navController.navigate(Destinations.Contact.route) },
)
}
}
@Composable
private fun SpielplanWidgetConfigEditor(
section: HomepageSectionDto,
seasons: List<SeasonDto>,
teamsBySeason: Map<String, List<HomeSpielplanTeamOption>>,
onUpdate: (season: String, teamName: String, teamAgeGroup: String) -> Unit,
) {
val selectedSeason = section.config?.season.orEmpty()
val selectedTeamName = section.config?.teamName.orEmpty()
val selectedTeamAgeGroup = section.config?.teamAgeGroup.orEmpty()
val teamOptions = teamsBySeason[selectedSeason].orEmpty()
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
SimpleSelector(
label = "Saison",
selected = seasons.firstOrNull { it.slug == selectedSeason }?.let { formatSeasonLabel(it.slug) } ?: "Bitte wählen",
options = seasons.map { season -> SelectOption(season.slug, formatSeasonLabel(season.slug)) },
onSelect = { option ->
val fallbackTeam = teamsBySeason[option.key].orEmpty().firstOrNull()
onUpdate(
option.key,
fallbackTeam?.teamName ?: "",
fallbackTeam?.teamAgeGroup ?: "",
)
},
)
SimpleSelector(
label = "Mannschaft",
selected = teamOptions.firstOrNull {
it.teamName == selectedTeamName && it.teamAgeGroup == selectedTeamAgeGroup
}?.label ?: "Bitte wählen",
options = teamOptions.map { team -> SelectOption(teamOptionKey(team), team.label) },
onSelect = { option ->
val selectedTeam = teamOptions.firstOrNull { teamOptionKey(it) == option.key }
if (selectedTeam != null) {
onUpdate(selectedSeason, selectedTeam.teamName, selectedTeam.teamAgeGroup)
}
},
)
}
}
@Composable
private fun HomeSpielplanTeamWidgetSection(
section: HomepageSectionDto,
spiele: List<SpielDto>,
error: String?,
loading: Boolean,
onOpenAll: () -> Unit,
) {
val teamName = section.config?.teamName.orEmpty()
val teamAgeGroup = section.config?.teamAgeGroup.orEmpty()
val title = if (teamAgeGroup.contains("jugend", ignoreCase = true) && teamName.isNotBlank()) {
"Spielplan: (J) $teamName"
} else {
"Spielplan: ${teamName.ifBlank { "Mannschaft" }}"
}
val season = section.config?.season.orEmpty()
HomeSection(title = title, subtitle = "Saison ${formatSeasonLabel(season)}", background = Color.White) {
if (loading) {
LoadingRow("Spiele werden geladen...")
} else if (!error.isNullOrBlank()) {
EmptyRow(error)
} else if (spiele.isEmpty()) {
EmptyRow("Keine kommenden Spiele für diese Mannschaft gefunden.")
} else {
spiele.forEach { spiel -> MatchCard(spiel) }
}
item { HomeFooter() }
PrimaryAction("Voller Spielplan", onOpenAll)
}
}
@@ -251,6 +567,18 @@ private fun HomeActionSection(onMembership: () -> Unit, onContact: () -> Unit) {
}
}
@Composable
private fun HomeExtraActionSection(title: String, body: String, action: String, onClick: () -> Unit) {
HomeSection(title = null, background = Color.White) {
ActionCard(
title = title,
body = body,
action = action,
onClick = onClick,
)
}
}
@Composable
private fun HomeSection(
title: String?,
@@ -427,3 +755,27 @@ private fun formatNewsDate(value: String?): String {
SimpleDateFormat("dd. MMMM yyyy", Locale.GERMANY).format(source.parse(value.take(10))!!)
}.getOrDefault(value.take(10))
}
private fun homeSectionKey(section: HomepageSectionDto): String =
section.key?.takeIf { it.isNotBlank() } ?: section.id
private fun teamOptionKey(option: HomeSpielplanTeamOption): String =
"${option.teamName}|${option.teamAgeGroup}"
private fun formatSeasonLabel(value: String): String {
val match = Regex("^(\\d{2})--(\\d{2})$").find(value)
if (match == null) return value.ifBlank { "-" }
return "20${match.groupValues[1]}/${match.groupValues[2]}"
}
private val homeSectionLabels = mapOf(
"banner" to "Banner (Willkommen)",
"aktuelles" to "Aktuelles",
"termine" to "Kommende Termine",
"spiele" to "Nächste Spiele",
"kontakt" to "Kontakt-Boxen",
"training" to "Training-Teaser",
"links" to "Links-Teaser",
"vereinsmeisterschaften" to "Vereinsmeisterschaften-Teaser",
"spielplan_team" to "Widget: Spielplan Mannschaft",
)

View File

@@ -3,30 +3,58 @@ package de.harheimertc.ui.screens.home
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.HomepageSectionConfigDto
import de.harheimertc.data.HomepageSectionDto
import de.harheimertc.data.NewsDto
import de.harheimertc.data.SeasonDto
import de.harheimertc.data.SpielDto
import de.harheimertc.data.TerminDto
import de.harheimertc.repositories.HomeLayoutPreferences
import de.harheimertc.repositories.HomeRepository
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.UUID
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class HomeSpielplanTeamOption(
val teamName: String,
val teamAgeGroup: String,
) {
val label: String
get() = if (teamAgeGroup.contains("jugend", ignoreCase = true)) {
"(J) $teamName"
} else {
teamName
}
}
data class HomeUiState(
val loading: Boolean = true,
val termine: List<TerminDto> = emptyList(),
val spiele: List<SpielDto> = emptyList(),
val news: List<NewsDto> = emptyList(),
val homepageSections: List<HomepageSectionDto> = defaultHomepageSections,
val spielplanSeasons: List<SeasonDto> = emptyList(),
val spielplanTeamsBySeason: Map<String, List<HomeSpielplanTeamOption>> = emptyMap(),
val spielplanWidgetPreviews: Map<String, List<SpielDto>> = emptyMap(),
val spielplanWidgetErrors: Map<String, String> = emptyMap(),
val widgetsLoading: Boolean = false,
val error: Boolean = false,
)
@HiltViewModel
class HomeViewModel @Inject constructor(private val repository: HomeRepository) : ViewModel() {
class HomeViewModel @Inject constructor(
private val repository: HomeRepository,
private val layoutPreferences: HomeLayoutPreferences,
) : ViewModel() {
private val _state = MutableStateFlow(HomeUiState())
val state: StateFlow<HomeUiState> = _state
private var serverSections: List<HomepageSectionDto> = defaultHomepageSections
private val seasonGamesCache = mutableMapOf<String, List<SpielDto>>()
init {
load()
@@ -37,6 +65,19 @@ class HomeViewModel @Inject constructor(private val repository: HomeRepository)
_state.value = _state.value.copy(loading = true, error = false)
repository.fetchHomeData()
.onSuccess { data ->
serverSections = normalizedHomepageSections(data.homepageSections)
val sections = mergeWithUserSections(
server = serverSections,
user = layoutPreferences.getSections(),
)
seasonGamesCache.clear()
data.selectedSpielplanSeason?.takeIf { it.isNotBlank() }?.let { season ->
seasonGamesCache[season] = data.spiele
}
val widgetData = loadWidgetData(
sections = sections,
seasons = data.spielplanSeasons,
)
_state.value = HomeUiState(
loading = false,
termine = data.termine
@@ -53,6 +94,11 @@ class HomeViewModel @Inject constructor(private val repository: HomeRepository)
.sortedBy { it.asDate() }
.take(3),
news = data.news.take(3),
homepageSections = sections,
spielplanSeasons = widgetData.seasons,
spielplanTeamsBySeason = widgetData.teamsBySeason,
spielplanWidgetPreviews = widgetData.previewGamesBySectionKey,
spielplanWidgetErrors = widgetData.errorsBySectionKey,
)
}
.onFailure {
@@ -60,8 +106,189 @@ class HomeViewModel @Inject constructor(private val repository: HomeRepository)
}
}
}
fun moveSectionUp(sectionKey: String) {
updateSections { sections ->
val index = sections.indexOfFirst { sectionKey(it) == sectionKey }
if (index <= 0) return@updateSections sections
sections.toMutableList().also { list ->
val current = list.removeAt(index)
list.add(index - 1, current)
}
}
}
fun moveSectionDown(sectionKey: String) {
updateSections { sections ->
val index = sections.indexOfFirst { sectionKey(it) == sectionKey }
if (index < 0 || index >= sections.lastIndex) return@updateSections sections
sections.toMutableList().also { list ->
val current = list.removeAt(index)
list.add(index + 1, current)
}
}
}
fun setSectionEnabled(sectionKey: String, enabled: Boolean) {
updateSections { sections ->
sections.map { section ->
if (sectionKey(section) == sectionKey) section.copy(enabled = enabled) else section
}
}
}
fun addSpielplanTeamWidget(season: String, teamName: String, teamAgeGroup: String) {
val normalizedSeason = season.trim()
val normalizedTeamName = teamName.trim()
if (normalizedSeason.isBlank() || normalizedTeamName.isBlank()) return
val newSection = HomepageSectionDto(
id = WIDGET_SECTION_ID,
key = "${WIDGET_SECTION_ID}_${UUID.randomUUID()}",
enabled = true,
config = HomepageSectionConfigDto(
season = normalizedSeason,
teamName = normalizedTeamName,
teamAgeGroup = teamAgeGroup.trim(),
),
)
updateSections { sections -> sections + newSection }
refreshWidgetData()
}
fun updateSpielplanTeamWidget(
sectionKey: String,
season: String,
teamName: String,
teamAgeGroup: String,
) {
updateSections { sections ->
sections.map { section ->
if (sectionKey(section) != sectionKey) return@map section
section.copy(
config = HomepageSectionConfigDto(
season = season.trim(),
teamName = teamName.trim(),
teamAgeGroup = teamAgeGroup.trim(),
),
)
}
}
refreshWidgetData()
}
fun resetSections() {
val reset = serverSections
layoutPreferences.clearSections()
_state.value = _state.value.copy(homepageSections = reset)
refreshWidgetData()
}
private fun updateSections(transform: (List<HomepageSectionDto>) -> List<HomepageSectionDto>) {
val updated = transform(_state.value.homepageSections)
if (updated == _state.value.homepageSections) return
layoutPreferences.setSections(updated)
_state.value = _state.value.copy(homepageSections = updated)
}
private fun refreshWidgetData() {
viewModelScope.launch {
_state.value = _state.value.copy(widgetsLoading = true)
val widgetData = loadWidgetData(
sections = _state.value.homepageSections,
seasons = _state.value.spielplanSeasons,
)
_state.value = _state.value.copy(
spielplanSeasons = widgetData.seasons,
spielplanTeamsBySeason = widgetData.teamsBySeason,
spielplanWidgetPreviews = widgetData.previewGamesBySectionKey,
spielplanWidgetErrors = widgetData.errorsBySectionKey,
widgetsLoading = false,
)
}
}
private suspend fun loadWidgetData(
sections: List<HomepageSectionDto>,
seasons: List<SeasonDto>,
): HomeWidgetData {
val allSeasons = seasons
.filter { it.slug.isNotBlank() }
.distinctBy { it.slug }
.toMutableList()
seasonGamesCache.keys.forEach { slug ->
if (allSeasons.none { it.slug == slug }) {
allSeasons += SeasonDto(slug = slug, label = slug)
}
}
val neededWidgetSeasons = sections
.asSequence()
.filter { it.id == WIDGET_SECTION_ID }
.mapNotNull { it.config?.season?.takeIf(String::isNotBlank) }
.toSet()
allSeasons.forEach { season ->
ensureSeasonLoaded(season.slug)
}
neededWidgetSeasons.forEach { season ->
if (allSeasons.none { it.slug == season }) {
allSeasons += SeasonDto(slug = season, label = season)
}
ensureSeasonLoaded(season)
}
val teamsBySeason = buildMap {
allSeasons.forEach { season ->
val games = seasonGamesCache[season.slug] ?: return@forEach
put(season.slug, extractHarheimerTeams(games))
}
}
val previews = mutableMapOf<String, List<SpielDto>>()
val errors = mutableMapOf<String, String>()
sections.forEach { section ->
if (section.id != WIDGET_SECTION_ID) return@forEach
val key = sectionKey(section)
val config = section.config
val season = config?.season.orEmpty()
val teamName = config?.teamName.orEmpty()
if (season.isBlank() || teamName.isBlank()) {
errors[key] = "Bitte Saison und Mannschaft wählen."
previews[key] = emptyList()
return@forEach
}
val games = seasonGamesCache[season]
if (games == null) {
errors[key] = "Spielplan konnte nicht geladen werden."
previews[key] = emptyList()
return@forEach
}
previews[key] = filterUpcomingTeamGames(games, teamName, config?.teamAgeGroup.orEmpty())
}
return HomeWidgetData(
seasons = allSeasons,
teamsBySeason = teamsBySeason,
previewGamesBySectionKey = previews,
errorsBySectionKey = errors,
)
}
private suspend fun ensureSeasonLoaded(season: String) {
if (seasonGamesCache.containsKey(season)) return
repository.fetchSpielplanForSeason(season).onSuccess { response ->
seasonGamesCache[season] = response.data
}
}
}
private data class HomeWidgetData(
val seasons: List<SeasonDto>,
val teamsBySeason: Map<String, List<HomeSpielplanTeamOption>>,
val previewGamesBySectionKey: Map<String, List<SpielDto>>,
val errorsBySectionKey: Map<String, String>,
)
private fun TerminDto.asDateTime(): LocalDateTime? = runCatching {
val time = uhrzeit?.takeIf { it.matches(Regex("\\d{2}:\\d{2}")) } ?: "00:00"
LocalDateTime.parse("$datum $time", DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
@@ -70,3 +297,110 @@ private fun TerminDto.asDateTime(): LocalDateTime? = runCatching {
fun SpielDto.asDate(): LocalDate? = runCatching {
LocalDate.parse(termin.substringBefore(' '), DateTimeFormatter.ofPattern("dd.MM.yyyy"))
}.getOrNull()
private val defaultHomepageSections = listOf(
HomepageSectionDto(id = "banner", key = "banner", enabled = true),
HomepageSectionDto(id = "aktuelles", key = "aktuelles", enabled = true),
HomepageSectionDto(id = "termine", key = "termine", enabled = true),
HomepageSectionDto(id = "spiele", key = "spiele", enabled = true),
HomepageSectionDto(id = "kontakt", key = "kontakt", enabled = true),
HomepageSectionDto(id = "training", key = "training", enabled = false),
HomepageSectionDto(id = "links", key = "links", enabled = false),
HomepageSectionDto(id = "vereinsmeisterschaften", key = "vereinsmeisterschaften", enabled = false),
)
private fun normalizedHomepageSections(configuredSections: List<HomepageSectionDto>): List<HomepageSectionDto> {
val configured = configuredSections
.filter { it.id.isNotBlank() }
.mapIndexed { index, section ->
val fallback = if (section.id == WIDGET_SECTION_ID) "${section.id}_${index + 1}" else section.id
section.copy(key = section.key?.takeIf { it.isNotBlank() } ?: fallback)
}
val knownIds = configured.map { it.id }.toMutableSet()
return buildList {
addAll(configured)
defaultHomepageSections.forEach { section ->
if (knownIds.add(section.id)) add(section)
}
}
}
private fun mergeWithUserSections(
server: List<HomepageSectionDto>,
user: List<HomepageSectionDto>?,
): List<HomepageSectionDto> {
if (user.isNullOrEmpty()) return server
val serverById = server.associateBy { it.id }
val serverByKey = server.associateBy { sectionKey(it) }
val ordered = buildList<HomepageSectionDto> {
user.forEach { userSection ->
val matchedServerSection = serverByKey[sectionKey(userSection)]
?: if (userSection.id == WIDGET_SECTION_ID) null else serverById[userSection.id]
if (matchedServerSection != null) {
if (none { sectionKey(it) == sectionKey(matchedServerSection) }) {
add(
matchedServerSection.copy(
enabled = userSection.enabled,
key = sectionKey(userSection),
config = userSection.config,
),
)
}
return@forEach
}
if (userSection.id == WIDGET_SECTION_ID && none { sectionKey(it) == sectionKey(userSection) }) {
add(
userSection.copy(
key = userSection.key?.takeIf { it.isNotBlank() }
?: "${WIDGET_SECTION_ID}_${UUID.randomUUID()}",
),
)
}
}
server.forEach { serverSection ->
if (none { sectionKey(it) == sectionKey(serverSection) }) add(serverSection)
}
}
return ordered.ifEmpty { server }
}
private fun sectionKey(section: HomepageSectionDto): String =
section.key?.takeIf { it.isNotBlank() } ?: section.id
private fun extractHarheimerTeams(games: List<SpielDto>): List<HomeSpielplanTeamOption> =
games
.flatMap { game ->
listOf(
HomeSpielplanTeamOption(game.heimMannschaft.trim(), game.heimAltersklasse.trim()),
HomeSpielplanTeamOption(game.gastMannschaft.trim(), game.gastAltersklasse.trim()),
)
}
.filter { option -> option.teamName.contains("Harheimer TC", ignoreCase = true) }
.filter { option -> option.teamName.isNotBlank() }
.distinctBy { "${it.teamName}|${it.teamAgeGroup}" }
.sortedBy { it.label }
private fun filterUpcomingTeamGames(
games: List<SpielDto>,
teamName: String,
teamAgeGroup: String,
): List<SpielDto> {
val normalizedTeam = teamName.trim()
val normalizedAgeGroup = teamAgeGroup.trim()
val today = LocalDate.now()
return games
.asSequence()
.filter { game ->
val homeMatch = game.heimMannschaft.trim() == normalizedTeam &&
(normalizedAgeGroup.isBlank() || game.heimAltersklasse.trim() == normalizedAgeGroup)
val awayMatch = game.gastMannschaft.trim() == normalizedTeam &&
(normalizedAgeGroup.isBlank() || game.gastAltersklasse.trim() == normalizedAgeGroup)
homeMatch || awayMatch
}
.filter { game -> game.asDate()?.let { !it.isBefore(today) } == true }
.sortedBy { it.asDate() }
.take(5)
.toList()
}
private const val WIDGET_SECTION_ID = "spielplan_team"

View File

@@ -38,7 +38,8 @@ class CmsViewModelTest {
coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse())
coEvery { repo.newsletterGroups() } returns Result.success(de.harheimertc.data.NewsletterGroupsResponse())
coEvery { repo.news() } returns Result.success(de.harheimertc.data.NewsResponse(success = true, news = listOf(de.harheimertc.data.NewsDto(id = 5, title = "T", content = "C"))))
coEvery { repo.passwordResetDiagnostics() } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
coEvery { repo.passwordResetDiagnostics(any(), any()) } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList())
val vm = CmsViewModel(repo)
// advance init launched coroutine
@@ -61,7 +62,8 @@ class CmsViewModelTest {
coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse())
coEvery { repo.newsletterGroups() } returns Result.success(de.harheimertc.data.NewsletterGroupsResponse())
coEvery { repo.news() } returns Result.success(de.harheimertc.data.NewsResponse(success = true, news = listOf()))
coEvery { repo.passwordResetDiagnostics() } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
coEvery { repo.passwordResetDiagnostics(any(), any()) } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList())
coEvery { repo.saveConfig(any()) } returns Result.success(cfg)
coEvery { repo.saveNews(any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "ok"))
val vm = CmsViewModel(repo)
@@ -89,7 +91,8 @@ class CmsViewModelTest {
coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse())
coEvery { repo.newsletterGroups() } returns Result.success(de.harheimertc.data.NewsletterGroupsResponse())
coEvery { repo.news() } returns Result.success(de.harheimertc.data.NewsResponse(success = true, news = listOf()))
coEvery { repo.passwordResetDiagnostics() } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
coEvery { repo.passwordResetDiagnostics(any(), any()) } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList())
coEvery { repo.saveNews(any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "saved"))
val vm = CmsViewModel(repo)
@@ -113,7 +116,8 @@ class CmsViewModelTest {
coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse())
coEvery { repo.newsletterGroups() } returns Result.success(de.harheimertc.data.NewsletterGroupsResponse())
coEvery { repo.news() } returns Result.success(de.harheimertc.data.NewsResponse(success = true, news = listOf()))
coEvery { repo.passwordResetDiagnostics() } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
coEvery { repo.passwordResetDiagnostics(any(), any()) } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList())
coEvery { repo.updateUserRoles(any(), any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "roles updated"))
coEvery { repo.users() } returns Result.success(de.harheimertc.data.CmsUsersResponse(listOf(de.harheimertc.data.CmsUserDto(id = "1", email = "u@e", name = "U", roles = listOf("admin", "vorstand")))))
@@ -140,7 +144,8 @@ class CmsViewModelTest {
coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse())
coEvery { repo.newsletterGroups() } returns Result.success(de.harheimertc.data.NewsletterGroupsResponse())
coEvery { repo.news() } returns Result.success(de.harheimertc.data.NewsResponse(success = true, news = listOf()))
coEvery { repo.passwordResetDiagnostics() } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
coEvery { repo.passwordResetDiagnostics(any(), any()) } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList())
coEvery { repo.updateUserActive(any(), any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "user updated"))
coEvery { repo.users() } returns Result.success(de.harheimertc.data.CmsUsersResponse(listOf(de.harheimertc.data.CmsUserDto(id = "2", email = "v@e", name = "V", active = false))))
@@ -167,7 +172,8 @@ class CmsViewModelTest {
coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse())
coEvery { repo.newsletterGroups() } returns Result.success(de.harheimertc.data.NewsletterGroupsResponse())
coEvery { repo.news() } returns Result.success(de.harheimertc.data.NewsResponse(success = true, news = listOf()))
coEvery { repo.passwordResetDiagnostics() } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
coEvery { repo.passwordResetDiagnostics(any(), any()) } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList())
coEvery { repo.resendInvite(any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "invite sent"))

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long