feat: Add friendly match management features including API integration and UI updates
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s

- Implemented API methods for listing, creating, updating, and deleting friendly matches.
- Enhanced the ScheduleManager to handle friendly matches, including loading and state management.
- Updated UI components to support editing and displaying friendly match results.
- Modified localization files to reflect changes in terminology for match sets.
This commit is contained in:
Torsten Schulz (local)
2026-05-18 09:39:00 +02:00
parent 5dfdcb63bc
commit f9ab3d9932
21 changed files with 279 additions and 60 deletions

View File

@@ -22,6 +22,10 @@ import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.Divider
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
@@ -543,6 +547,8 @@ internal fun TournamentEditorMatchesTab(
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
var confirmDelete by remember { mutableStateOf<Pair<Int, Int>?>(null) }
var editingSet by remember { mutableStateOf<Pair<Int, Int>?>(null) }
var editInput by remember { mutableStateOf("") }
val hasKO = matches.any { (it.round ?: "").lowercase() != "group" && (it.round ?: "").isNotBlank() }
var showDistributedDialog by remember { mutableStateOf(false) }
var distributedMatches by remember { mutableStateOf<List<TournamentMatchDto>>(emptyList()) }
@@ -720,7 +726,7 @@ internal fun TournamentEditorMatchesTab(
resultInput = it
resultError = null
},
placeholder = { Text(tr("tournaments.newSetPlaceholder", "11:7")) },
placeholder = { Text(tr("tournaments.setShort", "Satz")) },
singleLine = true,
isError = resultError != null,
keyboardOptions = KeyboardOptions(
@@ -746,10 +752,29 @@ internal fun TournamentEditorMatchesTab(
val results = m.tournamentResults
if (!results.isNullOrEmpty()) {
Text(formatMatchSets(m))
results.sortedBy { it.set }.forEach { r ->
results.sortedBy { it.set }.forEach { r ->
val a = r.pointsPlayer1?.let { kotlin.math.abs(it).toString() } ?: "-"
val b = r.pointsPlayer2?.let { kotlin.math.abs(it).toString() } ?: "-"
Text("${r.set}: $a:$b", style = MaterialTheme.typography.caption)
Row(verticalAlignment = Alignment.CenterVertically) {
Text("${r.set}: $a:$b", style = MaterialTheme.typography.caption)
if (m.isFinished != true) {
Spacer(modifier = Modifier.width(6.dp))
IconButton(onClick = { confirmDelete = Pair(m.id, r.set) }, modifier = Modifier.size(28.dp)) {
Icon(
Icons.Filled.Close,
contentDescription = "Löschen",
tint = MaterialTheme.colors.error,
modifier = Modifier.size(18.dp),
)
}
IconButton(onClick = {
editingSet = Pair(m.id, r.set)
editInput = "$a:$b"
}, modifier = Modifier.size(28.dp)) {
Icon(Icons.Filled.Edit, contentDescription = "Bearbeiten", modifier = Modifier.size(18.dp))
}
}
}
}
} else {
Text(m.tournamentResults?.size?.toString() ?: "0")
@@ -785,7 +810,7 @@ internal fun TournamentEditorMatchesTab(
Column(modifier = Modifier.width(120.dp)) {
if (m.isFinished != true) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
TextButton(onClick = {
IconButton(onClick = {
// finish
scope.launch {
runCatching {
@@ -795,18 +820,31 @@ internal fun TournamentEditorMatchesTab(
}.onFailure { onError(it.message) }
onReload()
}
}) { Text(tr("tournaments.finishMatchShort", "Fertig")) }
// start/stop match
TextButton(onClick = {
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.setMatchActive(clubId, tournamentId, m.id, TournamentMatchActiveBody(isActive = true))
}
}.onFailure { onError(it.message) }
onReload()
}
}) { Text(tr("tournaments.startMatch", "Starten")) }
}) { Icon(Icons.Filled.Check, contentDescription = tr("tournaments.finishMatchShort", "Fertig")) }
if (m.isActive == true) {
IconButton(onClick = {
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.setMatchActive(clubId, tournamentId, m.id, TournamentMatchActiveBody(isActive = false))
}
}.onFailure { onError(it.message) }
onReload()
}
}) { Icon(Icons.Filled.Stop, contentDescription = tr("tournaments.stopMatch", "Stoppen")) }
} else {
IconButton(onClick = {
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.setMatchActive(clubId, tournamentId, m.id, TournamentMatchActiveBody(isActive = true))
}
}.onFailure { onError(it.message) }
onReload()
}
}) { Icon(Icons.Filled.PlayArrow, contentDescription = tr("tournaments.startMatch", "Starten")) }
}
}
} else {
TextButton(onClick = {
@@ -820,19 +858,7 @@ internal fun TournamentEditorMatchesTab(
}
}) { Text(tr("tournaments.correct", "Korrigieren")) }
}
// Stop button shown regardless (to allow stopping active matches)
if (m.isActive == true) {
TextButton(onClick = {
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.setMatchActive(clubId, tournamentId, m.id, TournamentMatchActiveBody(isActive = false))
}
}.onFailure { onError(it.message) }
onReload()
}
}) { Text(tr("tournaments.stopMatch", "Stoppen")) }
}
// no duplicate stop button
}
}
Divider()
@@ -868,6 +894,73 @@ internal fun TournamentEditorMatchesTab(
}
)
}
// Edit set dialog
if (editingSet != null) {
val (matchIdToEdit, setToEdit) = editingSet!!
AlertDialog(
onDismissRequest = { editingSet = null },
title = { Text(tr("tournaments.editSetTitle", "Satz bearbeiten")) },
text = {
Column {
OutlinedTextField(
value = editInput,
onValueChange = { editInput = it },
placeholder = { Text("11:7") }
)
Text(tr("tournaments.editSetHint", "Gib ein neues Ergebnis ein, z.B. 11:7"), style = MaterialTheme.typography.caption)
}
},
confirmButton = {
TextButton(onClick = {
val normalized = normalizeResult(editInput)
if (normalized == null) {
onError(tr("tournaments.invalidResultInput", "Ungültiges Ergebnis"))
return@TextButton
}
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
// Find current match results snapshot
val matchObj = matches.firstOrNull { it.id == matchIdToEdit }
val results = matchObj?.tournamentResults?.sortedBy { it.set } ?: emptyList()
// Tail: sets after the edited one
val tail = results.filter { it.set > setToEdit }.map { Pair(it.pointsPlayer1, it.pointsPlayer2) }
// Delete tail from highest to lowest
for (i in results.size downTo setToEdit + 1) {
api.deleteMatchResult(de.tsschulz.tt_tagebuch.shared.api.models.TournamentDeleteMatchResultBody(
clubId, tournamentId, matchIdToEdit, i
))
}
// Delete the set to edit
api.deleteMatchResult(de.tsschulz.tt_tagebuch.shared.api.models.TournamentDeleteMatchResultBody(
clubId, tournamentId, matchIdToEdit, setToEdit
))
// Add the edited set
api.addMatchResult(de.tsschulz.tt_tagebuch.shared.api.models.TournamentAddMatchResultBody(
clubId, tournamentId, matchIdToEdit, setToEdit, normalized
))
// Re-add tail sets in order
var cur = setToEdit + 1
for (t in tail) {
val r = "${kotlin.math.abs(t.first ?: 0)}:${kotlin.math.abs(t.second ?: 0)}"
api.addMatchResult(de.tsschulz.tt_tagebuch.shared.api.models.TournamentAddMatchResultBody(
clubId, tournamentId, matchIdToEdit, cur, r
))
cur++
}
}
}.onFailure { onError(it.message) }
editingSet = null
editInput = ""
onReload()
}
}) { Text(tr("common.save", "Speichern")) }
},
dismissButton = {
TextButton(onClick = { editingSet = null }) { Text(tr("common.cancel", "Abbrechen")) }
}
)
}
if (showDistributedDialog) {
AlertDialog(
onDismissRequest = { showDistributedDialog = false },
@@ -948,7 +1041,7 @@ private fun MatchResultRow(
value = resultInput,
onValueChange = { resultInput = it },
modifier = Modifier.weight(1f),
placeholder = { Text(tr("tournaments.newSetPlaceholder", "Neuen Satz, z.B. 11:7")) },
placeholder = { Text(tr("tournaments.setShort", "Satz")) },
singleLine = true,
)
var tableExpanded by remember { mutableStateOf(false) }

View File

@@ -29,6 +29,7 @@ import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
@@ -51,6 +52,9 @@ import de.tsschulz.tt_tagebuch.app.stats.TrainingStatsDerived
import de.tsschulz.tt_tagebuch.shared.api.models.ScheduleMatchDto
import de.tsschulz.tt_tagebuch.shared.api.models.ScheduleMatchScope
import de.tsschulz.tt_tagebuch.shared.api.models.ScheduleViewMode
import de.tsschulz.tt_tagebuch.shared.api.models.FriendlyMatchSaveBody
import de.tsschulz.tt_tagebuch.shared.api.models.FriendlyParticipantDto
import de.tsschulz.tt_tagebuch.shared.api.models.FriendlyResultRowDto
import de.tsschulz.tt_tagebuch.shared.api.models.canReadSchedule
import de.tsschulz.tt_tagebuch.shared.api.models.canWriteSchedule
import de.tsschulz.tt_tagebuch.shared.i18n.MobileStrings