feat(Localization): enhance localization for tournament statistics and UI components
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
- 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.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -941,7 +941,14 @@
|
||||
"participationsTotal": "参加回数(合計)",
|
||||
"lastTraining": "最終練習",
|
||||
"actions": "操作",
|
||||
"showDetails": "詳細を表示"
|
||||
"showDetails": "詳細を表示",
|
||||
"panelSummary": "主要指標(フィルター)",
|
||||
"panelMonthlyTrend": "月次の推移",
|
||||
"panelWeekdayStats": "曜日別の練習日",
|
||||
"panelMemberStructure": "メンバー構成",
|
||||
"panelBestDay": "最も参加が多い練習日",
|
||||
"panelGroupPerformance": "グループ別の推移",
|
||||
"panelAgeGroups": "年齢クラス別の出席"
|
||||
},
|
||||
"courtDrawingTool": {
|
||||
"title": "卓球練習図",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -941,7 +941,14 @@
|
||||
"participationsTotal": "การเข้าร่วม (ทั้งหมด)",
|
||||
"lastTraining": "การฝึกซ้อมล่าสุด",
|
||||
"actions": "การดำเนินการ",
|
||||
"showDetails": "แสดงรายละเอียด"
|
||||
"showDetails": "แสดงรายละเอียด",
|
||||
"panelSummary": "ตัวเลขสำคัญ (กรอง)",
|
||||
"panelMonthlyTrend": "แนวโน้มรายเดือน",
|
||||
"panelWeekdayStats": "วันซ้อมตามวันในสัปดาห์",
|
||||
"panelMemberStructure": "โครงสร้างสมาชิก",
|
||||
"panelBestDay": "วันซ้อมที่คึกคักที่สุด",
|
||||
"panelGroupPerformance": "พัฒนาการตามกลุ่ม",
|
||||
"panelAgeGroups": "การเข้าร่วมตามกลุ่มอายุ"
|
||||
},
|
||||
"courtDrawingTool": {
|
||||
"title": "ภาพวาดแบบฝึกปิงปอง",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -941,7 +941,14 @@
|
||||
"participationsTotal": "参与次数(总计)",
|
||||
"lastTraining": "最近训练",
|
||||
"actions": "操作",
|
||||
"showDetails": "显示详情"
|
||||
"showDetails": "显示详情",
|
||||
"panelSummary": "关键数据(筛选)",
|
||||
"panelMonthlyTrend": "月度趋势",
|
||||
"panelWeekdayStats": "按星期几的训练日",
|
||||
"panelMemberStructure": "成员结构",
|
||||
"panelBestDay": "参与人数最多的训练日",
|
||||
"panelGroupPerformance": "各组进展",
|
||||
"panelAgeGroups": "按年龄组的出勤"
|
||||
},
|
||||
"courtDrawingTool": {
|
||||
"title": "乒乓球练习示意图",
|
||||
|
||||
@@ -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<InternalTournamentStatsAgeOption>): Set<String> {
|
||||
val seen = mutableSetOf<String>()
|
||||
val out = linkedSetOf<String>()
|
||||
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<InternalTournamentStatsAgeOption>,
|
||||
selectedBandKeys: Set<String>,
|
||||
genderScope: String,
|
||||
): List<String> {
|
||||
val valid = options.map { it.key }.toSet()
|
||||
val genderModes = if (genderScope == "female") listOf("female") else listOf("female", "open")
|
||||
val out = mutableListOf<String>()
|
||||
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<InternalTournamentStatsAgeOption>,
|
||||
selectedBandKeys: Set<String>,
|
||||
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<InternalTournamentStatsAgeOption>): List<Pair<String, InternalTournamentStatsAgeOption>> {
|
||||
val byKey = linkedMapOf<String, InternalTournamentStatsAgeOption>()
|
||||
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<Set<String>>(emptySet()) }
|
||||
var bandsInitialized by remember { mutableStateOf(false) }
|
||||
|
||||
var stats by remember { mutableStateOf<InternalTournamentStatsDto?>(null) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var error by remember { mutableStateOf<String?>(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"))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<InternalTournamentStatsAgeOption> = emptyList(),
|
||||
val absoluteRanking: List<InternalTournamentStatsRow> = emptyList(),
|
||||
val averageRanking: List<InternalTournamentStatsRow> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class OfficialTournamentListRowDto(
|
||||
val id: Int = 0,
|
||||
|
||||
Reference in New Issue
Block a user