diff --git a/ANDROID_KOTLIN_PLAN.md b/ANDROID_KOTLIN_PLAN.md index da1755d..5999d73 100644 --- a/ANDROID_KOTLIN_PLAN.md +++ b/ANDROID_KOTLIN_PLAN.md @@ -280,6 +280,7 @@ Detaillierte Aufgaben (priorisiert): - B5: Config / Seiten (Inhalte) - B5.1: Sichern/Zurücksetzen von Seiteninhalten mit Undo-Hinweis - B5.2: Satzung: PDF-Upload-Feld und native PDF-Viewer-Integration (falls serverseitig gespeichert) + - B5.6: Android-Startseite weiter ausbauen: Nutzer sollen Elemente und Reihenfolge der Startseite selbst zusammenstellen koennen; Detailkonzept und Feinschliff folgen spaeter - B6: Diagnostics / Passwort-Reset-Diagnose - B6.1: Detail-View mit exportierbaren Logs (bei Bedarf) - C1: Offline-/Caching-Strategie @@ -328,33 +329,36 @@ Wenn du willst, trage ich die einzelnen Subtickets in unserem lokalen Issue-Trac - [x] B4.1: Entwurf → Senden Flow mit Preview - [x] B4.2: Gruppenverwaltung (CRUD) - - [ ] B5: Config / Seiten + - [x] B5: Config / Seiten - Web‑Status: Die Web‑UI bietet bereits umfassende CMS‑UIs für `cms/startseite`, `cms/vereinsmeisterschaften`, `cms/sportbetrieb` und `cms/einstellungen` (Drag&Drop, CSV‑Import/Export, Tabbed‑UIs, ImageUpload, native‑like Modals). `cms/startseite` speichert `homepage.sections` via `PUT /api/config`, `vereinsmeisterschaften` arbeitet mit CSV‑Export/Import, `sportbetrieb` kapselt Termine/Mannschaften/Spielpläne in Tabs, `einstellungen` ist ein umfangreicher Config‑Editor. - - Android‑Status: In der Android‑App sind diese Bereiche derzeit nur rudimentär bzw. als Platzhalter umgesetzt (Startseite, Vereinsmeisterschaften, Sportbetrieb, Einstellungen, Passwort‑Reset‑Diagnose fehlen noch als vollwertige Admin‑Tools). - - Konkrete Android‑ToDos (B5.x): - - B5.1: `cms/startseite` (Startseiten‑Layout) - - Implementieren: Reorderable list + Visibility Toggle, Save → `PUT /api/config` (`homepage.sections`), Lade/Save‑Snackbar, Undo/Historie. - - B5.2: `cms/vereinsmeisterschaften` - - Implementieren: CSV‑Load/Parser, UI zur Anzeige gruppiert nach Jahr/Kategorie, Modal für Ergebnis‑CRUD, CSV Export via `/api/cms/save-csv`. - - B5.3: `cms/sportbetrieb` - - Implementieren: Tabbed UI (Termine / Mannschaften / Spielpläne), Wiederverwendung von bestehenden native Komponenten (`TermineScreen`, `MannschaftenScreen`, `SpielplanScreen`) und Admin‑Modi (Add/Edit/Delete). - - B5.4: `cms/einstellungen` - - Implementieren: Tabbed Config Editor (Vereinsdaten, Training, Trainer, Mitgliedschaft), ImageUpload, PDF‑Feld für Satzung, Validierung + Save/Preview. - - B5.5: Roundtrip & Tests - - Roundtrip‑Tests: RichText ↔ Web (Quill/HTML), CSV parser/tests für Vereinsmeisterschaften, ViewModel‑Unit‑Tests und Compose‑UI‑smoke tests für Save/Load flows. + - Android‑Status: Implementiert — die Android‑App enthält native CMS‑Screens (`CmsStartseiteScreen`, `CmsVereinsmeisterschaftenScreen`, `CmsSportbetriebScreen`, `CmsEinstellungenScreen`) mit Save/Load‑Flows via `CmsViewModel`. + - Umsetzung (B5.x): + - [x] B5.1: `cms/startseite` (Startseiten‑Layout) — Reorderable/Visibility + Save → `PUT /api/config` (via `CmsViewModel`). + - [x] B5.2: `cms/vereinsmeisterschaften` — CSV‑Parser/CSV‑Save integration and modal CRUD (native UI present). + - [x] B5.3: `cms/sportbetrieb` — Tabbed UI reusing `Termine`, `Mannschaften`, `Spielplan` components. + - [x] B5.4: `cms/einstellungen` — Tabbed config editor with Vereinsdaten/Training/Trainer/Mitgliedschaft and save. + - [x] B5.5: Roundtrip & Tests — basic ViewModel unit tests and roundtrip checks exist; Compose UI smoke tests remain for hardening. + - [x] B5.6: Startseite weiter ausgebaut — zusaetzliche Elemente (`training`, `links`, `vereinsmeisterschaften`) sind konfigurierbar; Android kann Reihenfolge/Sichtbarkeit lokal speichern und Web nutzt Marker (`cookie`, `eingeloggt`) mit marker-spezifischer Persistenz: `eingeloggt` wird als individuelles User-Setting serverseitig gespeichert, `cookie` wird ausschliesslich im Browser-Cookie gehalten. Neu umgesetzt: konfigurierbare Startseiten-Widgets vom Typ `spielplan_team` (Saison + Mannschaft beim Hinzufuegen waehlbar, spaeter jederzeit aenderbar, mehrfach pro Startseite moeglich, persistiert ueber `key` + `config`). - - [ ] B6: Diagnostics / Passwort-Reset-Diagnose (Export/Detail) + - [x] B6: Diagnostics / Passwort-Reset-Diagnose (Export/Detail) - Web‑Status: `cms/passwort-reset-diagnose` zeigt vollständige Diagnose‑UI mit Suche, Maskierung, Filter (nur Auffälligkeiten) und listbaren Reset‑Versuchen; Backend: `/api/cms/password-reset-diagnostics` liefert `matchingUsers`, `attempts`, `retentionHours`. - - Android‑Status: rudimentär/fehlend — Admin‑Diagnose ist nicht vollständig portiert. + - Android‑Status: umgesetzt — native Diagnose‑UI mit Suche (`email`/Name), Filter `Nur Auffälligkeiten`, `matchingUsers`‑Liste mit Schnellfilter, detaillierter Schrittansicht je Versuch (Zeit/Schritt/Status/Grund), Refresh und Share‑Export der maskierten Logs. - Konkrete Android‑ToDos (B6.x): - - B6.1: Implementieren Suche + Filter UI, Rendering der `attempts` mit Zeitstempeln, Status‑Badges und Details. - - B6.2: Logs exportieren / share (falls API Export unterstützt) und Datenschutz: E‑Mail Maskierung beibehalten. + - [x] B6.1: Implementieren Suche + Filter UI, Rendering der `attempts` mit Zeitstempeln, Status‑Badges und Details. + - [x] B6.2: Logs exportieren / share (falls API Export unterstützt) und Datenschutz: E‑Mail Maskierung beibehalten. -- [ ] C1: Offline-/Caching-Strategie (verschlüsselt für geschützte CMS-Daten) -- [ ] C2: Tests & CI - - [ ] C2.1: ViewModel-Unit-Tests für CMS-Flows (`CmsViewModel.load()` / `saveConfig()`) - - [ ] C2.2: Compose-UI-Tests für kritische Flows - - [ ] C2.3: androidTest Hilt-Stubs erweitern (falls nötig) +- [x] C1: Offline-/Caching-Strategie (verschlüsselt für geschützte CMS-Daten) + - Umgesetzt: EncryptedSharedPreferences-basierter Offline-Cache mit Zeitstempel/TTL pro Cache-Key (CMS standard 24h, Reset-Diagnose 6h). + - Umgesetzt: Fallback auf verschlüsselte Cache-Daten bei Ladefehlern nur innerhalb der TTL, um veraltete geschützte CMS-Daten zu begrenzen. + - Umgesetzt: Gezielte Cache-Invalidierung bei schreibenden CMS-Operationen (Konfiguration, Benutzerverwaltung, Kontaktanfragen, Newsletter, interne News), damit Offline-Daten nach Änderungen konsistent bleiben. + - Umgesetzt: Passwort-Reset-Diagnose-Cache wird nur für den Standardfilter (ohne Suchbegriff) verwendet, um falsche Treffer bei gefilterten Diagnosen zu vermeiden. +- [x] C2: Tests & CI + - [x] C2.1: ViewModel-Unit-Tests für CMS-Flows (`CmsViewModel.load()` / `saveConfig()`) + - Status: `:app:testLocalDebugUnitTest` läuft grün; `CmsViewModelTest` wurde auf aktuelle Repository-Signaturen und vollständige `load()`-Abhängigkeiten (inkl. `vereinsmeisterschaften`) aktualisiert. + - [x] C2.2: Compose-UI-Tests für kritische Flows + - Status: neuer Instrumentation-Test für `CmsPasswordResetDiagnosticsScreen` ergänzt (`diagnosticsScreen_showsFilterAndAttemptDetails`) und gezielt per `connectedLocalDebugAndroidTest` erfolgreich ausgeführt. + - [x] C2.3: androidTest Hilt-Stubs erweitern (falls nötig) + - Status: androidTest-ApiService-Stubs und Hilt-Testmodul auf neue `passwordResetDiagnostics(email, failedOnly)`-Signatur erweitert; `:app:assembleLocalDebugAndroidTest` läuft grün. Markiere die Items, wenn erledigt — ich kann die einzelnen Punkte jetzt in Branches/PRs umsetzen. diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts index 702f3e4..90cdb9b 100644 --- a/android-app/app/build.gradle.kts +++ b/android-app/app/build.gradle.kts @@ -135,6 +135,7 @@ dependencies { androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.5.0") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0") // Hilt testing androidTestImplementation("com.google.dagger:hilt-android-testing:2.59.2") // Ensure Hilt runtime is available in the test APK so HiltTestApplication can be instantiated diff --git a/android-app/app/src/androidTest/AndroidManifest.xml b/android-app/app/src/androidTest/AndroidManifest.xml index 1e8e6d5..2dd3e86 100644 --- a/android-app/app/src/androidTest/AndroidManifest.xml +++ b/android-app/app/src/androidTest/AndroidManifest.xml @@ -1,9 +1,7 @@ - + diff --git a/android-app/app/src/androidTest/java/de/harheimertc/test/TestHiltModules.kt b/android-app/app/src/androidTest/java/de/harheimertc/test/TestHiltModules.kt index d8d9cc0..f07a56c 100644 --- a/android-app/app/src/androidTest/java/de/harheimertc/test/TestHiltModules.kt +++ b/android-app/app/src/androidTest/java/de/harheimertc/test/TestHiltModules.kt @@ -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}") } } diff --git a/android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsExistingScreensSmokeTest.kt b/android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsExistingScreensSmokeTest.kt new file mode 100644 index 0000000..8c674f2 --- /dev/null +++ b/android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsExistingScreensSmokeTest.kt @@ -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() + + @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() + 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).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> = Response.success(emptyList()) + override suspend fun postContact(req: ContactRequest): Response = Response.success(ContactResponse(ok = true)) + override suspend fun galerieList(page: Int, perPage: Int): Response = Response.success(GalleryListResponse()) + override suspend fun uploadGalleryImage(image: MultipartBody.Part, title: RequestBody, description: RequestBody, isPublic: RequestBody): Response = Response.success(GalleryUploadResponse()) + override suspend fun termine(): Response = Response.success(TermineResponse()) + override suspend fun spielplan(season: String?): Response = Response.success(SpielplanResponse()) + override suspend fun spielplanTable(team: String, season: String?): Response = Response.success(TeamTableResponse()) + override suspend fun publicNews(): Response = Response.success(NewsPublicResponse()) + override suspend fun memberNews(): Response = Response.success(NewsResponse(success = true, news = emptyList())) + override suspend fun saveNews(request: NewsSaveRequest): Response = Response.success(AuthMessageResponse(success = true, message = "ok")) + override suspend fun deleteNews(id: Int): Response = Response.success(AuthMessageResponse(success = true)) + override suspend fun mannschaften(season: String?): Response = Response.success(null) + override suspend fun config(): Response = Response.success(config) + override suspend fun updateConfig(request: ConfigResponse): Response { + updateConfigCalls++ + return Response.success(request) + } + override suspend fun spielsysteme(): Response = Response.success(null) + override suspend fun vereinsmeisterschaften(): Response = 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 { + saveCsvCalls++ + return Response.success(SaveCsvResponse(success = true, message = "CSV gespeichert")) + } + override suspend fun generateMembershipPdf(request: MembershipRequest): Response = Response.success(MembershipResponse()) + override suspend fun downloadMembershipPdf(downloadUrl: String): Response = Response.success(null) + override suspend fun login(request: LoginRequest): Response = Response.success(LoginResponse()) + override suspend fun logout(request: LogoutRequest): Response = Response.success(Unit) + override suspend fun refresh(request: RefreshRequest): Response = Response.success(LoginResponse()) + override suspend fun authStatus(): Response = Response.success(AuthStatusResponse()) + override suspend fun resetPassword(request: ResetPasswordRequest): Response = Response.success(AuthMessageResponse()) + override suspend fun register(request: RegistrationRequest): Response = Response.success(AuthMessageResponse()) + override suspend fun passkeyAuthenticationOptions(request: PasskeyAuthenticationOptionsRequest): Response = Response.success(null) + override suspend fun passkeyLogin(request: RequestBody): Response = Response.success(LoginResponse()) + override suspend fun passkeys(): Response = Response.success(PasskeysResponse()) + override suspend fun passkeyRegistrationOptions(request: PasskeyRegistrationOptionsRequest): Response = Response.success(null) + override suspend fun registerPasskey(request: RequestBody): Response = Response.success(AuthMessageResponse()) + override suspend fun removePasskey(request: RemovePasskeyRequest): Response = Response.success(AuthMessageResponse()) + override suspend fun profile(): Response = Response.success(ProfileResponse()) + override suspend fun updateProfile(request: ProfileUpdateRequest): Response = Response.success(ProfileResponse()) + override suspend fun birthdays(): Response = Response.success(BirthdaysResponse()) + override suspend fun members(): Response = Response.success(MembersResponse()) + override suspend fun saveMember(request: ApiService.MemberSaveRequest): Response = Response.success(AuthMessageResponse()) + override suspend fun deleteMember(body: Map): Response = Response.success(AuthMessageResponse()) + override suspend fun bulkImportMembers(request: ApiService.BulkImportRequest): Response = Response.success(ApiService.BulkImportResponse()) + override suspend fun toggleMannschaftsspieler(body: Map): Response> = Response.success(emptyMap()) + override suspend fun cmsUsers(): Response = Response.success(CmsUsersResponse(users = users)) + override suspend fun updateUserRoles(request: ApiService.UpdateUserRolesRequest): Response { + updateUserRolesCalls++ + return Response.success(AuthMessageResponse(success = true, message = "Rollen aktualisiert")) + } + override suspend fun updateUserActive(request: ApiService.UpdateUserActiveRequest): Response { + updateUserActiveCalls++ + return Response.success(AuthMessageResponse(success = true, message = "Status aktualisiert")) + } + override suspend fun resendInvite(id: String): Response = Response.success(AuthMessageResponse(success = true)) + override suspend fun contactRequests(): Response> = Response.success(contactRequests) + override suspend fun replyToContactRequest(id: String, request: ApiService.ContactReplyRequest): Response { + replyContactCalls++ + return Response.success(ContactResponse(ok = true, message = "Antwort versendet")) + } + override suspend fun toggleContactRequestStatus(id: String): Response = Response.success(ContactResponse(ok = true, message = "Status aktualisiert")) + override suspend fun newsletters(): Response = Response.success(NewsletterListResponse(success = true, newsletters = newsletters)) + override suspend fun newsletterGroups(): Response = Response.success(NewsletterGroupsResponse(success = true, groups = groups)) + override suspend fun createNewsletterGroup(request: Map): Response = Response.success(AuthMessageResponse(success = true)) + override suspend fun updateNewsletterGroup(id: String, request: Map): Response = Response.success(AuthMessageResponse(success = true)) + override suspend fun deleteNewsletterGroup(id: String): Response = Response.success(AuthMessageResponse(success = true)) + override suspend fun createNewsletter(request: NewsletterCreateRequest): Response { + createNewsletterCalls++ + return Response.success(NewsletterCreateResponse(success = true, message = "Newsletter gespeichert")) + } + override suspend fun updateNewsletter(id: String, request: Map): Response = Response.success(NewsletterCreateResponse(success = true)) + override suspend fun sendNewsletter(id: String): Response = Response.success(NewsletterSendResponse(success = true)) + override suspend fun deleteNewsletter(id: String): Response = Response.success(AuthMessageResponse(success = true)) + override suspend fun publicNewsletterGroups(): Response = Response.success(NewsletterGroupsResponse(success = true, groups = groups)) + override suspend fun subscribeNewsletter(request: NewsletterSubscriptionRequest): Response = Response.success(AuthMessageResponse(success = true)) + override suspend fun unsubscribeNewsletter(request: NewsletterSubscriptionRequest): Response = Response.success(AuthMessageResponse(success = true)) + override suspend fun confirmNewsletter(token: String): Response = Response.success(AuthMessageResponse(success = true)) + override suspend fun passwordResetDiagnostics( + email: String?, + failedOnly: Boolean, + ): Response = Response.success(PasswordResetDiagnosticsResponse(attempts = diagnostics)) +} \ No newline at end of file diff --git a/android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsPasswordResetDiagnosticsScreenTest.kt b/android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsPasswordResetDiagnosticsScreenTest.kt new file mode 100644 index 0000000..d05aa89 --- /dev/null +++ b/android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsPasswordResetDiagnosticsScreenTest.kt @@ -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() + + @Test + fun diagnosticsScreen_showsFilterAndAttemptDetails() { + val api = createDiagnosticsApiService() + val context = ApplicationProvider.getApplicationContext() + 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()) + "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 + } +} diff --git a/android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsStartseiteSmokeTest.kt b/android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsStartseiteSmokeTest.kt new file mode 100644 index 0000000..882dcb5 --- /dev/null +++ b/android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsStartseiteSmokeTest.kt @@ -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() + + @Test + fun cmsStartseite_rendersWithDefaultState() { + // prepare a minimal fake ApiService that returns empty/neutral responses + val fakeApi = object : ApiService { + override suspend fun publicGalleryImages(): Response> = Response.success(emptyList()) + override suspend fun postContact(req: ContactRequest): Response = Response.success(ContactResponse(ok = true)) + override suspend fun galerieList(page: Int, perPage: Int): Response = Response.success(GalleryListResponse()) + override suspend fun uploadGalleryImage(image: MultipartBody.Part, title: RequestBody, description: RequestBody, isPublic: RequestBody): Response = Response.success(GalleryUploadResponse()) + override suspend fun termine(): Response = Response.success(TermineResponse()) + override suspend fun spielplan(season: String?): Response = Response.success(SpielplanResponse()) + override suspend fun spielplanTable(team: String, season: String?): Response = Response.success(TeamTableResponse()) + override suspend fun publicNews(): Response = Response.success(NewsPublicResponse()) + override suspend fun memberNews(): Response = Response.success(NewsResponse(success = true, news = emptyList())) + override suspend fun saveNews(request: NewsSaveRequest): Response = Response.success(AuthMessageResponse(success = true, message = "ok")) + override suspend fun deleteNews(id: Int): Response = Response.success(AuthMessageResponse(success = true)) + override suspend fun mannschaften(season: String?): Response = Response.success(null) + override suspend fun config(): Response = Response.success(ConfigResponse()) + override suspend fun updateConfig(request: ConfigResponse): Response = Response.success(request) + override suspend fun spielsysteme(): Response = Response.success(null) + override suspend fun vereinsmeisterschaften(): Response = Response.success(null) + override suspend fun saveCsv(request: SaveCsvRequest): Response = Response.success(SaveCsvResponse(success = true)) + override suspend fun generateMembershipPdf(request: MembershipRequest): Response = Response.success(MembershipResponse()) + override suspend fun downloadMembershipPdf(downloadUrl: String): Response = Response.success(null) + override suspend fun login(request: LoginRequest): Response = Response.success(LoginResponse()) + override suspend fun logout(request: LogoutRequest): Response = Response.success(Unit) + override suspend fun refresh(request: RefreshRequest): Response = Response.success(LoginResponse()) + override suspend fun authStatus(): Response = Response.success(AuthStatusResponse()) + override suspend fun resetPassword(request: ResetPasswordRequest): Response = Response.success(AuthMessageResponse()) + override suspend fun register(request: RegistrationRequest): Response = Response.success(AuthMessageResponse()) + override suspend fun passkeyAuthenticationOptions(request: PasskeyAuthenticationOptionsRequest): Response = Response.success(null) + override suspend fun passkeyLogin(request: okhttp3.RequestBody): Response = Response.success(LoginResponse()) + override suspend fun passkeys(): Response = Response.success(PasskeysResponse()) + override suspend fun passkeyRegistrationOptions(request: PasskeyRegistrationOptionsRequest): Response = Response.success(null) + override suspend fun registerPasskey(request: okhttp3.RequestBody): Response = Response.success(AuthMessageResponse()) + override suspend fun removePasskey(request: RemovePasskeyRequest): Response = Response.success(AuthMessageResponse()) + override suspend fun profile(): Response = Response.success(ProfileResponse()) + override suspend fun updateProfile(request: ProfileUpdateRequest): Response = Response.success(ProfileResponse()) + override suspend fun birthdays(): Response = Response.success(BirthdaysResponse()) + override suspend fun members(): Response = Response.success(MembersResponse()) + override suspend fun saveMember(request: ApiService.MemberSaveRequest): Response = Response.success(AuthMessageResponse()) + override suspend fun deleteMember(body: Map): Response = Response.success(AuthMessageResponse()) + override suspend fun bulkImportMembers(request: ApiService.BulkImportRequest): Response = Response.success(ApiService.BulkImportResponse()) + override suspend fun toggleMannschaftsspieler(body: Map): Response> = Response.success(emptyMap()) + override suspend fun cmsUsers(): Response = Response.success(CmsUsersResponse()) + override suspend fun updateUserRoles(request: ApiService.UpdateUserRolesRequest): Response = Response.success(AuthMessageResponse()) + override suspend fun updateUserActive(request: ApiService.UpdateUserActiveRequest): Response = Response.success(AuthMessageResponse()) + override suspend fun resendInvite(id: String): Response = Response.success(AuthMessageResponse()) + override suspend fun contactRequests(): Response> = Response.success(emptyList()) + override suspend fun replyToContactRequest(id: String, request: ApiService.ContactReplyRequest): Response = Response.success(de.harheimertc.data.ContactResponse(ok = true)) + override suspend fun toggleContactRequestStatus(id: String): Response = Response.success(de.harheimertc.data.ContactResponse(ok = true)) + override suspend fun newsletters(): Response = Response.success(NewsletterListResponse(success = true, newsletters = emptyList())) + override suspend fun newsletterGroups(): Response = Response.success(NewsletterGroupsResponse(success = true, groups = emptyList())) + override suspend fun createNewsletterGroup(request: Map): Response = Response.success(AuthMessageResponse(success = true)) + override suspend fun updateNewsletterGroup(id: String, request: Map): Response = Response.success(AuthMessageResponse(success = true)) + override suspend fun deleteNewsletterGroup(id: String): Response = Response.success(AuthMessageResponse(success = true)) + override suspend fun createNewsletter(request: NewsletterCreateRequest): Response = Response.success(NewsletterCreateResponse(success = true)) + override suspend fun updateNewsletter(id: String, request: Map): Response = Response.success(NewsletterCreateResponse(success = true)) + override suspend fun sendNewsletter(id: String): Response = Response.success(NewsletterSendResponse(success = true)) + override suspend fun deleteNewsletter(id: String): Response = Response.success(AuthMessageResponse(success = true)) + override suspend fun publicNewsletterGroups(): Response = Response.success(NewsletterGroupsResponse(success = true)) + override suspend fun subscribeNewsletter(request: NewsletterSubscriptionRequest): Response = Response.success(AuthMessageResponse(success = true)) + override suspend fun unsubscribeNewsletter(request: NewsletterSubscriptionRequest): Response = Response.success(AuthMessageResponse(success = true)) + override suspend fun confirmNewsletter(token: String): Response = Response.success(AuthMessageResponse(success = true)) + override suspend fun passwordResetDiagnostics( + email: String?, + failedOnly: Boolean, + ): Response = Response.success(PasswordResetDiagnosticsResponse()) + } + + val context = ApplicationProvider.getApplicationContext() + 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).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()) + } +} diff --git a/android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsUiAutomatorClickTest.kt b/android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsUiAutomatorClickTest.kt new file mode 100644 index 0000000..eec41dd --- /dev/null +++ b/android-app/app/src/androidTest/java/de/harheimertc/ui/screens/cms/CmsUiAutomatorClickTest.kt @@ -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 + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/HarheimerApplication.kt b/android-app/app/src/main/java/de/harheimertc/HarheimerApplication.kt index 7f777ec..b325812 100644 --- a/android-app/app/src/main/java/de/harheimertc/HarheimerApplication.kt +++ b/android-app/app/src/main/java/de/harheimertc/HarheimerApplication.kt @@ -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 -> diff --git a/android-app/app/src/main/java/de/harheimertc/MainActivity.kt b/android-app/app/src/main/java/de/harheimertc/MainActivity.kt index c75be2c..1a839cc 100644 --- a/android-app/app/src/main/java/de/harheimertc/MainActivity.kt +++ b/android-app/app/src/main/java/de/harheimertc/MainActivity.kt @@ -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) } } diff --git a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt index 01b714f..6a4ec33 100644 --- a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt +++ b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt @@ -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 = emptyList(), + val jsonWrittenTo: List = 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 = 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 = 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 = emptyList(), ) data class SeitenDto( val ueberUns: String = "", @@ -364,10 +398,12 @@ data class SeitenDto( data class ConfigResponse( val training: TrainingDto = TrainingDto(), val trainer: List = emptyList(), + val mitgliedschaft: List = 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 = 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 = emptyList(), val attempts: List = emptyList(), ) @@ -520,6 +565,9 @@ interface ApiService { @GET("/api/vereinsmeisterschaften") suspend fun vereinsmeisterschaften(): Response + @POST("/api/cms/save-csv") + suspend fun saveCsv(@Body request: SaveCsvRequest): Response + @POST("/api/membership/generate-pdf") suspend fun generateMembershipPdf(@Body request: MembershipRequest): Response @@ -669,5 +717,8 @@ interface ApiService { suspend fun confirmNewsletter(@Query("token") token: String): Response @GET("/api/cms/password-reset-diagnostics") - suspend fun passwordResetDiagnostics(): Response + suspend fun passwordResetDiagnostics( + @Query("email") email: String? = null, + @Query("failedOnly") failedOnly: Boolean = true, + ): Response } diff --git a/android-app/app/src/main/java/de/harheimertc/data/SecureOfflineCache.kt b/android-app/app/src/main/java/de/harheimertc/data/SecureOfflineCache.kt index 7aa94ff..260c24f 100644 --- a/android-app/app/src/main/java/de/harheimertc/data/SecureOfflineCache.kt +++ b/android-app/app/src/main/java/de/harheimertc/data/SecureOfflineCache.kt @@ -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) { val type = Types.newParameterizedType(List::class.java, ContactRequestDto::class.java) val json = moshi.adapter>(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? { - val json = preferences.getString("contact_requests", null) ?: return null + fun getContactRequests(maxAgeMillis: Long? = null): List? { + 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>(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 put(key: String, value: T, type: Class) { 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 get(key: String, type: Class): T? { + private fun get(key: String, type: Class, 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 } diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt index 9dc3845..1b9244c 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt @@ -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 = 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 = 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> = 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): Result = 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 = @@ -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 = 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 = 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 = - fetchEncryptedFallback( + suspend fun passwordResetDiagnostics( + email: String? = null, + failedOnly: Boolean = true, + ): Result { + 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 = 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 = 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 = 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): Result = 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 = 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 = 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): Result = 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 = 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 = 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> = + csv.lineSequence().filter(String::isNotBlank).map(::parseCsvLine).toList() + +private fun parseCsvLine(line: String): List { + val values = mutableListOf() + 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 +} diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/HomeLayoutPreferences.kt b/android-app/app/src/main/java/de/harheimertc/repositories/HomeLayoutPreferences.kt new file mode 100644 index 0000000..a004214 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/repositories/HomeLayoutPreferences.kt @@ -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>(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? { + val json = preferences.getString(HOME_SECTIONS_KEY, null) ?: return null + return runCatching { sectionListAdapter.fromJson(json) }.getOrNull() + } + + fun setSections(sections: List) { + 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" + } +} \ No newline at end of file diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/HomeRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/HomeRepository.kt index fbddb08..0fb77f9 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/HomeRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/HomeRepository.kt @@ -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, val spiele: List, + val spielplanSeasons: List, + val selectedSpielplanSeason: String?, val news: List, + val homepageSections: List, ) @Singleton class HomeRepository @Inject constructor(private val api: ApiService) { suspend fun fetchHomeData(): Result = 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 = runCatching { + val response = api.spielplan(season) + if (!response.isSuccessful) error("HTTP ${response.code()}") + response.body() ?: error("Leere Antwort") } } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt b/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt index 434a61b..624ba49 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt @@ -344,6 +344,7 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List() } + + 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() } + var selectedYear by remember { mutableStateOf("Alle Jahre") } + var editDialogOpen by remember { mutableStateOf(false) } + var noteDialogOpen by remember { mutableStateOf(false) } + var editingOriginal by remember { mutableStateOf(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() } + val trainers = remember { mutableStateListOf() } + + 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(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 { + 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) { - val viewModel: CmsViewModel = hiltViewModel() +private fun CmsUserListPage( + navController: NavController, + showBackNavigation: Boolean, + title: String, + users: List, + 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, + 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" diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt index bcd39ec..b3c7c9b 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt @@ -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 = emptyList(), val newsletterGroups: List = emptyList(), val passwordResetAttempts: List = emptyList(), + val passwordResetMatchingUsers: List = emptyList(), + val passwordResetRetentionHours: Int = 72, + val passwordResetSearchTerm: String = "", + val passwordResetFailedOnly: Boolean = true, val news: List = emptyList(), + val meisterschaften: List = 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) { + 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) diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeScreen.kt index c9d6f9a..5e059cf 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeScreen.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeScreen.kt @@ -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(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, + spielplanSeasons: List, + spielplanTeamsBySeason: Map>, + 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, + 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, + teamsBySeason: Map>, + 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, + 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", +) diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeViewModel.kt index 8b0c6e8..dda991b 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeViewModel.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeViewModel.kt @@ -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 = emptyList(), val spiele: List = emptyList(), val news: List = emptyList(), + val homepageSections: List = defaultHomepageSections, + val spielplanSeasons: List = emptyList(), + val spielplanTeamsBySeason: Map> = emptyMap(), + val spielplanWidgetPreviews: Map> = emptyMap(), + val spielplanWidgetErrors: Map = 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 = _state + private var serverSections: List = defaultHomepageSections + private val seasonGamesCache = mutableMapOf>() 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) -> List) { + 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, + seasons: List, + ): 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>() + val errors = mutableMapOf() + + 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, + val teamsBySeason: Map>, + val previewGamesBySectionKey: Map>, + val errorsBySectionKey: Map, +) + 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): List { + 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, + user: List?, +): List { + if (user.isNullOrEmpty()) return server + val serverById = server.associateBy { it.id } + val serverByKey = server.associateBy { sectionKey(it) } + val ordered = buildList { + 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): List = + 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, + teamName: String, + teamAgeGroup: String, +): List { + 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" diff --git a/android-app/app/src/test/java/de/harheimertc/ui/screens/cms/CmsViewModelTest.kt b/android-app/app/src/test/java/de/harheimertc/ui/screens/cms/CmsViewModelTest.kt index 29ccd09..ddab0b2 100644 --- a/android-app/app/src/test/java/de/harheimertc/ui/screens/cms/CmsViewModelTest.kt +++ b/android-app/app/src/test/java/de/harheimertc/ui/screens/cms/CmsViewModelTest.kt @@ -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")) diff --git a/android-app/build/reports/problems/problems-report.html b/android-app/build/reports/problems/problems-report.html index b9493e0..24a37cc 100644 --- a/android-app/build/reports/problems/problems-report.html +++ b/android-app/build/reports/problems/problems-report.html @@ -653,7 +653,7 @@ code + .copy-button { diff --git a/android-app/device_screenshot.png b/android-app/device_screenshot.png new file mode 100644 index 0000000..b00294d Binary files /dev/null and b/android-app/device_screenshot.png differ diff --git a/android-app/device_screenshot_after_back.png b/android-app/device_screenshot_after_back.png new file mode 100644 index 0000000..eb4d53c Binary files /dev/null and b/android-app/device_screenshot_after_back.png differ diff --git a/android-app/window_dump_emulator_app.xml b/android-app/window_dump_emulator_app.xml new file mode 100644 index 0000000..cc4bb45 --- /dev/null +++ b/android-app/window_dump_emulator_app.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/android-app/window_dump_tablet.xml b/android-app/window_dump_tablet.xml new file mode 100644 index 0000000..8ac74bf --- /dev/null +++ b/android-app/window_dump_tablet.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/android-app/window_dump_tablet_app.xml b/android-app/window_dump_tablet_app.xml new file mode 100644 index 0000000..d4082a2 --- /dev/null +++ b/android-app/window_dump_tablet_app.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/components/HomeLinksTeaser.vue b/components/HomeLinksTeaser.vue new file mode 100644 index 0000000..08bbd64 --- /dev/null +++ b/components/HomeLinksTeaser.vue @@ -0,0 +1,20 @@ + \ No newline at end of file diff --git a/components/HomeSpielplanTeamWidget.vue b/components/HomeSpielplanTeamWidget.vue new file mode 100644 index 0000000..fffbd6f --- /dev/null +++ b/components/HomeSpielplanTeamWidget.vue @@ -0,0 +1,187 @@ + + + \ No newline at end of file diff --git a/components/HomeTrainingTeaser.vue b/components/HomeTrainingTeaser.vue new file mode 100644 index 0000000..09a5285 --- /dev/null +++ b/components/HomeTrainingTeaser.vue @@ -0,0 +1,20 @@ + \ No newline at end of file diff --git a/components/HomeVereinsmeisterschaftenTeaser.vue b/components/HomeVereinsmeisterschaftenTeaser.vue new file mode 100644 index 0000000..fc5698c --- /dev/null +++ b/components/HomeVereinsmeisterschaftenTeaser.vue @@ -0,0 +1,20 @@ + \ No newline at end of file diff --git a/components/ModalDialog.vue b/components/ModalDialog.vue index e9bc003..3f4877a 100644 --- a/components/ModalDialog.vue +++ b/components/ModalDialog.vue @@ -7,8 +7,12 @@ >
-
{{ toastTitle }}
-
{{ toastMessage }}
+
+ {{ toastTitle }} +
+
+ {{ toastMessage }} +
diff --git a/pages/cms/sportbetrieb.vue b/pages/cms/sportbetrieb.vue index 630c110..505bd67 100644 --- a/pages/cms/sportbetrieb.vue +++ b/pages/cms/sportbetrieb.vue @@ -33,7 +33,10 @@
- +
diff --git a/pages/cms/startseite.vue b/pages/cms/startseite.vue index 68dcc94..6f244a4 100644 --- a/pages/cms/startseite.vue +++ b/pages/cms/startseite.vue @@ -46,7 +46,7 @@ Verfügbare Elemente

- Ziehen Sie die Elemente per Drag & Drop oder verwenden Sie die Pfeil-Buttons, um die Reihenfolge zu ändern. + Legen Sie Reihenfolge, Sichtbarkeit und Marker fest (ohne Marker, cookie, eingeloggt).

@@ -110,13 +110,33 @@ + + +
+ + +

- Hinweis: Deaktivierte Elemente werden auf der Startseite nicht angezeigt, bleiben aber in der Konfiguration erhalten. + Hinweis: Marker steuern die Sichtbarkeit auf der Web-Startseite: cookie zeigt das Element bei vorhandenen Cookies, eingeloggt nur für angemeldete Nutzer.

@@ -166,6 +186,30 @@ const availableSections = { kontakt: { label: 'Kontakt-Boxen', description: 'Mitglied werden & Kontakt aufnehmen' + }, + training: { + label: 'Training-Teaser', + description: 'Direktzugang zu Training, Trainern und Anfängerbereich' + }, + links: { + label: 'Links-Teaser', + description: 'Direktzugang zu den nützlichen Vereinslinks' + }, + vereinsmeisterschaften: { + label: 'Vereinsmeisterschaften-Teaser', + description: 'Direktzugang zu Meisterschaftsergebnissen' + } +} + +function normalizeMarker(marker) { + return marker === 'cookie' || marker === 'eingeloggt' ? marker : '' +} + +function normalizeSection(section) { + return { + id: section?.id, + enabled: section?.enabled !== false, + marker: normalizeMarker(section?.marker) } } @@ -185,17 +229,23 @@ const loadConfig = async () => { // Standard-Reihenfolge, falls nicht vorhanden const defaultSections = [ - { id: 'banner', enabled: true }, - { id: 'termine', enabled: true }, - { id: 'spiele', enabled: true }, - { id: 'aktuelles', enabled: true }, - { id: 'kontakt', enabled: true } + { id: 'banner', enabled: true, marker: '' }, + { id: 'termine', enabled: true, marker: '' }, + { id: 'spiele', enabled: true, marker: '' }, + { id: 'aktuelles', enabled: true, marker: '' }, + { id: 'kontakt', enabled: true, marker: '' }, + { id: 'training', enabled: false, marker: '' }, + { id: 'links', enabled: false, marker: '' }, + { id: 'vereinsmeisterschaften', enabled: false, marker: '' } ] if (config.homepage && config.homepage.sections && Array.isArray(config.homepage.sections)) { // Validiere und merge: Nur bekannte IDs verwenden, fehlende hinzufügen - const knownIds = new Set(config.homepage.sections.map(s => s.id)) - const merged = [...config.homepage.sections] + const normalized = config.homepage.sections + .filter(s => s?.id) + .map(normalizeSection) + const knownIds = new Set(normalized.map(s => s.id)) + const merged = [...normalized] // Füge fehlende Standard-Elemente hinzu for (const defaultSection of defaultSections) { @@ -204,9 +254,9 @@ const loadConfig = async () => { } } - sections.value = merged + sections.value = merged.map(normalizeSection) } else { - sections.value = [...defaultSections] + sections.value = defaultSections.map(normalizeSection) } } catch (error) { console.error('Fehler beim Laden der Konfiguration:', error) @@ -242,7 +292,7 @@ const saveConfig = async () => { if (!config.homepage) { config.homepage = {} } - config.homepage.sections = sections.value + config.homepage.sections = sections.value.map(normalizeSection) // Speichere Config await $fetch('/api/config', { diff --git a/pages/index.vue b/pages/index.vue index 871f437..30df1a1 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -1,55 +1,562 @@ diff --git a/server/api/homepage/settings.get.js b/server/api/homepage/settings.get.js new file mode 100644 index 0000000..7cab19b --- /dev/null +++ b/server/api/homepage/settings.get.js @@ -0,0 +1,59 @@ +import { getUserFromToken } from '../../utils/auth.js' + +function normalizeConfig(config) { + if (!config || typeof config !== 'object') return undefined + const normalized = { + season: config.season ? String(config.season) : undefined, + teamName: config.teamName ? String(config.teamName) : undefined, + teamAgeGroup: config.teamAgeGroup ? String(config.teamAgeGroup) : undefined + } + if (!normalized.season && !normalized.teamName && !normalized.teamAgeGroup) { + return undefined + } + return normalized +} + +function parseSections(value) { + if (!value || typeof value !== 'string') return [] + try { + const parsed = JSON.parse(value) + if (!Array.isArray(parsed)) return [] + return parsed + .filter(section => section?.id) + .map((section, index) => ({ + key: section.key ? String(section.key) : `${String(section.id)}-${index}`, + id: String(section.id), + enabled: section.enabled !== false, + config: normalizeConfig(section.config) + })) + } catch { + return [] + } +} + +export default defineEventHandler(async (event) => { + const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '') + const user = token ? await getUserFromToken(token) : null + + const rawCookieSections = getCookie(event, 'homepage_sections') + const cookieSections = parseSections(rawCookieSections) + + const userSections = Array.isArray(user?.homepageSettings?.sections) + ? user.homepageSettings.sections + .filter(section => section?.id) + .map((section, index) => ({ + key: section.key ? String(section.key) : `${String(section.id)}-${index}`, + id: String(section.id), + enabled: section.enabled !== false, + config: normalizeConfig(section.config) + })) + : [] + + const isLoggedIn = !!user + + return { + isLoggedIn, + storage: isLoggedIn ? 'user' : 'cookie', + sections: isLoggedIn ? userSections : cookieSections + } +}) \ No newline at end of file diff --git a/server/api/homepage/settings.put.js b/server/api/homepage/settings.put.js new file mode 100644 index 0000000..b3dff22 --- /dev/null +++ b/server/api/homepage/settings.put.js @@ -0,0 +1,81 @@ +import { getUserFromToken, readUsers, writeUsers } from '../../utils/auth.js' + +function normalizeConfig(config) { + if (!config || typeof config !== 'object') return undefined + const normalized = { + season: config.season ? String(config.season) : undefined, + teamName: config.teamName ? String(config.teamName) : undefined, + teamAgeGroup: config.teamAgeGroup ? String(config.teamAgeGroup) : undefined + } + if (!normalized.season && !normalized.teamName && !normalized.teamAgeGroup) { + return undefined + } + return normalized +} + +function normalizeSections(sections) { + if (!Array.isArray(sections)) return [] + const seenKeys = new Set() + return sections + .filter(section => section?.id) + .map((section, index) => ({ + key: section.key ? String(section.key) : `${String(section.id)}-${index}`, + id: String(section.id), + enabled: section.enabled !== false, + config: normalizeConfig(section.config) + })) + .filter(section => { + if (seenKeys.has(section.key)) return false + seenKeys.add(section.key) + return true + }) +} + +export default defineEventHandler(async (event) => { + const body = await readBody(event) + const sections = normalizeSections(body?.sections) + const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '') + const authUser = token ? await getUserFromToken(token) : null + + if (!authUser) { + + setCookie(event, 'homepage_sections', JSON.stringify(sections), { + path: '/', + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + httpOnly: false, + maxAge: 60 * 60 * 24 * 180 + }) + + return { + success: true, + storage: 'cookie', + sections + } + } + + const users = await readUsers() + const userIndex = users.findIndex(user => user.id === authUser.id) + if (userIndex < 0) { + throw createError({ + statusCode: 404, + message: 'Benutzer nicht gefunden.' + }) + } + + const current = users[userIndex] + users[userIndex] = { + ...current, + homepageSettings: { + sections, + updatedAt: new Date().toISOString() + } + } + await writeUsers(users) + + return { + success: true, + storage: 'user', + sections + } +}) \ No newline at end of file diff --git a/server/api/homepage/spielplan-options.get.js b/server/api/homepage/spielplan-options.get.js new file mode 100644 index 0000000..1b950a0 --- /dev/null +++ b/server/api/homepage/spielplan-options.get.js @@ -0,0 +1,61 @@ +import { listSpielplanSeasons, readSpielplanData, validateSeasonSlug } from '../../utils/spielplan-data.js' + +function teamLabel(teamName, teamAgeGroup) { + const name = String(teamName || '').trim() + const age = String(teamAgeGroup || '').trim() + if (!name) return '' + const isYouth = age.toLowerCase().includes('jugend') || name.toLowerCase().includes('jugend') + return isYouth ? `(J) ${name}` : name +} + +function extractHarheimerTeams(rows) { + const seen = new Set() + const teams = [] + + const addTeam = (teamName, teamAgeGroup) => { + const name = String(teamName || '').trim() + if (!name) return + const age = String(teamAgeGroup || '').trim() + const key = `${name}||${age}` + if (seen.has(key)) return + seen.add(key) + teams.push({ + key, + label: teamLabel(name, age), + teamName: name, + teamAgeGroup: age + }) + } + + for (const row of rows || []) { + if (String(row.HeimVereinName || '').trim() === 'Harheimer TC') { + addTeam(row.HeimMannschaft, row.HeimMannschaftAltersklasse) + } + if (String(row.GastVereinName || '').trim() === 'Harheimer TC') { + addTeam(row.GastMannschaft, row.GastMannschaftAltersklasse) + } + } + + return teams.sort((a, b) => a.label.localeCompare(b.label, 'de')) +} + +export default defineEventHandler(async (event) => { + const query = getQuery(event) + if (query.season && !validateSeasonSlug(query.season)) { + throw createError({ + statusCode: 400, + message: 'Ungültiger Saison-Slug.' + }) + } + + const seasons = await listSpielplanSeasons() + const selectedSeason = String(query.season || seasons[0]?.slug || '') + const dataResult = await readSpielplanData(selectedSeason ? { season: selectedSeason } : {}) + + return { + success: true, + selectedSeason, + seasons, + teams: extractHarheimerTeams(dataResult.data) + } +}) \ No newline at end of file