diff --git a/.gitea/workflows/code-analysis.yml b/.gitea/workflows/code-analysis.yml index 3819b8c..cced0da 100644 --- a/.gitea/workflows/code-analysis.yml +++ b/.gitea/workflows/code-analysis.yml @@ -6,6 +6,7 @@ on: jobs: analyze: + if: github.event_name == 'push' && github.ref == 'refs/heads/dev' runs-on: ubuntu-latest steps: - name: Checkout @@ -118,9 +119,8 @@ jobs: ./osv-scanner --lockfile ./package-lock.json deploy-production: - needs: analyze runs-on: ubuntu-latest - if: success() && github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' && github.ref == 'refs/heads/main' steps: - name: Prepare SSH run: | 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/PLAYSTORE_ASSETS.md b/android-app/PLAYSTORE_ASSETS.md index 6a51c39..fe12ed5 100644 --- a/android-app/PLAYSTORE_ASSETS.md +++ b/android-app/PLAYSTORE_ASSETS.md @@ -36,13 +36,12 @@ Ausgabe in: ## 3) Screenshots (anonymisiert) -### Grobe Anforderungen (Telefon) -- Mindestens 2 Screenshots -- PNG oder JPEG -- Seitenlaenge je Seite zwischen 320 px und 3840 px +### Zielgroessen fuer Store-Upload +- Telefon (Portrait): 1080 x 1920 +- Medium 7" Tablet (Portrait): 1200 x 1920 +- 10" Tablet (Portrait): 1600 x 2560 -Empfehlung fuer Android-Phone: -- 1080 x 1920 (Portrait) +Alle Dateien als PNG oder JPEG. ### Anonymisierung @@ -61,10 +60,33 @@ Beispiel: '68,118,520,72;70,706,560,98' ``` +### Zielprofile erzeugen (Telefon, 7", 10") + +Aus allen Dateien in `android-app/playstore-assets/anon` werden die drei Profile erzeugt: + +```bash +./scripts/playstore-screenshot-sizes.sh +``` + +Optional mit eigenen Ordnern: + +```bash +./scripts/playstore-screenshot-sizes.sh \ + --input-dir android-app/playstore-assets/anon \ + --output-dir android-app/playstore-assets/final +``` + +Output: +- android-app/playstore-assets/final/phone +- android-app/playstore-assets/final/tablet-7 +- android-app/playstore-assets/final/tablet-10 + ## 4) Upload in Play Console - Datenschutzerklaerung: URL eintragen - Konto-Loeschung: URL eintragen - App-Icon: playstore-icon-512.png - Feature Graphic: playstore-feature-graphic-1024x500.png -- Screenshots: anonymisierte PNG/JPEG hochladen +- Screenshots Telefon: Dateien aus `.../final/phone` +- Screenshots 7" Tablet: Dateien aus `.../final/tablet-7` +- Screenshots 10" Tablet: Dateien aus `.../final/tablet-10` 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/instantTest/release/app-instantTest-release.aab b/android-app/app/instantTest/release/app-instantTest-release.aab index b3a9f0a..14162da 100644 Binary files a/android-app/app/instantTest/release/app-instantTest-release.aab and b/android-app/app/instantTest/release/app-instantTest-release.aab differ diff --git a/android-app/app/local/release/app-local-release.aab b/android-app/app/local/release/app-local-release.aab index 83bdedd..5c6b984 100644 Binary files a/android-app/app/local/release/app-local-release.aab and b/android-app/app/local/release/app-local-release.aab differ diff --git a/android-app/app/production/release/app-production-release.aab b/android-app/app/production/release/app-production-release.aab index e80d41e..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 c7013bd..dea2d18 100644 --- a/android-app/app/proguard-rules.pro +++ b/android-app/app/proguard-rules.pro @@ -1,2 +1,37 @@ # Project-specific R8/ProGuard rules for release builds. -# Keep this file intentionally minimal and add rules only when needed. + +# Keep reflection/generic metadata used by Retrofit + Moshi. +-keepattributes Signature,InnerClasses,EnclosingMethod +-keepattributes RuntimeVisibleAnnotations,RuntimeVisibleParameterAnnotations,AnnotationDefault +-keep class kotlin.Metadata { *; } + +# Keep Retrofit service interfaces and HTTP method annotations. +-keep,allowobfuscation interface * { + @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 { *; } +-keep class de.harheimertc.data.*Response { *; } + +# Keep fields annotated with @Json names. +-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 624ba49..9e83723 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 @@ -72,15 +72,72 @@ private fun CompactNavigation( onNavigate: (String) -> Unit, navigationState: NavigationUiState = NavigationUiState(), ) { + val section = menuSection(selectedRoute) + val subItems = submenu(section, navigationState) + var cmsExpanded = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) } + val navigateAndClose: (String) -> Unit = { route -> cmsExpanded.value = false; onNavigate(route) } + 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) + CompactLink("Newsletter", Destinations.NewsletterSubscribe.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) + } + } + + val cmsIndex = subItems.indexOfFirst { it.label == "CMS" } + val cmsChildren = if (cmsIndex >= 0) subItems.subList(cmsIndex + 1, subItems.size) else emptyList() + if (cmsChildren.any { it.route == selectedRoute }) { + cmsExpanded.value = true + } + + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(top = 3.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + subItems.forEachIndexed { idx, item -> + if (idx == cmsIndex) { + SubLink(item.label, item.route == selectedRoute) { + cmsExpanded.value = !cmsExpanded.value + } + } else if (idx > cmsIndex && cmsIndex >= 0) { + // CMS children are rendered below when expanded. + } else { + SubLink(item.label, item.route == selectedRoute) { navigateAndClose(item.route) } + } + } + } + + if (cmsExpanded.value && cmsChildren.isNotEmpty()) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(top = 6.dp, bottom = 3.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + cmsChildren.forEach { child -> + SubLink(child.label, child.route == selectedRoute) { onNavigate(child.route) } + } } } } @@ -107,7 +164,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) }) @@ -214,8 +270,8 @@ private fun MainLink( ) { Text( label, - color = Color.White.copy(alpha = if (selected || primary) 1f else 0.82f), - style = MaterialTheme.typography.labelSmall, + color = Color.White.copy(alpha = if (selected || primary) 1f else 0.94f), + style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 10.dp, vertical = 9.dp), maxLines = 1, ) @@ -239,7 +295,7 @@ private fun CompactLink( label, color = if (route == selectedRoute) Color.White else Color(0xFFD4D4D8), textAlign = TextAlign.Center, - style = MaterialTheme.typography.labelSmall, + style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(vertical = 9.dp, horizontal = 2.dp), maxLines = 1, ) @@ -256,7 +312,7 @@ private fun SubLink(label: String, selected: Boolean, onClick: () -> Unit) { Text( label, color = if (selected) Color.White else Color(0xFFD4D4D8), - style = MaterialTheme.typography.labelSmall, + style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp), maxLines = 1, ) @@ -289,6 +345,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 +396,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 ac7f769..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,15 +26,19 @@ 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 import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import de.harheimertc.data.MemberDto import de.harheimertc.data.NewsDto +import de.harheimertc.data.QttrRowDto import de.harheimertc.ui.components.RichText import de.harheimertc.ui.theme.Accent100 import de.harheimertc.ui.theme.Accent500 @@ -166,7 +171,14 @@ fun MembersScreen( val m = display[index] Surface(color = Color.White, shape = RoundedCornerShape(6.dp)) { Row(Modifier.fillMaxWidth().padding(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Column(Modifier.weight(1f)) { Text(m.name, color = Accent900) } + Column(Modifier.weight(1f)) { + Text( + m.name, + color = Accent900, + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.ExtraBold, + ) + } Column(Modifier.weight(1f)) { Text(m.email ?: "-", color = Primary600) } Column(Modifier.weight(1f)) { Text(m.phone ?: "-", color = Accent700) } } @@ -200,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") { @@ -229,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, @@ -259,7 +307,13 @@ private fun MemberAreaPage( private fun MemberCard(member: MemberDto, onEdit: (MemberDto) -> Unit = {}, onDelete: (MemberDto) -> Unit = {}) { Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) { Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(7.dp)) { - Text(member.name.ifBlank { "${member.firstName} ${member.lastName}".trim() }, style = MaterialTheme.typography.titleLarge, color = Accent900) + Text( + member.name.ifBlank { "${member.firstName} ${member.lastName}".trim() }, + style = MaterialTheme.typography.titleLarge, + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.ExtraBold, + color = Accent900, + ) if (!member.email.isNullOrBlank()) Text(member.email, color = Primary600) if (!member.phone.isNullOrBlank()) Text(member.phone, color = Accent700) if (!member.birthday.isNullOrBlank()) { @@ -308,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/main/java/de/harheimertc/ui/theme/Typography.kt b/android-app/app/src/main/java/de/harheimertc/ui/theme/Typography.kt index 001af30..4b9548c 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/theme/Typography.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/theme/Typography.kt @@ -10,32 +10,91 @@ import de.harheimertc.R // Bundled variable fonts in res/font: val InterFamily = FontFamily(Font(R.font.inter_variable)) -val MontserratFamily = FontFamily(Font(R.font.montserrat_variable, FontWeight.SemiBold)) +val MontserratFamily = FontFamily( + Font(R.font.montserrat_variable, FontWeight.SemiBold), + Font(R.font.montserrat_variable, FontWeight.Bold), + Font(R.font.montserrat_variable, FontWeight.ExtraBold), +) +// Android headings: use system sans-serif for stronger strokes/readability on tablets. +val HeaderFamily = FontFamily.SansSerif val AppTypography = Typography( displayLarge = TextStyle( - fontFamily = MontserratFamily, - fontWeight = FontWeight.SemiBold, - fontSize = 30.sp + fontFamily = HeaderFamily, + fontWeight = FontWeight.ExtraBold, + fontSize = 32.sp, + lineHeight = 38.sp + ), + headlineLarge = TextStyle( + fontFamily = HeaderFamily, + fontWeight = FontWeight.Bold, + fontSize = 28.sp, + lineHeight = 34.sp + ), + headlineMedium = TextStyle( + fontFamily = HeaderFamily, + fontWeight = FontWeight.Bold, + fontSize = 24.sp, + lineHeight = 30.sp + ), + headlineSmall = TextStyle( + fontFamily = HeaderFamily, + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + lineHeight = 26.sp ), titleLarge = TextStyle( - fontFamily = MontserratFamily, - fontWeight = FontWeight.Medium, - fontSize = 20.sp + fontFamily = HeaderFamily, + fontWeight = FontWeight.Bold, + fontSize = 22.sp, + lineHeight = 28.sp + ), + titleMedium = TextStyle( + fontFamily = HeaderFamily, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + lineHeight = 24.sp + ), + titleSmall = TextStyle( + fontFamily = HeaderFamily, + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + lineHeight = 22.sp ), bodyLarge = TextStyle( fontFamily = InterFamily, - fontWeight = FontWeight.Normal, - fontSize = 16.sp + fontWeight = FontWeight.Medium, + fontSize = 17.sp, + lineHeight = 25.sp ), bodyMedium = TextStyle( fontFamily = InterFamily, - fontWeight = FontWeight.Normal, - fontSize = 14.sp + fontWeight = FontWeight.Medium, + fontSize = 15.sp, + lineHeight = 22.sp + ), + bodySmall = TextStyle( + fontFamily = InterFamily, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp + ), + labelLarge = TextStyle( + fontFamily = InterFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 15.sp, + lineHeight = 20.sp + ), + labelMedium = TextStyle( + fontFamily = InterFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + lineHeight = 19.sp ), labelSmall = TextStyle( fontFamily = InterFamily, - fontWeight = FontWeight.Medium, - fontSize = 12.sp + fontWeight = FontWeight.SemiBold, + fontSize = 13.sp, + lineHeight = 18.sp ) ) 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 81b4de3..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=4 -ANDROID_VERSION_NAME=1.0.0 +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/android-app/playstore-assets/anon/Screenshot_20260530_000103.png b/android-app/playstore-assets/anon/Screenshot_20260530_000103.png new file mode 100644 index 0000000..75669a5 Binary files /dev/null and b/android-app/playstore-assets/anon/Screenshot_20260530_000103.png differ diff --git a/android-app/playstore-assets/anon/Screenshot_20260530_000133.png b/android-app/playstore-assets/anon/Screenshot_20260530_000133.png new file mode 100644 index 0000000..51abf51 Binary files /dev/null and b/android-app/playstore-assets/anon/Screenshot_20260530_000133.png differ diff --git a/android-app/playstore-assets/anon/Screenshot_20260530_000230.png b/android-app/playstore-assets/anon/Screenshot_20260530_000230.png new file mode 100644 index 0000000..8befe26 Binary files /dev/null and b/android-app/playstore-assets/anon/Screenshot_20260530_000230.png differ diff --git a/android-app/playstore-assets/anon/Screenshot_20260530_000301.png b/android-app/playstore-assets/anon/Screenshot_20260530_000301.png new file mode 100644 index 0000000..5695a7c Binary files /dev/null and b/android-app/playstore-assets/anon/Screenshot_20260530_000301.png differ diff --git a/android-app/playstore-assets/anon/Screenshot_20260530_000429.png b/android-app/playstore-assets/anon/Screenshot_20260530_000429.png new file mode 100644 index 0000000..62e4186 Binary files /dev/null and b/android-app/playstore-assets/anon/Screenshot_20260530_000429.png differ diff --git a/android-app/playstore-assets/final/phone/ChatGPT Image 30. Mai 2026, 00_27_40-1080x1920.png b/android-app/playstore-assets/final/phone/ChatGPT Image 30. Mai 2026, 00_27_40-1080x1920.png new file mode 100644 index 0000000..7b18136 Binary files /dev/null and b/android-app/playstore-assets/final/phone/ChatGPT Image 30. Mai 2026, 00_27_40-1080x1920.png differ diff --git a/android-app/playstore-assets/final/phone/Screenshot_20260530_002151-1080x1920.png b/android-app/playstore-assets/final/phone/Screenshot_20260530_002151-1080x1920.png new file mode 100644 index 0000000..b08dedb Binary files /dev/null and b/android-app/playstore-assets/final/phone/Screenshot_20260530_002151-1080x1920.png differ diff --git a/android-app/playstore-assets/final/phone/Screenshot_20260530_002305-1080x1920.png b/android-app/playstore-assets/final/phone/Screenshot_20260530_002305-1080x1920.png new file mode 100644 index 0000000..89c18f0 Binary files /dev/null and b/android-app/playstore-assets/final/phone/Screenshot_20260530_002305-1080x1920.png differ diff --git a/android-app/playstore-assets/final/tablet-10/ChatGPT Image 30. Mai 2026, 00_13_26-1600x2560.png b/android-app/playstore-assets/final/tablet-10/ChatGPT Image 30. Mai 2026, 00_13_26-1600x2560.png new file mode 100644 index 0000000..94b799f Binary files /dev/null and b/android-app/playstore-assets/final/tablet-10/ChatGPT Image 30. Mai 2026, 00_13_26-1600x2560.png differ diff --git a/android-app/playstore-assets/final/tablet-10/ChatGPT Image 30. Mai 2026, 00_15_36-1600x2560.png b/android-app/playstore-assets/final/tablet-10/ChatGPT Image 30. Mai 2026, 00_15_36-1600x2560.png new file mode 100644 index 0000000..34ff045 Binary files /dev/null and b/android-app/playstore-assets/final/tablet-10/ChatGPT Image 30. Mai 2026, 00_15_36-1600x2560.png differ diff --git a/android-app/playstore-assets/final/tablet-10/Screenshot_20260530_000103-1600x2560.png b/android-app/playstore-assets/final/tablet-10/Screenshot_20260530_000103-1600x2560.png new file mode 100644 index 0000000..b7fc4b9 Binary files /dev/null and b/android-app/playstore-assets/final/tablet-10/Screenshot_20260530_000103-1600x2560.png differ diff --git a/android-app/playstore-assets/final/tablet-10/Screenshot_20260530_000301-1600x2560.png b/android-app/playstore-assets/final/tablet-10/Screenshot_20260530_000301-1600x2560.png new file mode 100644 index 0000000..64eeb25 Binary files /dev/null and b/android-app/playstore-assets/final/tablet-10/Screenshot_20260530_000301-1600x2560.png differ diff --git a/android-app/playstore-assets/raw/ChatGPT Image 30. Mai 2026, 00_13_26.png b/android-app/playstore-assets/raw/ChatGPT Image 30. Mai 2026, 00_13_26.png new file mode 100644 index 0000000..c1ec8ed Binary files /dev/null and b/android-app/playstore-assets/raw/ChatGPT Image 30. Mai 2026, 00_13_26.png differ diff --git a/android-app/playstore-assets/raw/ChatGPT Image 30. Mai 2026, 00_15_36.png b/android-app/playstore-assets/raw/ChatGPT Image 30. Mai 2026, 00_15_36.png new file mode 100644 index 0000000..84938ed Binary files /dev/null and b/android-app/playstore-assets/raw/ChatGPT Image 30. Mai 2026, 00_15_36.png differ diff --git a/android-app/playstore-assets/raw/Screenshot_20260530_000103.png b/android-app/playstore-assets/raw/Screenshot_20260530_000103.png new file mode 100644 index 0000000..5238e43 Binary files /dev/null and b/android-app/playstore-assets/raw/Screenshot_20260530_000103.png differ diff --git a/android-app/playstore-assets/raw/Screenshot_20260530_000301.png b/android-app/playstore-assets/raw/Screenshot_20260530_000301.png new file mode 100644 index 0000000..4c5544d Binary files /dev/null and b/android-app/playstore-assets/raw/Screenshot_20260530_000301.png differ diff --git a/android-app/playstore-assets/raw/smartphone/ChatGPT Image 30. Mai 2026, 00_27_40.png b/android-app/playstore-assets/raw/smartphone/ChatGPT Image 30. Mai 2026, 00_27_40.png new file mode 100644 index 0000000..36373ce Binary files /dev/null and b/android-app/playstore-assets/raw/smartphone/ChatGPT Image 30. Mai 2026, 00_27_40.png differ diff --git a/android-app/playstore-assets/raw/smartphone/Screenshot_20260530_002151.png b/android-app/playstore-assets/raw/smartphone/Screenshot_20260530_002151.png new file mode 100644 index 0000000..8cd91b6 Binary files /dev/null and b/android-app/playstore-assets/raw/smartphone/Screenshot_20260530_002151.png differ diff --git a/android-app/playstore-assets/raw/smartphone/Screenshot_20260530_002305.png b/android-app/playstore-assets/raw/smartphone/Screenshot_20260530_002305.png new file mode 100644 index 0000000..82a7454 Binary files /dev/null and b/android-app/playstore-assets/raw/smartphone/Screenshot_20260530_002305.png differ diff --git a/components/Navigation.vue b/components/Navigation.vue index 38e2fb2..70fc015 100644 --- a/components/Navigation.vue +++ b/components/Navigation.vue @@ -274,6 +274,13 @@ > Mitgliederliste + + QTTR + Mitgliederliste + + QTTR + { - try { - return useAuthStore() - } catch (e) { - // Fallback if Pinia is not yet initialized - return null - } -} - -// Reactive auth state from store (lazy) -const isLoggedIn = computed(() => { - const store = getAuthStore() - return store?.isLoggedIn ?? false -}) -const isAdmin = computed(() => { - const store = getAuthStore() - return store?.isAdmin ?? false -}) -const canAccessNewsletter = computed(() => { - const store = getAuthStore() - return store?.hasAnyRole('admin', 'vorstand', 'newsletter') ?? false -}) -const canAccessContactRequests = computed(() => { - const store = getAuthStore() - return store?.hasAnyRole('admin', 'vorstand', 'trainer') ?? false -}) +const isLoggedIn = computed(() => authStore.isLoggedIn) +const isAdmin = computed(() => authStore.isAdmin) +const canAccessNewsletter = computed(() => authStore.hasAnyRole('admin', 'vorstand', 'newsletter')) +const canAccessContactRequests = computed(() => authStore.hasAnyRole('admin', 'vorstand', 'trainer')) +const canManageUsers = computed(() => authStore.hasAnyRole('admin', 'vorstand')) // Automatisches Setzen des Submenus basierend auf der Route const currentSubmenu = computed(() => { @@ -982,10 +975,7 @@ const handleDocumentClick = (e) => { onMounted(() => { loadMannschaften() checkGalleryImages() - const store = getAuthStore() - if (store) { - store.checkAuth() - } + authStore.checkAuth() // Close CMS dropdown when clicking outside document.addEventListener('click', handleDocumentClick) diff --git a/package.json b/package.json index 76376fd..843aca5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "harheimertc-website", - "version": "1.7.0", + "version": "1.8.0", "description": "Moderne Webseite für den Harheimer Tischtennis Club", "private": true, "type": "module", @@ -23,6 +23,7 @@ "publish-spielplan": "node scripts/publish-imported-spielplan.js", "playstore:assets": "./scripts/playstore-assets.sh", "playstore:anonymize": "./scripts/anonymize-playstore-screenshot.sh", + "playstore:screenshots": "./scripts/playstore-screenshot-sizes.sh", "test:watch": "vitest watch", "lint": "eslint . --fix" }, diff --git a/pages/mitgliederbereich/qttr.vue b/pages/mitgliederbereich/qttr.vue new file mode 100644 index 0000000..53291e2 --- /dev/null +++ b/pages/mitgliederbereich/qttr.vue @@ -0,0 +1,176 @@ + + + \ No newline at end of file diff --git a/scripts/anonymize-playstore-screenshot.sh b/scripts/anonymize-playstore-screenshot.sh index 1212258..3633fa1 100755 --- a/scripts/anonymize-playstore-screenshot.sh +++ b/scripts/anonymize-playstore-screenshot.sh @@ -24,7 +24,7 @@ IFS=';' read -r -a BOXES <<< "$RECTS" for box in "${BOXES[@]}"; do IFS=',' read -r x y w h <<< "$box" magick "$TMP" \ - \( -size "${w}x${h}" xc:black -alpha set -channel a -evaluate set 70% +channel \) \ + \( -size "${w}x${h}" xc:black -alpha off \) \ -geometry "+${x}+${y}" -composite "$TMP" done diff --git a/scripts/playstore-screenshot-sizes.mjs b/scripts/playstore-screenshot-sizes.mjs new file mode 100644 index 0000000..827a248 --- /dev/null +++ b/scripts/playstore-screenshot-sizes.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +import { readdir, mkdir } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import sharp from 'sharp' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const rootDir = path.resolve(__dirname, '..') + +const args = process.argv.slice(2) + +function readArg(flag, fallback = '') { + const idx = args.indexOf(flag) + if (idx === -1) return fallback + return args[idx + 1] || fallback +} + +const inputDirArg = readArg('--input-dir', 'android-app/playstore-assets/anon') +const outputDirArg = readArg('--output-dir', 'android-app/playstore-assets/final') +const inputDir = path.resolve(rootDir, inputDirArg) +const outputDir = path.resolve(rootDir, outputDirArg) + +const profiles = [ + { key: 'phone', width: 1080, height: 1920 }, + { key: 'tablet-7', width: 1200, height: 1920 }, + { key: 'tablet-10', width: 1600, height: 2560 }, +] + +function isImageFile(name) { + const lower = name.toLowerCase() + return lower.endsWith('.png') || lower.endsWith('.jpg') || lower.endsWith('.jpeg') +} + +async function processFile(fileName) { + const inputPath = path.join(inputDir, fileName) + const parsed = path.parse(fileName) + + for (const profile of profiles) { + const profileDir = path.join(outputDir, profile.key) + await mkdir(profileDir, { recursive: true }) + + const outputPath = path.join( + profileDir, + `${parsed.name}-${profile.width}x${profile.height}.png`, + ) + + // Use contain to preserve all UI content and add solid bars only if needed. + await sharp(inputPath) + .resize(profile.width, profile.height, { + fit: 'contain', + background: { r: 0, g: 0, b: 0, alpha: 1 }, + }) + .png() + .toFile(outputPath) + + console.log(`Created: ${path.relative(rootDir, outputPath)}`) + } +} + +async function run() { + const files = await readdir(inputDir) + const images = files.filter(isImageFile) + + if (images.length === 0) { + console.error(`No PNG/JPG files found in: ${path.relative(rootDir, inputDir)}`) + process.exitCode = 1 + return + } + + await mkdir(outputDir, { recursive: true }) + for (const image of images) { + await processFile(image) + } + + console.log(`Done. Output dir: ${path.relative(rootDir, outputDir)}`) +} + +run().catch((error) => { + console.error('Failed to generate screenshot profiles:', error) + process.exitCode = 1 +}) diff --git a/scripts/playstore-screenshot-sizes.sh b/scripts/playstore-screenshot-sizes.sh new file mode 100755 index 0000000..fcd8ed5 --- /dev/null +++ b/scripts/playstore-screenshot-sizes.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +node "$ROOT_DIR/scripts/playstore-screenshot-sizes.mjs" "$@" 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