feat(Localization): enhance localization for tournament statistics and UI components
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:
Torsten Schulz (local)
2026-05-14 16:25:16 +02:00
parent 6ef1d79a5f
commit 3d1dfe9a4c
17 changed files with 660 additions and 55 deletions

View File

@@ -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",

View File

@@ -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"

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -941,7 +941,14 @@
"participationsTotal": "参加回数(合計)",
"lastTraining": "最終練習",
"actions": "操作",
"showDetails": "詳細を表示"
"showDetails": "詳細を表示",
"panelSummary": "主要指標(フィルター)",
"panelMonthlyTrend": "月次の推移",
"panelWeekdayStats": "曜日別の練習日",
"panelMemberStructure": "メンバー構成",
"panelBestDay": "最も参加が多い練習日",
"panelGroupPerformance": "グループ別の推移",
"panelAgeGroups": "年齢クラス別の出席"
},
"courtDrawingTool": {
"title": "卓球練習図",

View File

@@ -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",

View File

@@ -941,7 +941,14 @@
"participationsTotal": "การเข้าร่วม (ทั้งหมด)",
"lastTraining": "การฝึกซ้อมล่าสุด",
"actions": "การดำเนินการ",
"showDetails": "แสดงรายละเอียด"
"showDetails": "แสดงรายละเอียด",
"panelSummary": "ตัวเลขสำคัญ (กรอง)",
"panelMonthlyTrend": "แนวโน้มรายเดือน",
"panelWeekdayStats": "วันซ้อมตามวันในสัปดาห์",
"panelMemberStructure": "โครงสร้างสมาชิก",
"panelBestDay": "วันซ้อมที่คึกคักที่สุด",
"panelGroupPerformance": "พัฒนาการตามกลุ่ม",
"panelAgeGroups": "การเข้าร่วมตามกลุ่มอายุ"
},
"courtDrawingTool": {
"title": "ภาพวาดแบบฝึกปิงปอง",

View File

@@ -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",

View File

@@ -941,7 +941,14 @@
"participationsTotal": "参与次数(总计)",
"lastTraining": "最近训练",
"actions": "操作",
"showDetails": "显示详情"
"showDetails": "显示详情",
"panelSummary": "关键数据(筛选)",
"panelMonthlyTrend": "月度趋势",
"panelWeekdayStats": "按星期几的训练日",
"panelMemberStructure": "成员结构",
"panelBestDay": "参与人数最多的训练日",
"panelGroupPerformance": "各组进展",
"panelAgeGroups": "按年龄组的出勤"
},
"courtDrawingTool": {
"title": "乒乓球练习示意图",

View File

@@ -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"))
}
},
)
}

View File

@@ -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)) {

View File

@@ -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()
}
}

View File

@@ -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,