diff --git a/.gitignore b/.gitignore index 8231f6a..1ce6f1d 100644 --- a/.gitignore +++ b/.gitignore @@ -93,6 +93,7 @@ dist /android-app/.kotlin/ /android-app/**/build/ /android-app/local.properties +/android-app/gradle-local.properties # Build output (but keep production data!) .output diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts index 7cb3483..ace652e 100644 --- a/android-app/app/build.gradle.kts +++ b/android-app/app/build.gradle.kts @@ -1,3 +1,5 @@ +import java.util.Properties + plugins { id("com.android.application") id("com.google.devtools.ksp") @@ -25,15 +27,40 @@ val releaseMinifyEnabled = providers.gradleProperty("RELEASE_MINIFY_ENABLED") .orElse("true") .get() .toBoolean() -val releaseStoreFile = providers.gradleProperty("RELEASE_STORE_FILE").orNull -val releaseStorePassword = providers.gradleProperty("RELEASE_STORE_PASSWORD").orNull -val releaseKeyAlias = providers.gradleProperty("RELEASE_KEY_ALIAS").orNull -val releaseKeyPassword = providers.gradleProperty("RELEASE_KEY_PASSWORD").orNull + +val localSigningProperties = Properties().apply { + val localSigningFile = rootProject.file("gradle-local.properties") + if (localSigningFile.exists()) { + localSigningFile.inputStream().use { load(it) } + } +} + +fun signingProperty(name: String): String? = + providers.gradleProperty(name).orNull + ?: providers.environmentVariable(name).orNull + ?: localSigningProperties.getProperty(name) + +val releaseStoreFile = signingProperty("RELEASE_STORE_FILE") +val releaseStorePassword = signingProperty("RELEASE_STORE_PASSWORD") +val releaseKeyAlias = signingProperty("RELEASE_KEY_ALIAS") +val releaseKeyPassword = signingProperty("RELEASE_KEY_PASSWORD") val hasReleaseSigning = !releaseStoreFile.isNullOrBlank() && !releaseStorePassword.isNullOrBlank() && !releaseKeyAlias.isNullOrBlank() && !releaseKeyPassword.isNullOrBlank() +val ensureReleaseSigning = tasks.register("ensureReleaseSigning") { + doFirst { + if (!hasReleaseSigning) { + throw GradleException( + "Production release signing is not configured. " + + "Set RELEASE_STORE_FILE, RELEASE_STORE_PASSWORD, RELEASE_KEY_ALIAS and RELEASE_KEY_PASSWORD " + + "(e.g. via ~/.gradle/gradle.properties, environment variables, or android-app/gradle-local.properties)." + ) + } + } +} + android { namespace = "de.harheimertc" compileSdk = 35 @@ -135,6 +162,7 @@ val packageNativeDebugSymbolsForProductionRelease = tasks.register("package val collectPlayStoreArtifacts = tasks.register("collectPlayStoreArtifacts") { group = "distribution" description = "Builds production release artifacts and collects AAB, mapping, and native symbols for Play Console upload." + dependsOn(ensureReleaseSigning) dependsOn(":app:bundleProductionRelease") dependsOn(packageNativeDebugSymbolsForProductionRelease) @@ -161,6 +189,12 @@ val collectPlayStoreArtifacts = tasks.register("collectPlayStoreArtifacts") { } } +tasks.matching { + it.name in setOf("bundleProductionRelease", "assembleProductionRelease") +}.configureEach { + dependsOn(ensureReleaseSigning) +} + kotlin { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) diff --git a/android-app/app/production/release/app-production-release.aab b/android-app/app/production/release/app-production-release.aab index a1c0126..645c474 100644 Binary files a/android-app/app/production/release/app-production-release.aab and b/android-app/app/production/release/app-production-release.aab differ diff --git a/android-app/app/proguard-rules.pro b/android-app/app/proguard-rules.pro index 4251b83..dea2d18 100644 --- a/android-app/app/proguard-rules.pro +++ b/android-app/app/proguard-rules.pro @@ -10,6 +10,15 @@ @retrofit2.http.* ; } +# Retrofit + R8 full mode: keep interfaces with HTTP methods and suspend continuation generics. +-if interface * { @retrofit2.http.* ; } +-keep,allowobfuscation interface <1> +-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation + +# Avoid Retrofit generic signature loss on release builds for our API interface. +-keep interface de.harheimertc.data.ApiService { *; } +-keepclassmembers interface de.harheimertc.data.ApiService { *; } + # Keep app DTO/request/response models used via Moshi reflection. -keep class de.harheimertc.data.*Dto { *; } -keep class de.harheimertc.data.*Request { *; } @@ -19,3 +28,10 @@ -keepclassmembers class * { @com.squareup.moshi.Json ; } + +# Keep WorkManager + Room generated classes used reflectively at startup. +-keep class * extends androidx.work.ListenableWorker { + (android.content.Context, androidx.work.WorkerParameters); +} +-keep class androidx.work.impl.WorkDatabase_Impl { *; } +-keep class * extends androidx.room.RoomDatabase { *; } 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 6a4ec33..d0c393a 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 @@ -81,7 +81,7 @@ data class LeagueTableRowDto( ) data class NewsPublicResponse(val news: List = emptyList()) data class NewsDto( - val id: Int? = null, + val id: String? = null, val title: String = "", val content: String = "", val created: String? = null, @@ -96,7 +96,7 @@ data class NewsResponse( val news: List = emptyList(), ) data class NewsSaveRequest( - val id: Int? = null, + val id: String? = null, val title: String, val content: String, val isPublic: Boolean = false, @@ -260,6 +260,28 @@ data class BirthdaysResponse( val success: Boolean = false, val birthdays: List = emptyList(), ) +data class QttrSourceDto( + val url: String = "", +) +data class QttrRowDto( + val rank: Int? = null, + val playerNumber: Int? = null, + val gender: String? = null, + val playerName: String = "", + val clubName: String = "", + val currentQttr: Int? = null, + val previousQttr: Int? = null, + val birthdate: String? = null, +) +data class QttrValuesResponse( + val format: String = "", + val importedAt: String = "", + val source: QttrSourceDto = QttrSourceDto(), + val title: String? = null, + val headerCount: Int = 0, + val rowCount: Int = 0, + val rows: List = emptyList(), +) data class MemberDto( val id: String? = null, val name: String = "", @@ -548,7 +570,7 @@ interface ApiService { suspend fun saveNews(@Body request: NewsSaveRequest): Response @DELETE("/api/news") - suspend fun deleteNews(@Query("id") id: Int): Response + suspend fun deleteNews(@Query("id") id: String): Response @GET("/api/mannschaften") suspend fun mannschaften(@Query("season") season: String? = null): Response @@ -620,6 +642,9 @@ interface ApiService { @GET("/api/birthdays") suspend fun birthdays(): Response + @GET("/api/mitgliederbereich/qttr") + suspend fun qttrValues(): Response + @GET("/api/members") suspend fun members(): 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 260c24f..df1737b 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 @@ -16,6 +16,7 @@ class SecureOfflineCache @Inject constructor( ) { private companion object { const val KEY_BIRTHDAYS = "birthdays" + const val KEY_QTTR_VALUES = "qttr_values" const val KEY_MEMBERS = "members" const val KEY_MEMBER_NEWS = "member_news" const val KEY_CMS_CONFIG = "cms_config" @@ -43,6 +44,9 @@ class SecureOfflineCache @Inject constructor( 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 putQttrValues(response: QttrValuesResponse) = put(KEY_QTTR_VALUES, response, QttrValuesResponse::class.java) + fun getQttrValues(maxAgeMillis: Long? = null): QttrValuesResponse? = get(KEY_QTTR_VALUES, QttrValuesResponse::class.java, maxAgeMillis) + fun putMembers(response: MembersResponse) = put(KEY_MEMBERS, response, MembersResponse::class.java) fun getMembers(maxAgeMillis: Long? = null): MembersResponse? = get(KEY_MEMBERS, MembersResponse::class.java, maxAgeMillis) @@ -93,6 +97,7 @@ class SecureOfflineCache @Inject constructor( KEY_NEWSLETTER_GROUPS, KEY_PASSWORD_RESET_DIAGNOSTICS, KEY_MEMBER_NEWS, + KEY_QTTR_VALUES, ) } 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 1b9244c..fdefde2 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 @@ -267,7 +267,7 @@ class CmsRepository @Inject constructor( response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort") } - suspend fun deleteNews(id: Int): Result = runCatching { + suspend fun deleteNews(id: String): Result = runCatching { val response = api.deleteNews(id) if (!response.isSuccessful) error("News konnten nicht gelöscht werden.") cache.clearCmsNewsCache() 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 0fb77f9..d568142 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,5 +1,6 @@ package de.harheimertc.repositories +import de.harheimertc.BuildConfig import de.harheimertc.data.ApiService import de.harheimertc.data.HomepageSectionDto import de.harheimertc.data.NewsDto @@ -7,6 +8,7 @@ import de.harheimertc.data.SeasonDto import de.harheimertc.data.SpielDto import de.harheimertc.data.SpielplanResponse import de.harheimertc.data.TerminDto +import io.sentry.Sentry import javax.inject.Inject import javax.inject.Singleton @@ -17,19 +19,121 @@ data class HomeData( val selectedSpielplanSeason: String?, val news: List, val homepageSections: List, + val diagnostics: List = emptyList(), ) @Singleton class HomeRepository @Inject constructor(private val api: ApiService) { suspend fun fetchHomeData(): Result = runCatching { - val termine = api.termine().body()?.termine.orEmpty() - val spielplanResponse = api.spielplan().body() + val diagnostics = mutableListOf() + + val termine = runCatching { + val response = api.termine() + if (!response.isSuccessful) { + val errorBody = response.errorBody()?.string().orEmpty() + diagnostics += buildDiagnostic( + endpoint = "GET /api/termine", + requestPayload = "none", + httpCode = response.code(), + responseBody = errorBody, + throwable = null, + ) + error("Termine konnten nicht geladen werden (HTTP ${response.code()}).") + } + response.body()?.termine.orEmpty() + }.onFailure { error -> + captureLoadIssue("fetchHomeData.termine", error) + if (diagnostics.none { it.contains("GET /api/termine") }) { + diagnostics += buildDiagnostic( + endpoint = "GET /api/termine", + requestPayload = "none", + httpCode = null, + responseBody = null, + throwable = error, + ) + } + }.getOrDefault(emptyList()) + + val spielplanResponse = runCatching { + val response = api.spielplan() + if (!response.isSuccessful) { + val errorBody = response.errorBody()?.string().orEmpty() + diagnostics += buildDiagnostic( + endpoint = "GET /api/spielplan", + requestPayload = "none", + httpCode = response.code(), + responseBody = errorBody, + throwable = null, + ) + error("Spielplan konnte nicht geladen werden (HTTP ${response.code()}).") + } + response.body() + }.onFailure { error -> + captureLoadIssue("fetchHomeData.spielplan", error) + if (diagnostics.none { it.contains("GET /api/spielplan") }) { + diagnostics += buildDiagnostic( + endpoint = "GET /api/spielplan", + requestPayload = "none", + httpCode = null, + responseBody = null, + throwable = error, + ) + } + }.getOrNull() val spiele = spielplanResponse?.data.orEmpty() - val news = api.publicNews().body()?.news.orEmpty() + + val news = runCatching { + val response = api.publicNews() + if (!response.isSuccessful) { + val errorBody = response.errorBody()?.string().orEmpty() + diagnostics += buildDiagnostic( + endpoint = "GET /api/news-public", + requestPayload = "none", + httpCode = response.code(), + responseBody = errorBody, + throwable = null, + ) + error("News konnten nicht geladen werden (HTTP ${response.code()}).") + } + response.body()?.news.orEmpty() + }.onFailure { error -> + captureLoadIssue("fetchHomeData.news", error) + if (diagnostics.none { it.contains("GET /api/news-public") }) { + diagnostics += buildDiagnostic( + endpoint = "GET /api/news-public", + requestPayload = "none", + httpCode = null, + responseBody = null, + throwable = error, + ) + } + }.getOrDefault(emptyList()) + val homepageSections = runCatching { - val configResponse = api.config() - if (!configResponse.isSuccessful) return@runCatching emptyList() - configResponse.body()?.homepage?.sections.orEmpty() + val response = api.config() + if (!response.isSuccessful) { + val errorBody = response.errorBody()?.string().orEmpty() + diagnostics += buildDiagnostic( + endpoint = "GET /api/config", + requestPayload = "none", + httpCode = response.code(), + responseBody = errorBody, + throwable = null, + ) + error("Konfiguration konnte nicht geladen werden (HTTP ${response.code()}).") + } + response.body()?.homepage?.sections.orEmpty() + }.onFailure { error -> + captureLoadIssue("fetchHomeData.config", error) + if (diagnostics.none { it.contains("GET /api/config") }) { + diagnostics += buildDiagnostic( + endpoint = "GET /api/config", + requestPayload = "none", + httpCode = null, + responseBody = null, + throwable = error, + ) + } }.getOrDefault(emptyList()) HomeData( termine = termine, @@ -38,12 +142,56 @@ class HomeRepository @Inject constructor(private val api: ApiService) { selectedSpielplanSeason = spielplanResponse?.season, news = news, homepageSections = homepageSections, + diagnostics = diagnostics, ) + }.onFailure { error -> + Sentry.withScope { scope -> + scope.setTag("repository", "HomeRepository") + scope.setTag("operation", "fetchHomeData") + scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL) + Sentry.captureException(error) + } } suspend fun fetchSpielplanForSeason(season: String): Result = runCatching { val response = api.spielplan(season) if (!response.isSuccessful) error("HTTP ${response.code()}") response.body() ?: error("Leere Antwort") + }.onFailure { error -> + Sentry.withScope { scope -> + scope.setTag("repository", "HomeRepository") + scope.setTag("operation", "fetchSpielplanForSeason") + scope.setExtra("season", season) + scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL) + Sentry.captureException(error) + } + } + + private fun captureLoadIssue(operation: String, error: Throwable) { + Sentry.withScope { scope -> + scope.setTag("repository", "HomeRepository") + scope.setTag("operation", operation) + scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL) + Sentry.captureException(error) + } + } + + private fun buildDiagnostic( + endpoint: String, + requestPayload: String, + httpCode: Int?, + responseBody: String?, + throwable: Throwable?, + ): String { + val responsePreview = responseBody?.trim()?.take(500).orEmpty().ifBlank { "none" } + val throwableInfo = throwable?.let { "${it::class.simpleName}: ${it.message}" }.orEmpty().ifBlank { "none" } + return buildString { + append("Endpoint: ").append(endpoint).append('\n') + append("URL: ").append(BuildConfig.API_BASE_URL).append(endpoint.substringAfter(' ')).append('\n') + append("Request: ").append(requestPayload).append('\n') + append("HTTP: ").append(httpCode?.toString() ?: "none").append('\n') + append("Response: ").append(responsePreview).append('\n') + append("Throwable: ").append(throwableInfo) + } } } diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/LoginRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/LoginRepository.kt index 5ac3565..0bf3f73 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/LoginRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/LoginRepository.kt @@ -1,5 +1,6 @@ package de.harheimertc.repositories +import de.harheimertc.BuildConfig import de.harheimertc.data.ApiService import de.harheimertc.data.LoginRequest import de.harheimertc.data.LoginResponse @@ -9,6 +10,7 @@ import de.harheimertc.data.LogoutRequest import de.harheimertc.data.RegistrationRequest import de.harheimertc.data.ResetPasswordRequest import de.harheimertc.data.SessionRefresher +import io.sentry.Sentry import javax.inject.Inject import javax.inject.Singleton @@ -19,13 +21,41 @@ class LoginRepository @Inject constructor( private val sessionRefresher: SessionRefresher, ) { suspend fun login(email: String, password: String): Result = runCatching { + val endpoint = "api/auth/login" + val requestPreview = "{email=\"${maskEmail(email)}\", client=\"android\", deviceName=\"Harheimer TC Android-App\"}" val response = api.login(LoginRequest(email.trim(), password)) - if (!response.isSuccessful) error("Anmeldung fehlgeschlagen. Bitte prüfen Sie Ihre Zugangsdaten.") + if (!response.isSuccessful) { + val body = response.errorBody()?.string().orEmpty() + val serverMessage = extractServerMessage(body) + val fallback = when (response.code()) { + 401 -> "Ungueltige Anmeldedaten" + 403 -> "Konto nicht freigeschaltet" + 429 -> "Zu viele Anmeldeversuche. Bitte spaeter erneut versuchen." + else -> "Anmeldung fehlgeschlagen" + } + val diagnostic = buildString { + append("\n\nDiagnose:\n") + append("URL: ").append(BuildConfig.API_BASE_URL).append(endpoint).append('\n') + append("Request: ").append(requestPreview).append('\n') + append("HTTP: ").append(response.code()).append('\n') + append("Response: ").append(body.take(500).ifBlank { "none" }).append('\n') + append("Server message: ").append(serverMessage ?: "none") + } + error("$fallback (HTTP ${response.code()})${serverMessage?.let { ": $it" } ?: ""}$diagnostic") + } val body = response.body() ?: error("Leere Antwort") val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank) ?: error("Der Server hat kein Zugriffstoken geliefert.") authRepository.setSession(token, body.refreshToken, body.sessionId) body + }.onFailure { error -> + Sentry.withScope { scope -> + scope.setTag("repository", "LoginRepository") + scope.setTag("operation", "login") + scope.setExtra("emailDomain", email.substringAfter('@', "unknown")) + scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL) + Sentry.captureException(error) + } } suspend fun logout(): Result = runCatching { @@ -51,6 +81,15 @@ class LoginRepository @Inject constructor( } if (!status.isLoggedIn && authRepository.getRefreshToken() == null) authRepository.clearSession() status + }.onFailure { error -> + Sentry.withScope { scope -> + scope.setTag("repository", "LoginRepository") + scope.setTag("operation", "status") + scope.setExtra("hasAccessToken", (!authRepository.getToken().isNullOrBlank()).toString()) + scope.setExtra("hasRefreshToken", (authRepository.getRefreshToken() != null).toString()) + scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL) + Sentry.captureException(error) + } } suspend fun resetPassword(email: String): Result = runCatching { @@ -64,4 +103,19 @@ class LoginRepository @Inject constructor( if (!response.isSuccessful) error("Registrierung fehlgeschlagen.") response.body() ?: error("Leere Antwort") } + + private fun extractServerMessage(raw: String): String? { + if (raw.isBlank()) return null + val msgRegex = Regex("\"message\"\\s*:\\s*\"([^\"]+)\"") + return msgRegex.find(raw)?.groupValues?.getOrNull(1) + } + + private fun maskEmail(rawEmail: String): String { + val email = rawEmail.trim() + if (!email.contains('@')) return "hidden" + val local = email.substringBefore('@') + val domain = email.substringAfter('@') + val localMasked = if (local.length <= 2) "**" else local.take(2) + "***" + return "$localMasked@$domain" + } } diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/MemberAreaRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/MemberAreaRepository.kt index 4c6bbf3..da202de 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/MemberAreaRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/MemberAreaRepository.kt @@ -5,6 +5,7 @@ import de.harheimertc.data.BirthdaysResponse import de.harheimertc.data.MembersResponse import de.harheimertc.data.NewsResponse import de.harheimertc.data.NewsSaveRequest +import de.harheimertc.data.QttrValuesResponse import de.harheimertc.data.SecureOfflineCache import javax.inject.Inject @@ -24,6 +25,18 @@ class MemberAreaRepository @Inject constructor( fallbackMessage = "Geburtstage konnten nicht geladen werden.", ) + suspend fun qttrValues(): Result = + fetchEncryptedFallback( + load = { + val response = api.qttrValues() + if (!response.isSuccessful) error("QTTR-Werte konnten nicht geladen werden.") + response.body() ?: error("Leere Antwort vom Server.") + }, + save = cache::putQttrValues, + cached = { cache.getQttrValues(24L * 60L * 60L * 1000L) }, + fallbackMessage = "QTTR-Werte konnten nicht geladen werden.", + ) + suspend fun members(): Result = fetchEncryptedFallback( load = { @@ -88,7 +101,7 @@ class MemberAreaRepository @Inject constructor( response.body() ?: emptyMap() } - suspend fun deleteNews(id: Int): Result = runCatching { + suspend fun deleteNews(id: String): Result = runCatching { val response = api.deleteNews(id) if (!response.isSuccessful) error("News konnten nicht gelöscht werden.") } 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 2c6663d..b3baeb2 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 @@ -73,14 +73,25 @@ private fun CompactNavigation( navigationState: NavigationUiState = NavigationUiState(), ) { BrandRow(onLogin = { onNavigate(Destinations.Login.route) }) - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate, Modifier.weight(1f)) - CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate, Modifier.weight(1f)) - CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate, Modifier.weight(1f)) - CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate, Modifier.weight(1f)) - CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate, Modifier.weight(1f)) + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate) + CompactLink("Verein", Destinations.VereinAbout.route, selectedRoute, onNavigate) + CompactLink("Mannschaften", Destinations.Mannschaften.route, selectedRoute, onNavigate) + CompactLink("Training", Destinations.Training.route, selectedRoute, onNavigate) + CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate) + CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate) + if (navigationState.showGallery) { + CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate) + } + if (navigationState.loggedIn) { + CompactLink("Intern", Destinations.MemberArea.route, selectedRoute, onNavigate) + } + CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate) if (navigationState.isAdmin || navigationState.canAccessNewsletter || navigationState.canAccessContactRequests) { - CompactLink("CMS", Destinations.Cms.route, selectedRoute, onNavigate, Modifier.weight(1f)) + CompactLink("CMS", Destinations.Cms.route, selectedRoute, onNavigate) } } } @@ -107,7 +118,6 @@ private fun WebTabletNavigation( MainLink("Verein", section == MenuSection.VEREIN, onClick = { navigateAndClose(Destinations.VereinAbout.route) }) MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = { navigateAndClose(Destinations.Mannschaften.route) }) MainLink("Training", section == MenuSection.TRAINING, onClick = { navigateAndClose(Destinations.Training.route) }) - MainLink("Mitgliedschaft", selectedRoute == Destinations.Membership.route, onClick = { navigateAndClose(Destinations.Membership.route) }) MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = { navigateAndClose(Destinations.Termine.route) }) if (navigationState.showGallery) { MainLink("Galerie", selectedRoute == Destinations.Gallery.route, onClick = { onNavigate(Destinations.Gallery.route) }) @@ -289,6 +299,7 @@ private fun menuSection(route: String?): MenuSection? = when (route) { Destinations.NewsletterUnsubscribed.route -> MenuSection.NEWSLETTER Destinations.MemberArea.route, Destinations.Members.route, + Destinations.Qttr.route, Destinations.MemberNews.route, Destinations.Profile.route, Destinations.MemberApi.route, @@ -339,6 +350,7 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List buildList { add(MenuTarget("Übersicht", Destinations.MemberArea.route)) add(MenuTarget("Mitgliederliste", Destinations.Members.route)) + add(MenuTarget("QTTR", Destinations.Qttr.route)) add(MenuTarget("News", Destinations.MemberNews.route)) add(MenuTarget("Mein Profil", Destinations.Profile.route)) add(MenuTarget("API-Dokumentation", Destinations.MemberApi.route)) diff --git a/android-app/app/src/main/java/de/harheimertc/ui/navigation/Destinations.kt b/android-app/app/src/main/java/de/harheimertc/ui/navigation/Destinations.kt index 918abbb..832b3b9 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/navigation/Destinations.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/navigation/Destinations.kt @@ -36,6 +36,7 @@ sealed class Destinations(val route: String) { object Register : Destinations("register") object MemberArea : Destinations("intern") object Members : Destinations("intern/mitglieder") + object Qttr : Destinations("intern/qttr") object MemberNews : Destinations("intern/news") object Profile : Destinations("intern/profil") object MemberApi : Destinations("intern/api") diff --git a/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt b/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt index fefc732..e5d778f 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt @@ -261,6 +261,12 @@ fun NavGraph( showBackNavigation = !persistentNavigation, ) } + composable(Destinations.Qttr.route) { + de.harheimertc.ui.screens.memberarea.QttrScreen( + navController = navController, + showBackNavigation = !persistentNavigation, + ) + } composable(Destinations.MemberNews.route) { de.harheimertc.ui.screens.memberarea.MemberNewsScreen( navController = navController, diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsNewsScreens.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsNewsScreens.kt index 041dd81..e30e3d9 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsNewsScreens.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsNewsScreens.kt @@ -49,7 +49,7 @@ import java.util.Locale fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { val state by viewModel.state.collectAsState() val scope = rememberCoroutineScope() - var selection by remember { mutableStateOf(setOf()) } + var selection by remember { mutableStateOf(setOf()) } val loginVm: de.harheimertc.ui.screens.login.LoginViewModel = hiltViewModel() val loginState by loginVm.state.collectAsState() val canWrite = loginState.roles.any { it == "admin" || it == "vorstand" } @@ -62,7 +62,7 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie // Local dialog state for create/edit + delete confirmation (hoisted) var dialogOpen by remember { mutableStateOf(false) } - var deletingIds by remember { mutableStateOf?>(null) } + var deletingIds by remember { mutableStateOf?>(null) } var editing by remember { mutableStateOf(null) } var title by remember { mutableStateOf("") } var content by remember { mutableStateOf("") } @@ -256,9 +256,9 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie private fun NewsListItem( news: NewsDto, selected: Boolean = false, - onSelect: (Int?, Boolean) -> Unit = { _, _ -> }, + onSelect: (String?, Boolean) -> Unit = { _, _ -> }, onEdit: (NewsDto) -> Unit, - onDelete: (Int) -> Unit, + onDelete: (String) -> Unit, ) { androidx.compose.material3.Surface(modifier = Modifier.fillMaxWidth().padding(8.dp)) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { 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 b3c7c9b..7d66132 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 @@ -207,7 +207,7 @@ class CmsViewModel @Inject constructor( } } - fun bulkSetPublic(ids: List, makePublic: Boolean) { + fun bulkSetPublic(ids: List, makePublic: Boolean) { viewModelScope.launch { _state.value = _state.value.copy(saving = true, error = null, message = null) ids.forEach { id -> @@ -229,7 +229,7 @@ class CmsViewModel @Inject constructor( } } - fun bulkSetHidden(ids: List, makeHidden: Boolean) { + fun bulkSetHidden(ids: List, makeHidden: Boolean) { viewModelScope.launch { _state.value = _state.value.copy(saving = true, error = null, message = null) ids.forEach { id -> @@ -251,7 +251,7 @@ class CmsViewModel @Inject constructor( } } - fun bulkDelete(ids: List) { + fun bulkDelete(ids: List) { viewModelScope.launch { _state.value = _state.value.copy(saving = true, error = null, message = null) ids.forEach { id -> @@ -262,7 +262,7 @@ class CmsViewModel @Inject constructor( } } - fun deleteNews(id: Int) { + fun deleteNews(id: String) { viewModelScope.launch { _state.value = _state.value.copy(saving = true, error = null, message = null) repository.deleteNews(id) 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 5e059cf..ab47356 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 @@ -44,6 +44,7 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -126,6 +127,47 @@ fun HomeScreen( onReset = viewModel::resetSections, ) } + if (state.error) { + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = state.errorMessage ?: "Daten konnten nicht geladen werden.", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + OutlinedButton(onClick = viewModel::load) { + Text("Erneut versuchen") + } + } + } + } + if (state.debugDiagnostics.isNotEmpty()) { + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Technische Diagnose (vorübergehend)", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = state.debugDiagnostics.joinToString("\n\n---\n\n"), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.error, + ) + } + } + } state.homepageSections.forEachIndexed { index, section -> if (!section.enabled) return@forEachIndexed val sectionKey = homeSectionKey(section) 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 dda991b..65f3c3f 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 @@ -44,6 +44,8 @@ data class HomeUiState( val spielplanWidgetErrors: Map = emptyMap(), val widgetsLoading: Boolean = false, val error: Boolean = false, + val errorMessage: String? = null, + val debugDiagnostics: List = emptyList(), ) @HiltViewModel @@ -62,7 +64,12 @@ class HomeViewModel @Inject constructor( fun load() { viewModelScope.launch { - _state.value = _state.value.copy(loading = true, error = false) + _state.value = _state.value.copy( + loading = true, + error = false, + errorMessage = null, + debugDiagnostics = emptyList(), + ) repository.fetchHomeData() .onSuccess { data -> serverSections = normalizedHomepageSections(data.homepageSections) @@ -99,10 +106,16 @@ class HomeViewModel @Inject constructor( spielplanTeamsBySeason = widgetData.teamsBySeason, spielplanWidgetPreviews = widgetData.previewGamesBySectionKey, spielplanWidgetErrors = widgetData.errorsBySectionKey, + debugDiagnostics = data.diagnostics, ) } - .onFailure { - _state.value = HomeUiState(loading = false, error = true) + .onFailure { err -> + _state.value = HomeUiState( + loading = false, + error = true, + errorMessage = err.message ?: "Daten konnten nicht geladen werden.", + debugDiagnostics = listOf(err.message ?: "Unbekannter Fehler"), + ) } } } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailScreens.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailScreens.kt index 040b3ac..a1521d6 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailScreens.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailScreens.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button @@ -25,8 +26,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler import android.util.Log import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -35,6 +38,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import de.harheimertc.data.MemberDto import de.harheimertc.data.NewsDto +import de.harheimertc.data.QttrRowDto import de.harheimertc.ui.components.RichText import de.harheimertc.ui.theme.Accent100 import de.harheimertc.ui.theme.Accent500 @@ -208,7 +212,7 @@ fun MemberNewsScreen( fun MemberApiScreen(navController: NavController, showBackNavigation: Boolean) { val groups = listOf( "Authentifizierung" to listOf("POST /api/auth/login", "POST /api/auth/refresh", "GET /api/auth/status", "POST /api/auth/logout"), - "Mitgliederbereich" to listOf("GET /api/members", "GET /api/news", "GET /api/profile", "PUT /api/profile"), + "Mitgliederbereich" to listOf("GET /api/members", "GET /api/mitgliederbereich/qttr", "GET /api/news", "GET /api/profile", "PUT /api/profile"), "CMS" to listOf("GET /api/cms/users/list", "GET /api/cms/contact-requests", "GET /api/newsletter/list", "GET /api/config"), ) MemberAreaPage(navController, showBackNavigation, "API-Dokumentation", "Kurzüberblick der wichtigsten App-Endpunkte") { @@ -237,6 +241,42 @@ fun MemberApiScreen(navController: NavController, showBackNavigation: Boolean) { } } +@Composable +fun QttrScreen( + navController: NavController, + showBackNavigation: Boolean, + viewModel: QttrViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState() + val uriHandler = LocalUriHandler.current + val externalUrl = "https://www.mytischtennis.de/rankings/andro-rangliste?continent=all&country=Deutschland&all-players=on&as=DE.WE.R4.07&di=DE.WE.R4.07.04&area=DE.WE.R4.07.04.43&clubnr-search=Harheimer+TC&clubnr=43030&fednickname=HeTTV&gender=all¤t-ranking=yes&ttr-range=100%3B3000&birth-range=1926%3B2021" + + MemberAreaPage(navController, showBackNavigation, "QTTR-Werte", "Aus technischen Gründen sind nur die QTTR-Werte verfügbar.") { + item { + Surface(color = Primary100, shape = RoundedCornerShape(12.dp)) { + Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Für TTR bitte die myTischtennis-Rangliste verwenden.", color = Primary900) + TextButton(onClick = { uriHandler.openUri(externalUrl) }) { Text("myTischtennis öffnen") } + } + } + } + item { + Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) { + Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(state.title ?: "Andro-Rangliste", style = MaterialTheme.typography.titleLarge, color = Accent900) + Text("${state.rows.size} Einträge · Aktualisiert ${state.importedAt ?: "unbekannt"}", color = Accent500) + } + } + } + when { + state.loading -> item { CircularProgressIndicator(color = Primary600) } + state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) } + state.rows.isEmpty() -> item { Text("Keine QTTR-Werte gefunden.", color = Accent700) } + else -> items(state.rows.size) { index -> QttrRowCard(state.rows[index], isOwnRow(state.rows[index].playerName, state.currentUserName)) } + } + } +} + @Composable private fun MemberAreaPage( navController: NavController, @@ -322,6 +362,83 @@ private fun Badge(label: String) { } } +@Composable +private fun QttrRowCard(row: QttrRowDto, highlighted: Boolean) { + Surface( + color = if (highlighted) Primary100 else Color.White, + shape = RoundedCornerShape(14.dp), + shadowElevation = 3.dp, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(18.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface(color = if (highlighted) Primary100 else Accent100, shape = RoundedCornerShape(10.dp), modifier = Modifier.size(46.dp)) { + Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { + Text(row.rank?.toString() ?: "-", color = Accent900, fontWeight = FontWeight.Bold) + } + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + row.playerName.ifBlank { "Unbekannt" }, + style = MaterialTheme.typography.titleMedium, + color = qttrNameColor(row.gender, isMinor(row.birthdate)), + fontWeight = if (highlighted) FontWeight.Bold else FontWeight.Medium, + ) + Text(row.clubName.ifBlank { "Harheimer TC" }, color = qttrNameColor(row.gender, isMinor(row.birthdate)).copy(alpha = 0.88f)) + } + Text(row.currentQttr?.toString() ?: "-", color = Primary600, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + } + } +} + +private fun isOwnRow(playerName: String?, currentUserName: String): Boolean { + fun normalize(value: String?): String { + return java.text.Normalizer.normalize(value.orEmpty().trim().lowercase(), java.text.Normalizer.Form.NFKD) + .replace(Regex("[\\u0300-\\u036f]"), "") + .replace(Regex("[’'`]"), "") + .replace(Regex("\\s+"), " ") + } + + val current = normalize(currentUserName) + if (current.isBlank()) return false + return normalize(playerName) == current +} + +private fun qttrNameColor(gender: String?, isMinor: Boolean): Color { + val value = gender.orEmpty().trim().lowercase() + return when { + value.startsWith('m') || value.contains("männ") || value.contains("maenn") -> if (isMinor) Color(0xFF60A5FA) else Color(0xFF2563EB) + value.startsWith('w') || value.contains("weib") || value.contains("frau") -> if (isMinor) Color(0xFFF9A8D4) else Color(0xFF9D174D) + else -> Accent900 + } +} + +private fun isMinor(birthdate: String?): Boolean { + val date = parseBirthdate(birthdate) ?: return false + val today = java.time.LocalDate.now() + var age = today.year - date.year + if (today.monthValue < date.monthValue || (today.monthValue == date.monthValue && today.dayOfMonth < date.dayOfMonth)) { + age -= 1 + } + return age < 18 +} + +private fun parseBirthdate(value: String?): java.time.LocalDate? { + val raw = value.orEmpty().trim() + if (raw.isBlank()) return null + return try { + if (Regex("^\\d{4}$").matches(raw)) { + java.time.LocalDate.of(raw.toInt(), 1, 1) + } else { + java.time.LocalDate.parse(raw) + } + } catch (_: Exception) { + null + } +} + @Composable private fun ErrorCard(message: String, onRetry: () -> Unit) { Surface(color = Color(0xFFFEE2E2), shape = RoundedCornerShape(12.dp)) { diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailViewModels.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailViewModels.kt index 7d3bd2d..b42de74 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailViewModels.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailViewModels.kt @@ -3,9 +3,12 @@ package de.harheimertc.ui.screens.memberarea import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import de.harheimertc.data.AuthStatusResponse import de.harheimertc.data.MemberDto import de.harheimertc.data.NewsDto +import de.harheimertc.data.QttrRowDto import de.harheimertc.repositories.MemberAreaRepository +import de.harheimertc.repositories.LoginRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -101,3 +104,45 @@ class MemberNewsViewModel @Inject constructor( } } } + +data class QttrUiState( + val rows: List = emptyList(), + val title: String? = null, + val importedAt: String? = null, + val currentUserName: String = "", + val loading: Boolean = true, + val error: String? = null, +) + +@HiltViewModel +class QttrViewModel @Inject constructor( + private val repository: MemberAreaRepository, + private val loginRepository: LoginRepository, +) : ViewModel() { + private val _state = MutableStateFlow(QttrUiState()) + val state: StateFlow = _state + + init { + load() + } + + fun load() { + viewModelScope.launch { + _state.value = _state.value.copy(loading = true, error = null) + val authStatus = loginRepository.status().getOrDefault(AuthStatusResponse()) + repository.qttrValues() + .onSuccess { response -> + _state.value = _state.value.copy( + rows = response.rows, + title = response.title, + importedAt = response.importedAt, + currentUserName = authStatus.user?.name.orEmpty(), + loading = false, + ) + } + .onFailure { + _state.value = _state.value.copy(loading = false, error = it.message ?: "QTTR-Werte konnten nicht geladen werden.") + } + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaScreen.kt index 97063f0..555d954 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaScreen.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaScreen.kt @@ -95,6 +95,12 @@ private fun MemberAreaCardGrid(navController: NavController) { marker = "N", onClick = { navController.navigate(Destinations.MemberNews.route) }, ) + MemberAreaCard( + title = "QTTR", + description = "Aktuelle QTTR-Werte der Vereinsmitglieder", + marker = "Q", + onClick = { navController.navigate(Destinations.Qttr.route) }, + ) } } diff --git a/android-app/app/src/release/AndroidManifest.xml b/android-app/app/src/release/AndroidManifest.xml new file mode 100644 index 0000000..ffc4bf5 --- /dev/null +++ b/android-app/app/src/release/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/android-app/gradle-local.properties.example b/android-app/gradle-local.properties.example new file mode 100644 index 0000000..c0d6bc5 --- /dev/null +++ b/android-app/gradle-local.properties.example @@ -0,0 +1,6 @@ +# Copy this file to android-app/gradle-local.properties (ignored by git) +# and fill in your release signing credentials. +RELEASE_STORE_FILE=/home/torsten/android\ keystore/harheimertc.jks +RELEASE_STORE_PASSWORD= +RELEASE_KEY_ALIAS= +RELEASE_KEY_PASSWORD= diff --git a/android-app/gradle.properties b/android-app/gradle.properties index af31eb7..1601f8e 100644 --- a/android-app/gradle.properties +++ b/android-app/gradle.properties @@ -5,17 +5,18 @@ org.gradle.workers.max=2 LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/ # Production backend for Play Store build variant -PRODUCTION_API_BASE_URL=https://harheimertc.de/ +PRODUCTION_API_BASE_URL=https://harheimertc.tsschulz.de/ # Android app versioning for Play Store uploads -ANDROID_VERSION_CODE=7 -ANDROID_VERSION_NAME=0.9.2 +ANDROID_VERSION_CODE=17 +ANDROID_VERSION_NAME=0.9.12 -# Enable R8 for release by default so mapping.txt is generated for Play Console. -RELEASE_MINIFY_ENABLED=true +# Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping. +RELEASE_MINIFY_ENABLED=false # Release signing (set in local, untracked gradle.properties or via CI secrets) -# RELEASE_STORE_FILE=/absolute/path/to/keystore.jks +RELEASE_STORE_FILE=/home/torsten/android\ keystore/harheimertc.jks +# Keep secrets out of git. Use ~/.gradle/gradle.properties or environment variables. # RELEASE_STORE_PASSWORD=*** # RELEASE_KEY_ALIAS=*** # RELEASE_KEY_PASSWORD=*** diff --git a/components/Navigation.vue b/components/Navigation.vue index b45fd0a..70fc015 100644 --- a/components/Navigation.vue +++ b/components/Navigation.vue @@ -274,6 +274,13 @@ > Mitgliederliste + + QTTR + Mitgliederliste + + QTTR + +
+
+
+

+ QTTR-Werte +

+
+

+ Aus technischen Gründen sind nur die QTTR-Werte verfügbar. Für TTR bitte auf + myTischtennis + wechseln. +

+
+ +
+
+
+

+ Harheimer TC Rangliste +

+

+ {{ data?.title || 'Andro-Rangliste' }} · {{ data?.rowCount || 0 }} Einträge +

+
+
+ Aktualisiert: {{ formatDate(data?.importedAt) }} +
+
+ +
+ Lade QTTR-Werte... +
+
+ {{ error.statusMessage || error.message || 'QTTR-Werte konnten nicht geladen werden.' }} +
+
+ + + + + + + + + + + + + + + + + +
+ Rang + + Spieler + + Verein + + QTTR +
+ {{ row.rank ?? '–' }} + +
+ {{ row.playerName || 'Unbekannt' }} +
+
+ {{ row.clubName || 'Harheimer TC' }} + + {{ row.currentQttr ?? '–' }} +
+
+
+
+
+ + + \ No newline at end of file diff --git a/server/api/mitgliederbereich/qttr.get.js b/server/api/mitgliederbereich/qttr.get.js new file mode 100644 index 0000000..d944bc3 --- /dev/null +++ b/server/api/mitgliederbereich/qttr.get.js @@ -0,0 +1,90 @@ +import { readFile } from 'fs/promises' +import { getServerDataPath } from '../../utils/paths.js' +import { getUserFromToken, verifyToken } from '../../utils/auth.js' +import { readMembers } from '../../utils/members.js' +import { readUsers } from '../../utils/auth.js' + +const QTTR_FILE = getServerDataPath('qttr-values.json') + +function normalizeName(value) { + return String(value || '') + .trim() + .toLowerCase() + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/\s+/g, ' ') + .replace(/[’'`]/g, '') +} + +function buildBirthdateLookup(entries) { + const lookup = new Map() + + for (const entry of entries || []) { + const candidates = [ + entry?.name, + `${entry?.firstName || ''} ${entry?.lastName || ''}`.trim(), + ] + + const birthdate = entry?.geburtsdatum || entry?.birthday || entry?.birthDate || '' + if (!birthdate) continue + + for (const candidate of candidates) { + const normalized = normalizeName(candidate) + if (!normalized || lookup.has(normalized)) continue + lookup.set(normalized, birthdate) + } + } + + return lookup +} + +export default defineEventHandler(async (event) => { + const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '') + if (!token || !verifyToken(token)) { + throw createError({ + statusCode: 401, + message: 'Nicht authentifiziert.' + }) + } + + const currentUser = await getUserFromToken(token) + if (!currentUser) { + throw createError({ + statusCode: 401, + message: 'Ungültiges Token.' + }) + } + + try { + const content = await readFile(QTTR_FILE, 'utf8') + const payload = JSON.parse(content) + const [manualMembers, registeredUsers] = await Promise.all([ + readMembers(), + readUsers() + ]) + const birthdateLookup = buildBirthdateLookup([...manualMembers, ...registeredUsers]) + + return { + ...payload, + rows: Array.isArray(payload.rows) + ? payload.rows.map((row) => ({ + ...row, + birthdate: birthdateLookup.get(normalizeName(row.playerName)) || row.birthdate || '' + })) + : [] + } + } catch (error) { + if (error?.code === 'ENOENT') { + throw createError({ + statusCode: 404, + message: 'QTTR-Datei nicht gefunden.' + }) + } + + console.error('Fehler beim Laden der QTTR-Werte:', error) + throw createError({ + statusCode: 500, + message: 'Fehler beim Laden der QTTR-Werte.' + }) + } +}) \ No newline at end of file diff --git a/server/plugins/spielplan-import-scheduler.js b/server/plugins/spielplan-import-scheduler.js index aaaa425..4552f65 100644 --- a/server/plugins/spielplan-import-scheduler.js +++ b/server/plugins/spielplan-import-scheduler.js @@ -1,16 +1,20 @@ import { importSpielplan } from '../utils/spielplan-import.js' import { importLeagueTables } from '../utils/spielklassen-tables-import.js' +import { importQttrValues } from '../utils/qttr-import.js' import { publishImportedSpielplan } from '../utils/spielplan-publish.js' import { info as loggerInfo, error as loggerError } from '../utils/logger.js' import { cleanupPasswordResetLogs } from '../utils/password-reset-log.js' const TIME_ZONE = 'Europe/Berlin' -const RUN_HOUR = 7 -const RUN_MINUTE = 0 const MAX_TIMEOUT = 2_147_483_647 -let timer = null -let running = false +const JOBS = [ + { label: 'spielplan-import', hour: 7, minute: 0 }, + { label: 'qttr-import', hour: 7, minute: 30 } +] + +const timers = new Map() +const runningJobs = new Set() function getTimeParts(date) { const parts = new Intl.DateTimeFormat('en-CA', { @@ -47,12 +51,12 @@ function zonedDateToUtc(year, month, day, hour, minute) { return new Date(utcGuess.getTime() - offset) } -function nextRunAt(now = new Date()) { +function nextRunAt(hour, minute, now = new Date()) { const parts = getTimeParts(now) let year = Number(parts.year) let month = Number(parts.month) let day = Number(parts.day) - let candidate = zonedDateToUtc(year, month, day, RUN_HOUR, RUN_MINUTE) + let candidate = zonedDateToUtc(year, month, day, hour, minute) if (candidate <= now) { const nextDay = zonedDateToUtc(year, month, day + 1, 12, 0) @@ -60,65 +64,86 @@ function nextRunAt(now = new Date()) { year = Number(nextParts.year) month = Number(nextParts.month) day = Number(nextParts.day) - candidate = zonedDateToUtc(year, month, day, RUN_HOUR, RUN_MINUTE) + candidate = zonedDateToUtc(year, month, day, hour, minute) } return candidate } -async function runDailyJobs(reason, skipSpielplanImport = false) { - if (running) return +async function runJob(job, reason) { + if (runningJobs.has(job.label)) return - running = true + runningJobs.add(job.label) try { - try { - const cleanup = await cleanupPasswordResetLogs() - loggerInfo(`[password-reset-log] ${reason}: Bereinigung abgeschlossen`, cleanup) - } catch (error) { - loggerError('[password-reset-log] Bereinigung fehlgeschlagen:', { error }) - } - - if (skipSpielplanImport) { - return - } - - const spielplan = await importSpielplan() - loggerInfo(`[spielplan-import] ${reason}: ${spielplan.matchCount} Spiele importiert`, { range: `${spielplan.source.season.dateStart} - ${spielplan.source.season.dateEnd}` }) - - const published = await publishImportedSpielplan({ inputPath: spielplan.jsonFile }) - loggerInfo(`[spielplan-import] ${reason}: Spielplan publiziert`, { - season: published.seasonSlug, - internalPath: published.internalSeasonPath - }) - - try { - const tables = await importLeagueTables() - loggerInfo(`[spielplan-import] ${reason}: ${tables.importedCount}/${tables.teamCount} Tabellen importiert`, { - season: tables.seasonSlug, - outputFile: tables.outputFile, - errors: tables.errorCount - }) - } catch (error) { - loggerError('[spielplan-import] Tabellen-Import fehlgeschlagen:', { error }) - } + await job.run(reason) } catch (error) { - loggerError('[spielplan-import] Import fehlgeschlagen:', { error }) + loggerError(`[${job.label}] Import fehlgeschlagen:`, { error }) } finally { - running = false + runningJobs.delete(job.label) } } -function scheduleNext(skipSpielplanImport = false) { - const runAt = nextRunAt() +function scheduleNext(job) { + const runAt = nextRunAt(job.hour, job.minute) const delay = Math.min(Math.max(runAt.getTime() - Date.now(), 1_000), MAX_TIMEOUT) - timer = setTimeout(async () => { - await runDailyJobs('taeglicher Lauf', skipSpielplanImport) - scheduleNext(skipSpielplanImport) + const timer = setTimeout(async () => { + await runJob(job, 'taeglicher Lauf') + scheduleNext(job) }, delay) timer.unref?.() - loggerInfo('[spielplan-import] Naechster Lauf', { runAt: runAt.toISOString(), tz: TIME_ZONE, time: `${String(RUN_HOUR).padStart(2, '0')}:${String(RUN_MINUTE).padStart(2, '0')}` }) + timers.set(job.label, timer) + loggerInfo(`[${job.label}] Naechster Lauf`, { runAt: runAt.toISOString(), tz: TIME_ZONE, time: `${String(job.hour).padStart(2, '0')}:${String(job.minute).padStart(2, '0')}` }) +} + +function createSpielplanJob(skipSpielplanImport) { + return { + run: async (reason) => { + try { + const cleanup = await cleanupPasswordResetLogs() + loggerInfo(`[password-reset-log] ${reason}: Bereinigung abgeschlossen`, cleanup) + } catch (error) { + loggerError('[password-reset-log] Bereinigung fehlgeschlagen:', { error }) + } + + if (skipSpielplanImport) { + return + } + + const spielplan = await importSpielplan() + loggerInfo(`[spielplan-import] ${reason}: ${spielplan.matchCount} Spiele importiert`, { range: `${spielplan.source.season.dateStart} - ${spielplan.source.season.dateEnd}` }) + + const published = await publishImportedSpielplan({ inputPath: spielplan.jsonFile }) + loggerInfo(`[spielplan-import] ${reason}: Spielplan publiziert`, { + season: published.seasonSlug, + internalPath: published.internalSeasonPath + }) + + try { + const tables = await importLeagueTables() + loggerInfo(`[spielplan-import] ${reason}: ${tables.importedCount}/${tables.teamCount} Tabellen importiert`, { + season: tables.seasonSlug, + outputFile: tables.outputFile, + errors: tables.errorCount + }) + } catch (error) { + loggerError('[spielplan-import] Tabellen-Import fehlgeschlagen:', { error }) + } + } + } +} + +function createQttrJob() { + return { + run: async (reason) => { + const qttr = await importQttrValues() + loggerInfo(`[qttr-import] ${reason}: ${qttr.rowCount} QTTR-Werte importiert`, { + outputFile: qttr.outputFile, + tableCount: qttr.tableCount + }) + } + } } export default defineNitroPlugin((nitroApp) => { @@ -127,13 +152,19 @@ export default defineNitroPlugin((nitroApp) => { loggerInfo('[spielplan-import] Import deaktiviert; Passwort-Reset-Log-Bereinigung bleibt aktiv') } - scheduleNext(skipSpielplanImport) + const spielplanJob = createSpielplanJob(skipSpielplanImport) + const qttrJob = createQttrJob() + + scheduleNext({ ...JOBS[0], ...spielplanJob }) + scheduleNext({ ...JOBS[1], ...qttrJob }) if (process.env.SPIELPLAN_IMPORT_RUN_ON_START === 'true') { - runDailyJobs('Startlauf', skipSpielplanImport) + runJob({ label: 'spielplan-import', run: spielplanJob.run }, 'Startlauf') } nitroApp.hooks.hookOnce('close', () => { - if (timer) clearTimeout(timer) + for (const timer of timers.values()) { + clearTimeout(timer) + } }) }) diff --git a/server/utils/qttr-import.js b/server/utils/qttr-import.js new file mode 100644 index 0000000..cc5d7bf --- /dev/null +++ b/server/utils/qttr-import.js @@ -0,0 +1,220 @@ +import { promises as fs } from 'fs' +import { getServerDataPath } from './paths.js' + +const QTTR_URL = 'https://www.mytischtennis.de/rankings/andro-rangliste?continent=all&country=Deutschland&all-players=on&as=DE.WE.R4.07&di=DE.WE.R4.07.04&area=DE.WE.R4.07.04.43&clubnr-search=Harheimer+TC&clubnr=43030&fednickname=HeTTV&gender=all¤t-ranking=no&ttr-range=100%3B3000&birth-range=1926%3B2021' +const OUTPUT_FILE = getServerDataPath('qttr-values.json') + +function decodeHtmlEntities(value) { + const namedEntities = { + amp: '&', + apos: "'", + gt: '>', + lt: '<', + nbsp: ' ', + quot: '"' + } + + return String(value || '') + .replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (match, entity) => { + if (entity.startsWith('#x')) { + const codePoint = Number.parseInt(entity.slice(2), 16) + return Number.isNaN(codePoint) ? match : String.fromCodePoint(codePoint) + } + + if (entity.startsWith('#')) { + const codePoint = Number.parseInt(entity.slice(1), 10) + return Number.isNaN(codePoint) ? match : String.fromCodePoint(codePoint) + } + + return Object.prototype.hasOwnProperty.call(namedEntities, entity) ? namedEntities[entity] : match + }) +} + +function stripTags(value) { + return decodeHtmlEntities(String(value || '') + .replace(//gi, ' ') + .replace(//gi, ' ') + .replace(/]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim()) +} + +function normalizeHeaderKey(value) { + return String(value || '') + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/ß/g, 'ss') + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') +} + +function toNumberOrNull(value) { + const raw = String(value || '').replace(',', '.').match(/-?\d+(?:\.\d+)?/) + if (!raw) return null + const numberValue = Number(raw[0]) + return Number.isNaN(numberValue) ? null : numberValue +} + +function normalizeName(value) { + return String(value || '') + .trim() + .toLowerCase() + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/\s+/g, ' ') + .replace(/[’'`]/g, '') +} + +function normalizeGender(value) { + const normalized = String(value || '').trim().toLowerCase() + if (normalized === 'm' || normalized === 'männlich') return 'männlich' + if (normalized === 'w' || normalized === 'weiblich') return 'weiblich' + return normalized || null +} + +function extractTableBlocks(html) { + return [...String(html || '').matchAll(/]*>[\s\S]*?<\/table>/gi)].map((match) => match[0]) +} + +function extractCellTexts(rowHtml) { + return [...String(rowHtml || '').matchAll(/<(?:t[hd])\b[^>]*>([\s\S]*?)<\/t[hd]>/gi)].map((match) => stripTags(match[1])) +} + +function findBestTable(html) { + const tables = extractTableBlocks(html) + if (tables.length === 0) return null + + return ( + tables.find((table) => /Harheimer TC/i.test(table) && /Q-?TTR/i.test(table)) || + tables.find((table) => /Harheimer TC/i.test(table)) || + tables.find((table) => /Q-?TTR/i.test(table)) || + tables[0] + ) +} + +function extractTableTitle(html, tableHtml) { + const tableIndex = String(html || '').indexOf(tableHtml) + if (tableIndex === -1) return null + + const prefix = String(html || '').slice(0, tableIndex) + const headingMatches = [...prefix.matchAll(/<(h[1-6])\b[^>]*>([\s\S]*?)<\/\1>/gi)] + if (headingMatches.length === 0) return null + + return stripTags(headingMatches[headingMatches.length - 1][2]) || null +} + +function extractRowsFromTable(tableHtml) { + const rows = [...String(tableHtml || '').matchAll(/]*>([\s\S]*?)<\/tr>/gi)] + if (rows.length === 0) return { headers: [], rows: [] } + + const headerRow = rows.find((row) => / ({ + label: cell, + key: normalizeHeaderKey(cell) + })) + + const dataRows = rows + .filter((row) => row !== headerRow) + .map((row) => extractCellTexts(row[1])) + .filter((cells) => cells.length > 0 && cells.some((cell) => cell !== '')) + + return { headers, rows: dataRows } +} + +function deriveQttrFields(headers, cells) { + const valuesByHeader = {} + headers.forEach((header, index) => { + valuesByHeader[header.key] = cells[index] ?? null + }) + + const lookup = (pattern) => { + const entry = headers.find((header) => pattern.test(header.key)) + return entry ? valuesByHeader[entry.key] : null + } + + const rank = lookup(/platz|rank|rang/) ?? cells[0] ?? null + const playerNumber = toNumberOrNull(cells[1] ?? lookup(/spieler.*nr|spielernr|id/)) + let gender = lookup(/geschlecht/) ?? null + let playerName = lookup(/^(name|spielername)$/) ?? lookup(/\bspieler\b/) ?? null + const combinedPlayerCell = cells[2] ?? lookup(/\bspieler\b/) ?? null + const clubName = lookup(/verein|club/) ?? cells[3] ?? null + const currentQttr = toNumberOrNull(cells[4] ?? lookup(/aktuell.*q.*ttr|current.*q.*ttr|q.*ttr/)) + const previousQttr = toNumberOrNull(lookup(/vorher|previous/)) + + if (combinedPlayerCell) { + const genderAndName = String(combinedPlayerCell).match(/^(m|w|männlich|weiblich)\s+(.*)$/i) + if (genderAndName) { + gender = gender ?? normalizeGender(genderAndName[1]) + playerName = genderAndName[2].trim() + } else { + playerName = playerName ?? String(combinedPlayerCell).trim() + } + } + + gender = normalizeGender(gender) + + return { + rank: toNumberOrNull(rank), + playerNumber, + gender, + playerName, + clubName, + currentQttr, + previousQttr, + valuesByHeader, + rawCells: cells + } +} + +export async function importQttrValues(options = {}) { + const url = options.url || QTTR_URL + const response = await fetch(url, { + headers: { + accept: 'text/html,application/xhtml+xml', + 'accept-language': 'de-DE,de;q=0.9' + } + }) + + if (!response.ok) { + throw new Error(`QTTR-Download fehlgeschlagen: HTTP ${response.status}`) + } + + const html = await response.text() + const tableHtml = findBestTable(html) + + if (!tableHtml) { + throw new Error('Keine QTTR-Tabelle im HTML gefunden') + } + + const { headers, rows } = extractRowsFromTable(tableHtml) + if (headers.length === 0 || rows.length === 0) { + throw new Error('QTTR-Tabelle ist leer oder unvollständig') + } + + const parsedRows = rows.map((cells) => deriveQttrFields(headers, cells)) + const payload = { + format: 'harheimertc.qttr.v1', + importedAt: new Date().toISOString(), + source: { + url + }, + title: extractTableTitle(html, tableHtml), + headerCount: headers.length, + rowCount: parsedRows.length, + headers, + rows: parsedRows + } + + await fs.mkdir(getServerDataPath(), { recursive: true }) + await fs.writeFile(OUTPUT_FILE, `${JSON.stringify(payload, null, 2)}\n`, 'utf8') + + return { + outputFile: OUTPUT_FILE, + tableCount: 1, + rowCount: parsedRows.length, + ...payload + } +} \ No newline at end of file