From 3d1dfe9a4ce2b739b96ae4d77d6cb9e7b0d243fe Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Thu, 14 May 2026 16:25:16 +0200 Subject: [PATCH] feat(Localization): enhance localization for tournament statistics and UI components - Added new localization keys for tournament statistics panels across multiple languages, improving user accessibility. - Updated the TournamentsScreen in the mobile app to include a search feature and display internal tournament statistics. - Enhanced the Tournaments API to support fetching internal tournament statistics, providing detailed insights for users. - Improved UI components for better organization and interaction within the tournaments section, enhancing overall user experience. --- frontend/src/i18n/locales/de-CH.json | 9 +- frontend/src/i18n/locales/de-extended.json | 9 +- frontend/src/i18n/locales/en-AU.json | 9 +- frontend/src/i18n/locales/en-GB.json | 9 +- frontend/src/i18n/locales/es.json | 9 +- frontend/src/i18n/locales/fil.json | 9 +- frontend/src/i18n/locales/fr.json | 9 +- frontend/src/i18n/locales/it.json | 9 +- frontend/src/i18n/locales/ja.json | 9 +- frontend/src/i18n/locales/pl.json | 9 +- frontend/src/i18n/locales/th.json | 9 +- frontend/src/i18n/locales/tl.json | 9 +- frontend/src/i18n/locales/zh.json | 9 +- .../app/ui/InternalTournamentStatsDialog.kt | 297 ++++++++++++++++++ .../tt_tagebuch/app/ui/TournamentsScreen.kt | 259 ++++++++++++--- .../tt_tagebuch/shared/api/TournamentsApi.kt | 12 + .../shared/api/models/TournamentDtos.kt | 30 ++ 17 files changed, 660 insertions(+), 55 deletions(-) create mode 100644 mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/InternalTournamentStatsDialog.kt diff --git a/frontend/src/i18n/locales/de-CH.json b/frontend/src/i18n/locales/de-CH.json index 6bab6c70..44472838 100644 --- a/frontend/src/i18n/locales/de-CH.json +++ b/frontend/src/i18n/locales/de-CH.json @@ -976,7 +976,14 @@ "participationsTotal": "Teilnahmen (Gesamt)", "lastTraining": "Letztes Training", "actions": "Aktionen", - "showDetails": "Details anzeigen" + "showDetails": "Details anzeigen", + "panelSummary": "Kennzahlen (Filter)", + "panelMonthlyTrend": "Monatlicher Verlauf", + "panelWeekdayStats": "Trainingstage nach Wochentag", + "panelMemberStructure": "Mitgliederstruktur", + "panelBestDay": "Stärkster Trainingstag", + "panelGroupPerformance": "Entwicklung pro Gruppe", + "panelAgeGroups": "Anwesenheit nach Altersklasse" }, "courtDrawingTool": { "title": "Tischtennis-Übungszeichnung", diff --git a/frontend/src/i18n/locales/de-extended.json b/frontend/src/i18n/locales/de-extended.json index 49b94ac4..81de30b7 100644 --- a/frontend/src/i18n/locales/de-extended.json +++ b/frontend/src/i18n/locales/de-extended.json @@ -1287,7 +1287,14 @@ "participationsTotal": "Teilnahmen (Gesamt)", "lastTraining": "Letztes Training", "actions": "Aktionen", - "showDetails": "Details anzeigen" + "showDetails": "Details anzeigen", + "panelSummary": "Kennzahlen (Filter)", + "panelMonthlyTrend": "Monatlicher Verlauf", + "panelWeekdayStats": "Trainingstage nach Wochentag", + "panelMemberStructure": "Mitgliederstruktur", + "panelBestDay": "Stärkster Trainingstag", + "panelGroupPerformance": "Entwicklung pro Gruppe", + "panelAgeGroups": "Anwesenheit nach Altersklasse" }, "tournament": { "apply": "Übernehmen" diff --git a/frontend/src/i18n/locales/en-AU.json b/frontend/src/i18n/locales/en-AU.json index 5f85b76f..38d5d657 100644 --- a/frontend/src/i18n/locales/en-AU.json +++ b/frontend/src/i18n/locales/en-AU.json @@ -975,7 +975,14 @@ "participationsTotal": "Participations (total)", "lastTraining": "Last training", "actions": "Actions", - "showDetails": "Show details" + "showDetails": "Show details", + "panelSummary": "Key figures (filtered)", + "panelMonthlyTrend": "Monthly trend", + "panelWeekdayStats": "Training days by weekday", + "panelMemberStructure": "Member structure", + "panelBestDay": "Busiest training day", + "panelGroupPerformance": "Progress by group", + "panelAgeGroups": "Attendance by age class" }, "courtDrawingTool": { "title": "Table tennis exercise drawing", diff --git a/frontend/src/i18n/locales/en-GB.json b/frontend/src/i18n/locales/en-GB.json index 06f96b3e..3ac08e6b 100644 --- a/frontend/src/i18n/locales/en-GB.json +++ b/frontend/src/i18n/locales/en-GB.json @@ -1091,7 +1091,14 @@ "participationsTotal": "Participations (total)", "lastTraining": "Last training", "actions": "Actions", - "showDetails": "Show details" + "showDetails": "Show details", + "panelSummary": "Key figures (filtered)", + "panelMonthlyTrend": "Monthly trend", + "panelWeekdayStats": "Training days by weekday", + "panelMemberStructure": "Member structure", + "panelBestDay": "Busiest training day", + "panelGroupPerformance": "Progress by group", + "panelAgeGroups": "Attendance by age class" }, "courtDrawingTool": { "title": "Table tennis exercise drawing", diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 3d20b8a6..8f315ff1 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -941,7 +941,14 @@ "participationsTotal": "Participaciones (total)", "lastTraining": "Último entrenamiento", "actions": "Acciones", - "showDetails": "Mostrar detalles" + "showDetails": "Mostrar detalles", + "panelSummary": "Cifras clave (filtro)", + "panelMonthlyTrend": "Tendencia mensual", + "panelWeekdayStats": "Días de entreno por día de la semana", + "panelMemberStructure": "Estructura de miembros", + "panelBestDay": "Día de entreno con más asistencia", + "panelGroupPerformance": "Evolución por grupo", + "panelAgeGroups": "Asistencia por categoría de edad" }, "courtDrawingTool": { "title": "Dibujo de ejercicio de tenis de mesa", diff --git a/frontend/src/i18n/locales/fil.json b/frontend/src/i18n/locales/fil.json index 6df2d35a..4a70df21 100644 --- a/frontend/src/i18n/locales/fil.json +++ b/frontend/src/i18n/locales/fil.json @@ -941,7 +941,14 @@ "participationsTotal": "Paglahok (kabuuan)", "lastTraining": "Huling pagsasanay", "actions": "Mga aksyon", - "showDetails": "Ipakita ang mga detalye" + "showDetails": "Ipakita ang mga detalye", + "panelSummary": "Mahahalagang numero (salain)", + "panelMonthlyTrend": "Buwanang uso", + "panelWeekdayStats": "Mga araw ng ensayo ayon sa araw ng linggo", + "panelMemberStructure": "Istraktura ng miyembro", + "panelBestDay": "Pinakamataong araw ng ensayo", + "panelGroupPerformance": "Pag-unlad ayon sa grupo", + "panelAgeGroups": "Dalo ayon sa edad" }, "courtDrawingTool": { "title": "Guhit ng ehersisyo sa table tennis", diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 536a26bf..7b30d91e 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -941,7 +941,14 @@ "participationsTotal": "Participations (total)", "lastTraining": "Dernier entraînement", "actions": "Actions", - "showDetails": "Afficher les détails" + "showDetails": "Afficher les détails", + "panelSummary": "Chiffres clés (filtre)", + "panelMonthlyTrend": "Évolution mensuelle", + "panelWeekdayStats": "Jours d'entraînement par jour de la semaine", + "panelMemberStructure": "Structure des membres", + "panelBestDay": "Jour d'entraînement le plus fréquenté", + "panelGroupPerformance": "Progression par groupe", + "panelAgeGroups": "Présence par catégorie d'âge" }, "courtDrawingTool": { "title": "Schéma d'exercice de tennis de table", diff --git a/frontend/src/i18n/locales/it.json b/frontend/src/i18n/locales/it.json index 8790e1fc..1a5bc9ab 100644 --- a/frontend/src/i18n/locales/it.json +++ b/frontend/src/i18n/locales/it.json @@ -941,7 +941,14 @@ "participationsTotal": "Partecipazioni (totale)", "lastTraining": "Ultimo allenamento", "actions": "Azioni", - "showDetails": "Mostra dettagli" + "showDetails": "Mostra dettagli", + "panelSummary": "Cifre chiave (filtro)", + "panelMonthlyTrend": "Andamento mensile", + "panelWeekdayStats": "Giorni di allenamento per giorno", + "panelMemberStructure": "Struttura membri", + "panelBestDay": "Giorno di allenamento più frequentato", + "panelGroupPerformance": "Andamento per gruppo", + "panelAgeGroups": "Presenze per fascia d'età" }, "courtDrawingTool": { "title": "Schema di esercizio di tennistavolo", diff --git a/frontend/src/i18n/locales/ja.json b/frontend/src/i18n/locales/ja.json index 6ef0e18a..a3761617 100644 --- a/frontend/src/i18n/locales/ja.json +++ b/frontend/src/i18n/locales/ja.json @@ -941,7 +941,14 @@ "participationsTotal": "参加回数(合計)", "lastTraining": "最終練習", "actions": "操作", - "showDetails": "詳細を表示" + "showDetails": "詳細を表示", + "panelSummary": "主要指標(フィルター)", + "panelMonthlyTrend": "月次の推移", + "panelWeekdayStats": "曜日別の練習日", + "panelMemberStructure": "メンバー構成", + "panelBestDay": "最も参加が多い練習日", + "panelGroupPerformance": "グループ別の推移", + "panelAgeGroups": "年齢クラス別の出席" }, "courtDrawingTool": { "title": "卓球練習図", diff --git a/frontend/src/i18n/locales/pl.json b/frontend/src/i18n/locales/pl.json index ba6b6582..ee96c583 100644 --- a/frontend/src/i18n/locales/pl.json +++ b/frontend/src/i18n/locales/pl.json @@ -941,7 +941,14 @@ "participationsTotal": "Udziały (łącznie)", "lastTraining": "Ostatni trening", "actions": "Akcje", - "showDetails": "Pokaż szczegóły" + "showDetails": "Pokaż szczegóły", + "panelSummary": "Kluczowe liczby (filtr)", + "panelMonthlyTrend": "Trend miesięczny", + "panelWeekdayStats": "Dni treningowe wg dnia tygodnia", + "panelMemberStructure": "Struktura członków", + "panelBestDay": "Najbardziej uczęszczany trening", + "panelGroupPerformance": "Rozwój wg grup", + "panelAgeGroups": "Frekwencja wg kategorii wiekowej" }, "courtDrawingTool": { "title": "Rysunek ćwiczenia tenisa stołowego", diff --git a/frontend/src/i18n/locales/th.json b/frontend/src/i18n/locales/th.json index 47615e4b..9836d769 100644 --- a/frontend/src/i18n/locales/th.json +++ b/frontend/src/i18n/locales/th.json @@ -941,7 +941,14 @@ "participationsTotal": "การเข้าร่วม (ทั้งหมด)", "lastTraining": "การฝึกซ้อมล่าสุด", "actions": "การดำเนินการ", - "showDetails": "แสดงรายละเอียด" + "showDetails": "แสดงรายละเอียด", + "panelSummary": "ตัวเลขสำคัญ (กรอง)", + "panelMonthlyTrend": "แนวโน้มรายเดือน", + "panelWeekdayStats": "วันซ้อมตามวันในสัปดาห์", + "panelMemberStructure": "โครงสร้างสมาชิก", + "panelBestDay": "วันซ้อมที่คึกคักที่สุด", + "panelGroupPerformance": "พัฒนาการตามกลุ่ม", + "panelAgeGroups": "การเข้าร่วมตามกลุ่มอายุ" }, "courtDrawingTool": { "title": "ภาพวาดแบบฝึกปิงปอง", diff --git a/frontend/src/i18n/locales/tl.json b/frontend/src/i18n/locales/tl.json index e8fd4ac5..de650285 100644 --- a/frontend/src/i18n/locales/tl.json +++ b/frontend/src/i18n/locales/tl.json @@ -941,7 +941,14 @@ "participationsTotal": "Paglahok (kabuuan)", "lastTraining": "Huling pagsasanay", "actions": "Mga aksyon", - "showDetails": "Ipakita ang mga detalye" + "showDetails": "Ipakita ang mga detalye", + "panelSummary": "Pangunahing bilang (salain)", + "panelMonthlyTrend": "Buwanang uso", + "panelWeekdayStats": "Mga araw ng ensayo ayon sa araw ng linggo", + "panelMemberStructure": "Istraktura ng miyembro", + "panelBestDay": "Pinakamataong araw ng ensayo", + "panelGroupPerformance": "Pag-unlad ayon sa grupo", + "panelAgeGroups": "Dalo ayon sa edad" }, "courtDrawingTool": { "title": "Guhit ng ehersisyo sa table tennis", diff --git a/frontend/src/i18n/locales/zh.json b/frontend/src/i18n/locales/zh.json index d08ed114..42aa3c66 100644 --- a/frontend/src/i18n/locales/zh.json +++ b/frontend/src/i18n/locales/zh.json @@ -941,7 +941,14 @@ "participationsTotal": "参与次数(总计)", "lastTraining": "最近训练", "actions": "操作", - "showDetails": "显示详情" + "showDetails": "显示详情", + "panelSummary": "关键数据(筛选)", + "panelMonthlyTrend": "月度趋势", + "panelWeekdayStats": "按星期几的训练日", + "panelMemberStructure": "成员结构", + "panelBestDay": "参与人数最多的训练日", + "panelGroupPerformance": "各组进展", + "panelAgeGroups": "按年龄组的出勤" }, "courtDrawingTool": { "title": "乒乓球练习示意图", diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/InternalTournamentStatsDialog.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/InternalTournamentStatsDialog.kt new file mode 100644 index 00000000..a8b50116 --- /dev/null +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/InternalTournamentStatsDialog.kt @@ -0,0 +1,297 @@ +package de.tt_tagebuch.app.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.AlertDialog +import androidx.compose.material.Checkbox +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import de.tt_tagebuch.app.AppDependencies +import de.tt_tagebuch.shared.api.models.InternalTournamentStatsAgeOption +import de.tt_tagebuch.shared.api.models.InternalTournamentStatsDto +import de.tt_tagebuch.shared.i18n.MobileStrings + +private fun allBandKeysFromOptions(options: List): Set { + val seen = mutableSetOf() + val out = linkedSetOf() + for (o in options) { + if (o.isNoClass == true) continue + val bk = when { + o.band == "adult" -> "adult" + o.bandNum != null -> o.bandNum.toString() + else -> continue + } + if (seen.add(bk)) out.add(bk) + } + return out +} + +private fun effectiveAgeClassKeys( + options: List, + selectedBandKeys: Set, + genderScope: String, +): List { + val valid = options.map { it.key }.toSet() + val genderModes = if (genderScope == "female") listOf("female") else listOf("female", "open") + val out = mutableListOf() + for (bk in selectedBandKeys) { + for (g in genderModes) { + val key = if (bk == "adult") "tt|adult|$g" else "tt|$bk|$g" + if (key in valid) out.add(key) + } + } + return out +} + +private fun buildAgeClassKeysQuery( + options: List, + selectedBandKeys: Set, + genderScope: String, +): String? { + if (options.isEmpty()) return null + val allKeys = options.map { it.key }.toSet() + val effective = effectiveAgeClassKeys(options, selectedBandKeys, genderScope).toSet() + if (effective.isEmpty()) return "" + if (effective == allKeys) return null + return effective.joinToString(",") +} + +private fun bandOptionsForUi(options: List): List> { + val byKey = linkedMapOf() + for (o in options) { + if (o.isNoClass == true) continue + val bandKey = if (o.band == "adult") "adult" else o.bandNum?.toString() ?: continue + if (!byKey.containsKey(bandKey)) { + byKey[bandKey] = o + } + } + return byKey.entries.sortedWith(compareBy { (_, o) -> + if (o.band == "adult") 1000 else o.bandNum ?: 0 + }).map { it.key to it.value } +} + +private fun formatBandLabel(o: InternalTournamentStatsAgeOption, tr: (String, String) -> String): String { + return when { + o.band == "youth" && o.bandNum != null -> "J${o.bandNum}" + o.band == "adult" -> tr("tournaments.internalStatsTtAdult", "Erwachsene") + else -> o.bandNum?.toString() ?: "" + } +} + +@Composable +fun InternalTournamentStatsDialog( + clubId: Int, + dependencies: AppDependencies, + onDismiss: () -> Unit, +) { + val languageCode = LocalLanguageCode.current + fun tr(key: String, fb: String) = MobileStrings.get(languageCode, key, fb) + + var months by remember { mutableStateOf(12) } + var genderScope by remember { mutableStateOf("all") } + var selectedBandKeys by remember { mutableStateOf>(emptySet()) } + var bandsInitialized by remember { mutableStateOf(false) } + + var stats by remember { mutableStateOf(null) } + var loading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(null) } + + val ageClassQuery = remember(stats, selectedBandKeys, genderScope) { + val opts = stats?.ageClassOptions.orEmpty() + buildAgeClassKeysQuery(opts, selectedBandKeys, genderScope) + } + + LaunchedEffect(clubId, months, ageClassQuery) { + loading = true + error = null + runCatching { + dependencies.tournamentsApi.getInternalTournamentStats(clubId, months, ageClassQuery) + }.fold( + onSuccess = { data -> + stats = data + if (!bandsInitialized && data.ageClassOptions.isNotEmpty()) { + selectedBandKeys = allBandKeysFromOptions(data.ageClassOptions) + genderScope = "all" + bandsInitialized = true + } + }, + onFailure = { t -> + error = t.message ?: tr("mobile.loadingError", "Fehler beim Laden") + stats = null + }, + ) + loading = false + } + + val scroll = rememberScrollState() + val s = stats + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(tr("tournaments.internalStatsTitle", "Interne Turnier-Statistik (Einzel)")) }, + text = { + Column( + modifier = Modifier + .heightIn(max = 520.dp) + .verticalScroll(scroll), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + listOf( + 12 to tr("tournaments.internalStatsLast12Months", "Letzte 12 Monate"), + 6 to tr("tournaments.internalStatsLast6Months", "Letzte 6 Monate"), + 3 to tr("tournaments.internalStatsLast3Months", "Letzte 3 Monate"), + ).forEach { (m, fullLabel) -> + TextButton( + onClick = { + months = m + bandsInitialized = false + selectedBandKeys = emptySet() + }, + enabled = !loading, + ) { + Text( + if (months == m) "✓ $fullLabel" else fullLabel, + style = MaterialTheme.typography.caption, + fontWeight = if (months == m) FontWeight.Bold else FontWeight.Normal, + ) + } + } + } + if (loading) { + CircularProgressIndicator(modifier = Modifier.padding(16.dp).align(Alignment.CenterHorizontally)) + } + error?.let { + Text(it, color = MaterialTheme.colors.error, modifier = Modifier.padding(vertical = 8.dp)) + } + if (s != null && !loading) { + Text( + tr("tournaments.internalStatsTournamentsInPeriod", "{count} Turniere im Zeitraum.") + .replace("{count}", (s.tournamentCount).toString()), + style = MaterialTheme.typography.body2, + modifier = Modifier.padding(bottom = 6.dp), + ) + Text( + tr("tournaments.internalStatsPointsExplain", "Punkte: Gruppenplatz als % plus K.-o.-Bonus."), + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.75f), + modifier = Modifier.padding(bottom = 8.dp), + ) + val bands = bandOptionsForUi(s.ageClassOptions) + if (bands.isNotEmpty()) { + Text(tr("tournaments.internalStatsAgeFilter", "Altersklassen"), fontWeight = FontWeight.SemiBold) + Row(modifier = Modifier.padding(vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { + selectedBandKeys = allBandKeysFromOptions(s.ageClassOptions) + genderScope = "all" + }) { Text(tr("tournaments.internalStatsAgeSelectAll", "Alle"), style = MaterialTheme.typography.caption) } + OutlinedButton(onClick = { selectedBandKeys = emptySet() }) { + Text(tr("tournaments.internalStatsAgeSelectNone", "Keine"), style = MaterialTheme.typography.caption) + } + } + bands.forEach { (bandKey, opt) -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Checkbox( + checked = bandKey in selectedBandKeys, + onCheckedChange = { checked -> + selectedBandKeys = if (checked) { + selectedBandKeys + bandKey + } else { + selectedBandKeys - bandKey + } + }, + ) + Text(formatBandLabel(opt, ::tr)) + } + } + Text(tr("tournaments.internalStatsFilterGenderColumn", "Geschlecht"), fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = 8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { genderScope = "all" }) { + Text( + if (genderScope == "all") "✓ ${tr("tournaments.internalStatsGenderScopeAll", "Alle")}" else tr("tournaments.internalStatsGenderScopeAll", "Alle"), + style = MaterialTheme.typography.caption, + ) + } + TextButton(onClick = { genderScope = "female" }) { + Text( + if (genderScope == "female") "✓ ${tr("tournaments.internalStatsGenderScopeGirls", "Mädchen/Frauen")}" else tr("tournaments.internalStatsGenderScopeGirls", "Mädchen/Frauen"), + style = MaterialTheme.typography.caption, + ) + } + } + Divider(modifier = Modifier.padding(vertical = 8.dp)) + } + Text(tr("tournaments.internalStatsAbsoluteRank", "Absolut"), fontWeight = FontWeight.SemiBold) + if (s.absoluteRanking.isEmpty()) { + Text(tr("tournaments.internalStatsEmpty", "Keine Daten"), style = MaterialTheme.typography.caption) + } else { + s.absoluteRanking.take(40).forEachIndexed { i, r -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 3.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text("${i + 1}. ${r.firstName.orEmpty()} ${r.lastName.orEmpty()}".trim(), modifier = Modifier.weight(1f)) + Text( + r.totalPoints?.let { v -> "%.2f".format(v) } ?: "–", + fontWeight = FontWeight.Medium, + ) + } + } + } + Spacer(modifier = Modifier.height(12.dp)) + Text(tr("tournaments.internalStatsAverageRank", "Ø pro Turnier"), fontWeight = FontWeight.SemiBold) + if (s.averageRanking.isEmpty()) { + Text(tr("tournaments.internalStatsEmpty", "Keine Daten"), style = MaterialTheme.typography.caption) + } else { + s.averageRanking.take(40).forEachIndexed { i, r -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 3.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text("${i + 1}. ${r.firstName.orEmpty()} ${r.lastName.orEmpty()}".trim(), modifier = Modifier.weight(1f)) + Text(r.averagePoints?.toString() ?: "–", fontWeight = FontWeight.Medium) + } + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(tr("common.close", "Schließen")) + } + }, + ) +} diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TournamentsScreen.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TournamentsScreen.kt index 355cd899..a61a3124 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TournamentsScreen.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TournamentsScreen.kt @@ -1,7 +1,12 @@ package de.tt_tagebuch.app.ui +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -10,22 +15,30 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material.Card +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Divider import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import de.tt_tagebuch.app.AppDependencies +import de.tt_tagebuch.shared.api.models.InternalTournamentSummaryDto import de.tt_tagebuch.shared.api.models.OfficialParticipationEntryDto import de.tt_tagebuch.shared.api.models.canReadTournaments import de.tt_tagebuch.shared.i18n.MobileStrings @@ -49,6 +62,8 @@ internal fun TournamentsScreen(dependencies: AppDependencies) { val perms = clubState.currentPermissions val internalState by dependencies.clubInternalTournamentsManager.state.collectAsState() val officialState by dependencies.officialTournamentsReadManager.state.collectAsState() + var searchQuery by rememberSaveable { mutableStateOf("") } + var showInternalStats by remember { mutableStateOf(false) } if (perms?.canReadTournaments() != true) { Column( @@ -82,6 +97,42 @@ internal fun TournamentsScreen(dependencies: AppDependencies) { } } + val modeDescription = when (internalState.filter) { + ClubTournamentDisplayFilter.Mini -> tr("tournaments.miniChampionships", "Mini") + ClubTournamentDisplayFilter.External -> tr("tournaments.openTournaments", "Offen") + ClubTournamentDisplayFilter.Internal -> tr("tournaments.internalTournaments", "Vereins-Turniere") + } + + val sortedInternal = remember(internalState.tournaments) { + internalState.tournaments.sortedByDescending { it.date ?: "" } + } + + val filteredInternal = remember(sortedInternal, searchQuery) { + val q = searchQuery.trim().lowercase() + if (q.isEmpty()) sortedInternal + else { + sortedInternal.filter { t -> + val name = (t.name ?: "").lowercase() + val date = (t.date ?: "").lowercase() + name.contains(q) || date.contains(q) + } + } + } + + val countLabel = remember(filteredInternal.size, sortedInternal.size, languageCode) { + tr("tournaments.showingTournamentCount", "{visible} von {total}") + .replace("{visible}", filteredInternal.size.toString()) + .replace("{total}", sortedInternal.size.toString()) + } + + if (showInternalStats) { + InternalTournamentStatsDialog( + clubId = clubId, + dependencies = dependencies, + onDismiss = { showInternalStats = false }, + ) + } + LazyColumn( modifier = Modifier .fillMaxSize() @@ -103,26 +154,77 @@ internal fun TournamentsScreen(dependencies: AppDependencies) { } item { - Text(tr("tournaments.internalTournaments", "Vereins-Turniere"), fontWeight = FontWeight.SemiBold) - Column(modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) { - ModeFilterChip( - label = tr("mobile.tournamentFilterInternal", "Intern"), + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TournamentModePill( + label = "🏆 " + tr("tournaments.internalTournaments", "Interne Turniere"), selected = internalState.filter == ClubTournamentDisplayFilter.Internal, - onClick = { dependencies.clubInternalTournamentsManager.setFilter(ClubTournamentDisplayFilter.Internal) }, + onClick = { + dependencies.clubInternalTournamentsManager.setFilter(ClubTournamentDisplayFilter.Internal) + }, ) - ModeFilterChip( - label = tr("tournaments.openTournaments", "Offen"), + TournamentModePill( + label = "🌐 " + tr("tournaments.openTournaments", "Offene Turniere"), selected = internalState.filter == ClubTournamentDisplayFilter.External, - onClick = { dependencies.clubInternalTournamentsManager.setFilter(ClubTournamentDisplayFilter.External) }, + onClick = { + dependencies.clubInternalTournamentsManager.setFilter(ClubTournamentDisplayFilter.External) + }, ) - ModeFilterChip( - label = tr("tournaments.miniChampionships", "Mini"), + TournamentModePill( + label = "🏅 " + tr("tournaments.miniChampionships", "Minimeisterschaften"), selected = internalState.filter == ClubTournamentDisplayFilter.Mini, - onClick = { dependencies.clubInternalTournamentsManager.setFilter(ClubTournamentDisplayFilter.Mini) }, + onClick = { + dependencies.clubInternalTournamentsManager.setFilter(ClubTournamentDisplayFilter.Mini) + }, ) } } + item { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + modeDescription, + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.85f), + modifier = Modifier.weight(1f), + ) + if (internalState.filter == ClubTournamentDisplayFilter.Internal) { + TextButton(onClick = { showInternalStats = true }) { + Text( + "📊 " + tr("tournaments.internalStatsOpenButton", "Turnierstatistik (Einzel)"), + style = MaterialTheme.typography.caption, + fontWeight = FontWeight.SemiBold, + ) + } + } + } + } + + item { + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text(tr("tournaments.tournamentName", "Turniername")) }, + placeholder = { Text(tr("tournaments.tournamentName", "Turniername")) }, + ) + Text( + countLabel, + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.65f), + modifier = Modifier.padding(top = 4.dp), + ) + } + if (internalState.isLoadingList) { item { CircularProgressIndicator(modifier = Modifier.padding(vertical = 16.dp)) } } else { @@ -131,7 +233,7 @@ internal fun TournamentsScreen(dependencies: AppDependencies) { Text(err, color = MaterialTheme.colors.error) } } - if (internalState.tournaments.isEmpty()) { + if (sortedInternal.isEmpty()) { item { Text( tr("mobile.noClubTournaments", "Keine Turniere in dieser Ansicht."), @@ -139,35 +241,37 @@ internal fun TournamentsScreen(dependencies: AppDependencies) { modifier = Modifier.padding(vertical = 8.dp), ) } + } else if (filteredInternal.isEmpty()) { + item { + Column(modifier = Modifier.padding(vertical = 12.dp)) { + Text( + tr("tournaments.noMatchingTournamentsTitle", "Keine passenden Turniere"), + fontWeight = FontWeight.SemiBold, + ) + Text( + tr("tournaments.adjustTournamentSearch", "Suchbegriff anpassen."), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.75f), + modifier = Modifier.padding(top = 4.dp), + ) + } + } } else { itemsIndexed( - internalState.tournaments, - key = { index, t -> "${t.id}-$index" }, + filteredInternal, + key = { _, t -> t.id.toString() }, ) { _, t -> val selected = internalState.selectedId == t.id - Card( - modifier = Modifier.fillMaxWidth(), - elevation = if (selected) 2.dp else 1.dp, - backgroundColor = if (selected) { - MaterialTheme.colors.primary.copy(alpha = 0.08f) - } else { - MaterialTheme.colors.surface + InternalTournamentListCard( + tournament = t, + selected = selected, + unknownDateLabel = tr("tournaments.unknownDate", "Datum unbekannt"), + onClick = { + dependencies.clubInternalTournamentsManager.selectTournament( + if (selected) null else t.id, + ) }, - ) { - TextButton( - onClick = { - dependencies.clubInternalTournamentsManager.selectTournament( - if (selected) null else t.id, - ) - }, - modifier = Modifier.fillMaxWidth().heightIn(min = TournamentsTouchMin), - ) { - Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.Start) { - Text(t.name ?: "Turnier #${t.id}", fontWeight = FontWeight.SemiBold) - t.date?.let { d -> Text(d, style = MaterialTheme.typography.caption) } - } - } - } + ) } } } @@ -265,25 +369,96 @@ internal fun TournamentsScreen(dependencies: AppDependencies) { style = MaterialTheme.typography.caption, color = MaterialTheme.colors.onSurface.copy(alpha = 0.62f), ) - // Web-UI wird in der Produktiv-App nicht aufgerufen. } } } @Composable -private fun ModeFilterChip(label: String, selected: Boolean, onClick: () -> Unit) { +private fun TournamentModePill( + label: String, + selected: Boolean, + onClick: () -> Unit, +) { + val shape = RoundedCornerShape(999.dp) + val bg = if (selected) { + MaterialTheme.colors.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colors.surface + } + val borderColor = if (selected) { + MaterialTheme.colors.primary + } else { + MaterialTheme.colors.onSurface.copy(alpha = 0.12f) + } TextButton( onClick = onClick, - modifier = Modifier.heightIn(min = TournamentsTouchMin), + modifier = Modifier + .heightIn(min = 40.dp) + .clip(shape) + .background(bg) + .border(width = if (selected) 2.dp else 1.dp, color = borderColor, shape = shape), ) { Text( label, - fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, - color = if (selected) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium, ) } } +@Composable +private fun InternalTournamentListCard( + tournament: InternalTournamentSummaryDto, + selected: Boolean, + unknownDateLabel: String, + onClick: () -> Unit, +) { + val shape = RoundedCornerShape(14.dp) + val borderColor = if (selected) { + MaterialTheme.colors.primary + } else { + MaterialTheme.colors.onSurface.copy(alpha = 0.15f) + } + val bg = if (selected) { + MaterialTheme.colors.primary.copy(alpha = 0.08f) + } else { + MaterialTheme.colors.surface + } + val dateLine = tournament.date?.takeIf { it.isNotBlank() } ?: unknownDateLabel + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clip(shape) + .border( + width = if (selected) 2.dp else 1.dp, + color = borderColor, + shape = shape, + ) + .clickable(onClick = onClick), + color = bg, + elevation = if (selected) 4.dp else 1.dp, + shape = shape, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = TournamentsTouchMin) + .padding(horizontal = 16.dp, vertical = 14.dp), + ) { + Text( + tournament.name ?: "Turnier #${tournament.id}", + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.subtitle1, + ) + Text( + dateLine, + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.72f), + ) + } + } +} + @Composable private fun ParticipationRow(tournamentTitle: String?, entry: OfficialParticipationEntryDto) { Column(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)) { diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/TournamentsApi.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/TournamentsApi.kt index 6888c82b..5f73fbc4 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/TournamentsApi.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/TournamentsApi.kt @@ -2,6 +2,7 @@ package de.tt_tagebuch.shared.api import de.tt_tagebuch.shared.api.http.AuthedHttpClient import de.tt_tagebuch.shared.api.models.InternalTournamentDetailDto +import de.tt_tagebuch.shared.api.models.InternalTournamentStatsDto import de.tt_tagebuch.shared.api.models.InternalTournamentSummaryDto import io.ktor.client.call.body import io.ktor.client.request.get @@ -20,4 +21,15 @@ class TournamentsApi( suspend fun getTournament(clubId: Int, tournamentId: Int): InternalTournamentDetailDto { return client.http.get("/api/tournament/$clubId/$tournamentId").body() } + + suspend fun getInternalTournamentStats( + clubId: Int, + months: Int, + ageClassKeys: String?, + ): InternalTournamentStatsDto { + return client.http.get("/api/tournament/internal-stats/$clubId") { + parameter("months", months) + ageClassKeys?.let { parameter("ageClassKeys", it) } + }.body() + } } diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/TournamentDtos.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/TournamentDtos.kt index beb4cebc..802752f5 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/TournamentDtos.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/TournamentDtos.kt @@ -33,6 +33,36 @@ data class InternalTournamentDetailDto( val bestOfEndroundSize: Int? = null, ) +@Serializable +data class InternalTournamentStatsAgeOption( + val key: String, + val band: String? = null, + val bandNum: Int? = null, + val genderMode: String? = null, + @Serializable(with = FlexibleNullableBooleanSerializer::class) + val isNoClass: Boolean? = null, +) + +@Serializable +data class InternalTournamentStatsRow( + val memberId: Int = 0, + val firstName: String? = null, + val lastName: String? = null, + val totalPoints: Double? = null, + val tournamentCount: Int? = null, + val averagePoints: Double? = null, +) + +@Serializable +data class InternalTournamentStatsDto( + val months: Int? = null, + val fromDate: String? = null, + val tournamentCount: Int = 0, + val ageClassOptions: List = emptyList(), + val absoluteRanking: List = emptyList(), + val averageRanking: List = emptyList(), +) + @Serializable data class OfficialTournamentListRowDto( val id: Int = 0,