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:
@@ -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>
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user