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

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