feat: separate backend base URL configurations for release and debug builds
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 46s

This commit is contained in:
Torsten Schulz (local)
2026-05-17 22:46:07 +02:00
parent f8f1c797e7
commit 6c7ae6860b
20 changed files with 777 additions and 285 deletions

View File

@@ -1,9 +1,13 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
val backendBaseUrl = providers.gradleProperty("backendBaseUrl")
val backendBaseUrlForRelease = providers.gradleProperty("backendBaseUrl")
.orElse("https://tt-tagebuch.de")
.get()
val backendBaseUrlForDebug = providers.gradleProperty("backendBaseUrl")
.orElse("http://10.0.2.2:3005")
.get()
val socketBaseUrl = providers.gradleProperty("socketBaseUrl")
.orElse("wss://tt-tagebuch.de:3051")
.get()
@@ -60,7 +64,7 @@ android {
targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = libs.versions.appVersionCode.get().toInt()
versionName = libs.versions.appVersionName.get()
buildConfigField("String", "BACKEND_BASE_URL", "\"$backendBaseUrl\"")
// BUILD CONFIGS are provided per-buildType below. SOCKET_BASE_URL remains in defaultConfig
buildConfigField("String", "SOCKET_BASE_URL", "\"$socketBaseUrl\"")
}
buildFeatures {
@@ -78,6 +82,12 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
// Release: default to production, can be overridden with -PbackendBaseUrl
buildConfigField("String", "BACKEND_BASE_URL", "\"$backendBaseUrlForRelease\"")
}
getByName("debug") {
// Debug: default to emulator localhost via 10.0.2.2, can be overridden with -PbackendBaseUrl
buildConfigField("String", "BACKEND_BASE_URL", "\"$backendBaseUrlForDebug\"")
}
}
compileOptions {

View File

@@ -6,12 +6,22 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.AlertDialog
import androidx.compose.material.Button
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.Divider
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
@@ -29,6 +39,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import de.tsschulz.tt_tagebuch.app.AppDependencies
@@ -41,6 +52,13 @@ import de.tsschulz.tt_tagebuch.shared.api.models.TournamentClassDto
import de.tsschulz.tt_tagebuch.shared.api.models.TournamentDeleteKnockoutBody
import de.tsschulz.tt_tagebuch.shared.api.models.TournamentExternalParticipantRowDto
import de.tsschulz.tt_tagebuch.shared.api.models.TournamentFinishMatchBody
import de.tsschulz.tt_tagebuch.shared.api.models.TournamentMatchTableBody
import de.tsschulz.tt_tagebuch.shared.api.models.TournamentReopenMatchBody
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.focus.FocusState
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import de.tsschulz.tt_tagebuch.shared.api.models.TournamentStartKnockoutBody
import de.tsschulz.tt_tagebuch.shared.api.models.TournamentMatchDto
import de.tsschulz.tt_tagebuch.shared.api.models.TournamentParticipantRowDto
import de.tsschulz.tt_tagebuch.shared.api.models.TournamentRemoveInternalParticipantBody
@@ -49,6 +67,7 @@ import de.tsschulz.tt_tagebuch.shared.api.models.UpdateTournamentClassBody
import de.tsschulz.tt_tagebuch.shared.api.models.AddExternalTournamentParticipantBody
import de.tsschulz.tt_tagebuch.shared.api.models.RemoveExternalTournamentParticipantBody
import kotlinx.coroutines.CoroutineScope
import de.tsschulz.tt_tagebuch.shared.api.models.InternalTournamentDetailDto
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -115,12 +134,12 @@ internal fun TournamentEditorClassesTab(
},
)
}
Column(Modifier.fillMaxSize().padding(16.dp)) {
Column(Modifier.fillMaxWidth()) {
Button(onClick = { showAdd = true }, modifier = Modifier.fillMaxWidth()) {
Text(tr("tournaments.addClass", "Klasse anlegen"))
}
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 12.dp)) {
items(classes, key = { it.id }) { c ->
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 12.dp)) {
classes.forEach { c ->
ClassRowEditor(
clubId = clubId,
tournamentId = tournamentId,
@@ -204,7 +223,7 @@ internal fun TournamentEditorParticipantsTab(
val api = dependencies.tournamentsApi
val scope = dependencies.applicationScope
val membersState by dependencies.membersManager.state.collectAsState()
val scroll = rememberScrollState()
var addMenu by remember { mutableStateOf(false) }
var extDialog by remember { mutableStateOf(false) }
var extFirst by remember { mutableStateOf("") }
@@ -214,157 +233,168 @@ internal fun TournamentEditorParticipantsTab(
dependencies.membersManager.loadMembers(clubId)
}
var showInternal by remember { mutableStateOf(true) }
var showExternal by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scroll)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(tr("tournaments.internalParticipants", "Vereinsmitglieder"), fontWeight = FontWeight.SemiBold)
Row {
OutlinedButton(onClick = { addMenu = true }) { Text(tr("tournaments.addParticipant", "Teilnehmer hinzufügen")) }
DropdownMenu(expanded = addMenu, onDismissRequest = { addMenu = false }) {
val existing = participants.mapNotNull { it.clubMemberId }.toSet()
membersState.members
.filter { it.id !in existing }
.take(200)
.forEach { m ->
DropdownMenuItem(
onClick = {
addMenu = false
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(tr("tournaments.internalParticipants", "Vereinsmitglieder"), style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.SemiBold)
TextButton(onClick = { showInternal = !showInternal }) { Text(if (showInternal) tr("mobile.hide", "Verbergen") else tr("mobile.show", "Anzeigen")) }
}
if (showInternal) {
Row {
OutlinedButton(onClick = { addMenu = true }) { Text(tr("tournaments.addParticipant", "Teilnehmer hinzufügen")) }
DropdownMenu(expanded = addMenu, onDismissRequest = { addMenu = false }) {
val existing = participants.mapNotNull { it.clubMemberId }.toSet()
membersState.members
.filter { it.id !in existing }
.take(200)
.forEach { m ->
DropdownMenuItem(
onClick = {
addMenu = false
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.addInternalParticipant(
TournamentAddInternalParticipantBody(
clubId = clubId,
classId = null,
participant = m.id,
tournamentId = tournamentId,
),
)
}
}.onFailure { onError(it.message) }
onReload()
}
},
) {
Text("${m.firstName} ${m.lastName}".trim())
}
}
}
}
participants.forEach { p ->
val label = p.member?.let { "${it.firstName.orEmpty()} ${it.lastName.orEmpty()}".trim() }.orEmpty().ifBlank { "#${p.id}" }
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(Modifier.weight(1f)) {
Text(label, fontWeight = FontWeight.Medium)
ClassPickerChipRow(
classes = classes,
selectedClassId = p.classId,
tr = tr,
onPick = { classId ->
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.addInternalParticipant(
TournamentAddInternalParticipantBody(
clubId = clubId,
classId = null,
participant = m.id,
tournamentId = tournamentId,
),
api.updateParticipantClass(
clubId,
tournamentId,
p.id,
UpdateParticipantClassBody(classId = classId, isExternal = false),
)
}
}.onFailure { onError(it.message) }
onReload()
}
},
) {
Text("${m.firstName} ${m.lastName}".trim())
}
)
}
}
}
participants.forEach { p ->
val label = p.member?.let { "${it.firstName.orEmpty()} ${it.lastName.orEmpty()}".trim() }.orEmpty().ifBlank { "#${p.id}" }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(Modifier.weight(1f)) {
Text(label, fontWeight = FontWeight.Medium)
ClassPickerChipRow(
classes = classes,
selectedClassId = p.classId,
tr = tr,
onPick = { classId ->
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.updateParticipantClass(
clubId,
tournamentId,
p.id,
UpdateParticipantClassBody(classId = classId, isExternal = false),
)
}
}.onFailure { onError(it.message) }
onReload()
}
},
)
}
TextButton(
onClick = {
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.removeInternalParticipant(
TournamentRemoveInternalParticipantBody(clubId, tournamentId, p.id),
)
}
}.onFailure { onError(it.message) }
onReload()
}
},
) { Text(tr("mobile.remove", "Entfernen")) }
}
Divider()
}
Text(tr("tournaments.externalParticipants", "Externe Teilnehmer"), fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = 12.dp))
OutlinedButton(onClick = { extDialog = true }) { Text(tr("tournaments.addExternalParticipant", "Extern hinzufügen")) }
if (extDialog) {
AlertDialog(
onDismissRequest = { extDialog = false },
title = { Text(tr("tournaments.addExternalParticipant", "Extern hinzufügen")) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(extFirst, { extFirst = it }, label = { Text(tr("members.firstName", "Vorname")) })
OutlinedTextField(extLast, { extLast = it }, label = { Text(tr("members.lastName", "Nachname")) })
}
},
confirmButton = {
TextButton(
onClick = {
if (extFirst.isBlank() || extLast.isBlank()) return@TextButton
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.addExternalParticipant(
AddExternalTournamentParticipantBody(
clubId = clubId,
tournamentId = tournamentId,
classId = null,
firstName = extFirst.trim(),
lastName = extLast.trim(),
),
api.removeInternalParticipant(
TournamentRemoveInternalParticipantBody(clubId, tournamentId, p.id),
)
}
}.onFailure { onError(it.message) }
extDialog = false
extFirst = ""
extLast = ""
onReload()
}
},
) { Text(tr("mobile.add", "Hinzufügen")) }
},
dismissButton = { TextButton(onClick = { extDialog = false }) { Text(tr("common.cancel", "Abbrechen")) } },
)
) { Text(tr("mobile.remove", "Entfernen")) }
}
Divider()
}
}
externalParticipants.forEach { e ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text("${e.firstName.orEmpty()} ${e.lastName.orEmpty()} (${tr("tournaments.externalShort", "ext.")})".trim())
TextButton(
onClick = {
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.removeExternalParticipant(
RemoveExternalTournamentParticipantBody(clubId, tournamentId, e.id),
)
}
}.onFailure { onError(it.message) }
onReload()
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
Text(tr("tournaments.externalParticipants", "Externe Teilnehmer"), style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.SemiBold)
TextButton(onClick = { showExternal = !showExternal }) { Text(if (showExternal) tr("mobile.hide", "Verbergen") else tr("mobile.show", "Anzeigen")) }
}
if (showExternal) {
OutlinedButton(onClick = { extDialog = true }) { Text(tr("tournaments.addExternalParticipant", "Extern hinzufügen")) }
if (extDialog) {
AlertDialog(
onDismissRequest = { extDialog = false },
title = { Text(tr("tournaments.addExternalParticipant", "Extern hinzufügen")) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(extFirst, { extFirst = it }, label = { Text(tr("members.firstName", "Vorname")) })
OutlinedTextField(extLast, { extLast = it }, label = { Text(tr("members.lastName", "Nachname")) })
}
},
) { Text(tr("mobile.remove", "Entfernen")) }
confirmButton = {
TextButton(
onClick = {
if (extFirst.isBlank() || extLast.isBlank()) return@TextButton
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.addExternalParticipant(
AddExternalTournamentParticipantBody(
clubId = clubId,
tournamentId = tournamentId,
classId = null,
firstName = extFirst.trim(),
lastName = extLast.trim(),
),
)
}
}.onFailure { onError(it.message) }
extDialog = false
extFirst = ""
extLast = ""
onReload()
}
},
) { Text(tr("mobile.add", "Hinzufügen")) }
},
dismissButton = { TextButton(onClick = { extDialog = false }) { Text(tr("common.cancel", "Abbrechen")) } },
)
}
externalParticipants.forEach { e ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text("${e.firstName.orEmpty()} ${e.lastName.orEmpty()} (${tr("tournaments.externalShort", "ext.")})".trim())
TextButton(
onClick = {
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.removeExternalParticipant(
RemoveExternalTournamentParticipantBody(clubId, tournamentId, e.id),
)
}
}.onFailure { onError(it.message) }
onReload()
}
},
) { Text(tr("mobile.remove", "Entfernen")) }
}
Divider()
}
Divider()
}
}
}
@@ -393,20 +423,21 @@ internal fun TournamentEditorMatchesTab(
tournamentId: Int,
matches: List<TournamentMatchDto>,
winningSets: Int,
detail: InternalTournamentDetailDto,
tr: (String, String) -> String,
api: TournamentsApi,
scope: CoroutineScope,
onReload: () -> Unit,
onError: (String?) -> Unit,
) {
val scroll = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scroll)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
var confirmDelete by remember { mutableStateOf<Pair<Int, Int>?>(null) }
val hasKO = matches.any { (it.round ?: "").lowercase() != "group" && (it.round ?: "").isNotBlank() }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(
onClick = {
@@ -420,24 +451,214 @@ internal fun TournamentEditorMatchesTab(
}
},
) { Text(tr("tournaments.resetKnockoutShort", "KO löschen")) }
}
val grouped = matches.groupBy { it.round }
grouped.entries.sortedBy { it.key }.forEach { (round, list) ->
Text("$round (${list.size})", fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.subtitle1)
list.forEach { m ->
MatchResultRow(
clubId = clubId,
tournamentId = tournamentId,
m = m,
winningSets = winningSets,
tr = tr,
api = api,
scope = scope,
onReload = onReload,
onError = onError,
)
if (!hasKO) {
TextButton(
onClick = {
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.startKnockout(TournamentStartKnockoutBody(clubId, tournamentId))
}
}.onFailure { onError(it.message) }
onReload()
}
},
) { Text(tr("tournaments.startKnockout", "K.-o. starten")) }
}
}
// Table-like view: show header + flattened rows so Android resembles web UI
val displayList = matches.sortedWith(compareBy<TournamentMatchDto> {
it.isFinished == true
}.thenBy {
// primary: if groupRound available use it, else try parse round as int, else large
it.groupRound ?: it.round.toIntOrNull() ?: Int.MAX_VALUE
}.thenBy { it.groupId ?: Int.MAX_VALUE }.thenBy { it.groupRound ?: 0 })
// header
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Runde", fontWeight = FontWeight.SemiBold, modifier = Modifier.width(56.dp))
Text(tr("tournaments.group", "Gruppe"), fontWeight = FontWeight.SemiBold, modifier = Modifier.width(96.dp))
Text(tr("tournaments.match", "Begegnung"), fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Text(tr("tournaments.result", "Ergebnis"), fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(0.7f))
Text("Sätze", fontWeight = FontWeight.SemiBold, modifier = Modifier.width(72.dp))
Text("Aktion", fontWeight = FontWeight.SemiBold, modifier = Modifier.width(96.dp))
}
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
displayList.forEach { m ->
var resultInput by remember(m.id, m.tournamentResults) { mutableStateOf("") }
var resultError by remember(m.id, m.tournamentResults) { mutableStateOf<String?>(null) }
var wasResultFocused by remember(m.id) { mutableStateOf(false) }
var isSavingResult by remember(m.id) { mutableStateOf(false) }
fun saveResultInput() {
if (isSavingResult) return
if (resultInput.isBlank()) {
resultError = null
return
}
val normalized = normalizeResult(resultInput)
if (normalized == null) {
resultError = tr("tournaments.invalidResultInput", "Ungültiges Ergebnis")
onError(tr("tournaments.invalidResultInputHint", "Bitte ein gültiges Ergebnis eingeben, z.B. 11:7, 7 oder -7."))
return
}
val nextSet = (m.tournamentResults?.maxOfOrNull { it.set } ?: 0) + 1
isSavingResult = true
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.addMatchResult(
TournamentAddMatchResultBody(
clubId = clubId,
tournamentId = tournamentId,
matchId = m.id,
set = nextSet,
result = normalized,
),
)
}
}.fold(
onSuccess = {
resultInput = ""
resultError = null
isSavingResult = false
onReload()
},
onFailure = {
resultError = it.message ?: tr("messages.error", "Fehler")
isSavingResult = false
onError(resultError)
},
)
}
}
val roundLabel = m.groupRound?.toString() ?: m.round.takeIf { it.isNotBlank() } ?: "-"
val groupLabel = m.groupId?.let { "${tr("tournaments.group", "Gruppe")} $it" } ?: "-"
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(roundLabel, modifier = Modifier.width(56.dp))
Text(groupLabel, modifier = Modifier.width(96.dp))
Column(modifier = Modifier.weight(1f)) {
Text("${displayNameFromPlayerJson(m.player1)} ${displayNameFromPlayerJson(m.player2)}", style = MaterialTheme.typography.body2)
val results = m.tournamentResults
if (!results.isNullOrEmpty()) {
results.sortedBy { it.set }.forEach { r ->
Row(verticalAlignment = Alignment.CenterVertically) {
val a = r.pointsPlayer1?.toString() ?: "-"
val b = r.pointsPlayer2?.toString() ?: "-"
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),
)
}
}
}
}
}
}
Column(modifier = Modifier.weight(0.7f)) {
if (m.isFinished == true) {
Text(formatMatchSets(m))
} else if (m.result == "BYE") {
Text("BYE")
} else {
OutlinedTextField(
value = resultInput,
onValueChange = {
resultInput = it
resultError = null
},
placeholder = { Text(tr("tournaments.newSetPlaceholder", "11:7")) },
singleLine = true,
isError = resultError != null,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { saveResultInput() }),
modifier = Modifier
.width(86.dp)
.onFocusChanged { focusState ->
if (wasResultFocused && !focusState.isFocused) {
saveResultInput()
}
wasResultFocused = focusState.isFocused
},
)
resultError?.let {
Text(it, color = MaterialTheme.colors.error, style = MaterialTheme.typography.caption)
}
}
}
Text(m.tournamentResults?.size?.toString() ?: "0", modifier = Modifier.width(72.dp))
// actions column
Column(modifier = Modifier.width(96.dp)) {
if (m.isFinished != true) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
TextButton(onClick = {
// finish
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.finishMatch(TournamentFinishMatchBody(clubId, tournamentId, m.id))
}
}.onFailure { onError(it.message) }
onReload()
}
}) { Text(tr("tournaments.finishMatchShort", "Fertig")) }
}
} else {
TextButton(onClick = {
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.reopenMatch(TournamentReopenMatchBody(clubId, tournamentId, m.id))
}
}.onFailure { onError(it.message) }
onReload()
}
}) { Text(tr("tournaments.correct", "Korrigieren")) }
}
}
}
Divider()
}
}
// Confirmation dialog for deleting a set
if (confirmDelete != null) {
val (matchIdToDelete, setToDelete) = confirmDelete!!
AlertDialog(
onDismissRequest = { confirmDelete = null },
title = { Text(tr("tournaments.confirmDeleteSetTitle", "Satz löschen")) },
text = { Text(tr("tournaments.confirmDeleteSet", "Soll dieser Satz wirklich gelöscht werden?")) },
confirmButton = {
TextButton(onClick = {
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.deleteMatchResult(de.tsschulz.tt_tagebuch.shared.api.models.TournamentDeleteMatchResultBody(
clubId = clubId,
tournamentId = tournamentId,
matchId = matchIdToDelete,
set = setToDelete,
))
}
}.onFailure { onError(it.message) }
confirmDelete = null
onReload()
}
}) { Text(tr("common.delete", "Löschen")) }
},
dismissButton = {
TextButton(onClick = { confirmDelete = null }) { Text(tr("common.cancel", "Abbrechen")) }
}
)
}
}
}
@@ -447,6 +668,7 @@ private fun MatchResultRow(
tournamentId: Int,
m: TournamentMatchDto,
@Suppress("UNUSED_PARAMETER") winningSets: Int,
maxTables: Int?,
tr: (String, String) -> String,
api: TournamentsApi,
scope: CoroutineScope,
@@ -458,19 +680,50 @@ private fun MatchResultRow(
val p2 = displayNameFromPlayerJson(m.player2)
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Text("$p1 vs $p2", style = MaterialTheme.typography.body2)
val setsText = m.tournamentResults?.joinToString(", ") { r ->
val a = r.pointsPlayer1?.toString() ?: "-"
val b = r.pointsPlayer2?.toString() ?: "-"
"$a:$b"
} ?: ""
if (setsText.isNotBlank()) {
Text(setsText, style = MaterialTheme.typography.body2)
}
Text(
"${tr("tournaments.result", "Ergebnis")}: ${m.result ?: "—"} ${if (m.isFinished == true) "✓" else ""}",
style = MaterialTheme.typography.caption,
)
if (m.isFinished != true) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 4.dp)) {
Row(modifier = Modifier.padding(top = 4.dp).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = resultInput,
onValueChange = { resultInput = it },
modifier = Modifier.weight(1f),
placeholder = { Text("11:9") },
placeholder = { Text(tr("tournaments.newSetPlaceholder", "Neuen Satz, z.B. 11:7")) },
singleLine = true,
)
var tableExpanded by remember { mutableStateOf(false) }
val max = maxTables ?: 12
Box {
OutlinedButton(onClick = { tableExpanded = true }, modifier = Modifier.width(84.dp)) {
Text(m.tableNumber?.toString() ?: "-")
}
DropdownMenu(expanded = tableExpanded, onDismissRequest = { tableExpanded = false }) {
for (i in 1..max) {
DropdownMenuItem(onClick = {
tableExpanded = false
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.setMatchTable(clubId, tournamentId, m.id, TournamentMatchTableBody(i))
}
}.onFailure { onError(it.message) }
onReload()
}
}) { Text(i.toString()) }
}
}
}
Spacer(Modifier.weight(0.1f))
TextButton(
onClick = {
val r = normalizeResult(resultInput) ?: return@TextButton
@@ -509,6 +762,22 @@ private fun MatchResultRow(
},
) { Text(tr("tournaments.finishMatchShort", "Fertig")) }
}
} else {
// finished -> allow reopening for correction
Row(modifier = Modifier.padding(top = 6.dp)) {
Text(tr("tournaments.table", "Tisch") + ": ${m.tableNumber ?: "-"}")
Spacer(Modifier.weight(1f))
TextButton(onClick = {
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.reopenMatch(TournamentReopenMatchBody(clubId, tournamentId, m.id))
}
}.onFailure { onError(it.message) }
onReload()
}
}) { Text(tr("tournaments.correct", "Korrigieren")) }
}
}
Divider()
}
@@ -516,13 +785,36 @@ private fun MatchResultRow(
private fun normalizeResult(s: String): String? {
val t = s.trim()
if (!t.contains(':')) return null
val parts = t.split(':')
if (parts.size != 2) return null
val a = parts[0].trim().toIntOrNull() ?: return null
val b = parts[1].trim().toIntOrNull() ?: return null
if (a < 0 || b < 0) return null
return "$a:$b"
if (t.contains(':')) {
val parts = t.split(':')
if (parts.size != 2) return null
val a = parts[0].trim().toIntOrNull() ?: return null
val b = parts[1].trim().toIntOrNull() ?: return null
if (a < 0 || b < 0) return null
if ((a < 11 && b < 11) || kotlin.math.abs(a - b) < 2) return null
return "$a:$b"
}
val value = t.toIntOrNull() ?: return null
val losing = kotlin.math.abs(value)
val winning = if (losing < 10) 11 else losing + 2
return if (t.startsWith("-")) {
"$losing:$winning"
} else {
"$winning:$losing"
}
}
private fun formatMatchSets(match: TournamentMatchDto): String {
val results = match.tournamentResults.orEmpty()
if (results.isEmpty()) return match.result ?: ""
return results
.sortedBy { it.set }
.joinToString(", ") { result ->
val p1 = result.pointsPlayer1?.let { kotlin.math.abs(it).toString() } ?: "-"
val p2 = result.pointsPlayer2?.let { kotlin.math.abs(it).toString() } ?: "-"
"$p1:$p2"
}
}
private fun displayNameFromPlayerJson(el: JsonElement?): String {
@@ -572,7 +864,7 @@ internal fun TournamentEditorPairingsTab(
}.getOrElse { null }
}
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
Column(Modifier.fillMaxWidth()) {
if (doublesClasses.isEmpty()) {
Text(tr("tournaments.noDoublesClasses", "Keine Doppel-Klassen."))
return@Column

View File

@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.AlertDialog
@@ -56,6 +57,10 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.contentOrNull
import android.util.Log
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.focus.FocusState
@@ -89,11 +94,12 @@ internal fun InternalTournamentEditorScreen(
val showPairingsTab = detail?.isDoublesTournament == true
val tabTitles = remember(showPairingsTab, languageCode) {
buildList {
add(tr("mobile.tournamentEditorTabMeta", "Stammdaten"))
add(tr("mobile.tournamentEditorTabFlow", "Ablauf & Gruppen"))
add(tr("mobile.tournamentEditorTabClasses", "Klassen"))
add(tr("mobile.tournamentEditorTabParticipants", "Teilnehmer"))
add(tr("mobile.tournamentEditorTabMatches", "Spiele"))
// Match web ordering: Config, Participants, Groups, Results, Placements, (Pairings)
add(tr("tournaments.tabConfig", "Konfiguration"))
add(tr("tournaments.tabParticipants", "Teilnehmer"))
add(tr("tournaments.tabGroups", "Gruppen"))
add(tr("tournaments.tabResults", "Ergebnisse"))
add(tr("tournaments.tabPlacements", "Platzierungen"))
if (showPairingsTab) add(tr("tournaments.pairings", "Doppel-Paarungen"))
}
}
@@ -118,7 +124,12 @@ internal fun InternalTournamentEditorScreen(
}
}.fold(
onSuccess = { r ->
detail = r.detail
detail = r.detail
try {
Log.d("InternalTournamentEditor", "Loaded detail.numberOfTables=${'$'}{r.detail.numberOfTables}")
} catch (e: Exception) {
// ignore
}
classes = r.classes
participants = r.participants
externalParticipants = r.external
@@ -186,9 +197,15 @@ internal fun InternalTournamentEditorScreen(
Divider()
when (tabIndex) {
// 0: Config (Meta with integrated Classes management)
0 -> TournamentEditorMetaTab(
clubId = clubId,
tournamentId = tournamentId,
detail = d,
classes = classes,
tr = ::tr,
api = api,
scope = scope,
onSave = { body ->
scope.launch {
runCatching {
@@ -202,8 +219,25 @@ internal fun InternalTournamentEditorScreen(
reloadAll()
}
},
onReload = { reloadAll() },
onError = { error = it },
)
1 -> TournamentEditorFlowTab(
// 1: Participants (web's second tab)
1 -> Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
TournamentEditorParticipantsTab(
clubId = clubId,
tournamentId = tournamentId,
classes = classes,
participants = participants,
externalParticipants = externalParticipants,
dependencies = dependencies,
tr = ::tr,
onReload = { reloadAll() },
onError = { error = it },
)
}
// 2: Groups / Flow
2 -> TournamentEditorFlowTab(
clubId = clubId,
tournamentId = tournamentId,
detail = d,
@@ -214,51 +248,41 @@ internal fun InternalTournamentEditorScreen(
onReload = { reloadAll() },
onError = { error = it },
)
2 -> TournamentEditorClassesTab(
clubId = clubId,
tournamentId = tournamentId,
classes = classes,
tr = ::tr,
api = api,
scope = scope,
onReload = { reloadAll() },
onError = { error = it },
)
3 -> TournamentEditorParticipantsTab(
clubId = clubId,
tournamentId = tournamentId,
classes = classes,
participants = participants,
externalParticipants = externalParticipants,
dependencies = dependencies,
tr = ::tr,
onReload = { reloadAll() },
onError = { error = it },
)
4 -> TournamentEditorMatchesTab(
clubId = clubId,
tournamentId = tournamentId,
matches = matches,
winningSets = d.winningSets ?: 3,
tr = ::tr,
api = api,
scope = scope,
onReload = { reloadAll() },
onError = { error = it },
)
5 -> if (showPairingsTab) {
TournamentEditorPairingsTab(
// 3: Results / Matches
3 -> Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
TournamentEditorMatchesTab(
clubId = clubId,
tournamentId = tournamentId,
classes = classes,
participants = participants,
externalParticipants = externalParticipants,
matches = matches,
winningSets = d.winningSets ?: 3,
detail = d,
tr = ::tr,
api = api,
scope = scope,
onReload = { reloadAll() },
onError = { error = it },
)
}
// 4: Placements (placeholder for now)
4 -> TournamentEditorPlacementsTab(
tr = ::tr,
)
// 5: Pairings (optional)
5 -> if (showPairingsTab) {
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
TournamentEditorPairingsTab(
clubId = clubId,
tournamentId = tournamentId,
classes = classes,
participants = participants,
externalParticipants = externalParticipants,
tr = ::tr,
api = api,
scope = scope,
onReload = { reloadAll() },
onError = { error = it },
)
}
} else {
Spacer(Modifier.height(16.dp))
}
@@ -267,6 +291,23 @@ internal fun InternalTournamentEditorScreen(
}
}
@Composable
private fun TournamentEditorPlacementsTab(
tr: (String, String) -> String,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(tr("tournaments.placementsPendingTitle", "Platzierungen noch nicht verfügbar"), fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.height(8.dp))
Text(tr("tournaments.noPlacementsYet", "Noch keine Platzierungen vorhanden."))
}
}
private data class TournamentEditorLoadResult(
val detail: InternalTournamentDetailDto,
val classes: List<TournamentClassDto>,
@@ -278,9 +319,16 @@ private data class TournamentEditorLoadResult(
@Composable
private fun TournamentEditorMetaTab(
clubId: Int,
tournamentId: Int,
detail: InternalTournamentDetailDto,
classes: List<TournamentClassDto>,
tr: (String, String) -> String,
api: de.tsschulz.tt_tagebuch.shared.api.TournamentsApi,
scope: kotlinx.coroutines.CoroutineScope,
onSave: (UpdateTournamentMetaBody) -> Unit,
onReload: () -> Unit,
onError: (String?) -> Unit,
) {
var name by remember(detail.id) { mutableStateOf(detail.name.orEmpty()) }
var date by remember(detail.id) { mutableStateOf(detail.date.orEmpty()) }
@@ -299,56 +347,81 @@ private fun TournamentEditorMetaTab(
),
)
}
val scroll = rememberScrollState()
var showClasses by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scroll)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(tr("tournaments.tournamentName", "Turniername"), fontWeight = FontWeight.SemiBold)
OutlinedTextField(
value = name,
onValueChange = { name = it },
modifier = Modifier.fillMaxWidth().onFocusChanged { fs: FocusState -> if (!fs.isFocused) saveIfChanged(name, date, winningSets, tables, doubles, lastSaved, onSave) },
singleLine = true,
)
OutlinedTextField(
value = date,
onValueChange = { date = it },
modifier = Modifier.fillMaxWidth().onFocusChanged { fs: FocusState -> if (!fs.isFocused) saveIfChanged(name, date, winningSets, tables, doubles, lastSaved, onSave) },
label = { Text(tr("tournaments.date", "Datum")) },
singleLine = true,
)
OutlinedTextField(
value = winningSets,
onValueChange = { winningSets = it.filter { ch -> ch.isDigit() }.take(2) },
modifier = Modifier.fillMaxWidth().onFocusChanged { fs: FocusState -> if (!fs.isFocused) saveIfChanged(name, date, winningSets, tables, doubles, lastSaved, onSave) },
label = { Text(tr("tournaments.winningSets", "Gewinnsätze")) },
singleLine = true,
)
OutlinedTextField(
value = tables,
onValueChange = { tables = it.filter { ch -> ch.isDigit() } },
modifier = Modifier.fillMaxWidth().onFocusChanged { fs: FocusState -> if (!fs.isFocused) saveIfChanged(name, date, winningSets, tables, doubles, lastSaved, onSave) },
label = { Text(tr("mobile.tables", "Tische")) },
singleLine = true,
)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(tr("mobile.doublesTournament", "Doppel-Turnier"))
Switch(checked = doubles, onCheckedChange = {
doubles = it
saveIfChanged(name, date, winningSets, tables, doubles, lastSaved, onSave)
})
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(tr("tournaments.metaSectionTitle", "Stammdaten"), style = MaterialTheme.typography.h6, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.height(8.dp))
Text(tr("tournaments.tournamentName", "Turniername"), fontWeight = FontWeight.SemiBold)
OutlinedTextField(
value = name,
onValueChange = { name = it },
modifier = Modifier.fillMaxWidth().onFocusChanged { fs: FocusState -> if (!fs.isFocused) saveIfChanged(name, date, winningSets, tables, doubles, lastSaved, onSave) },
singleLine = true,
)
OutlinedTextField(
value = date,
onValueChange = { date = it },
modifier = Modifier.fillMaxWidth().onFocusChanged { fs: FocusState -> if (!fs.isFocused) saveIfChanged(name, date, winningSets, tables, doubles, lastSaved, onSave) },
label = { Text(tr("tournaments.date", "Datum")) },
singleLine = true,
)
OutlinedTextField(
value = winningSets,
onValueChange = { winningSets = it.filter { ch -> ch.isDigit() }.take(2) },
modifier = Modifier.fillMaxWidth().onFocusChanged { fs: FocusState -> if (!fs.isFocused) saveIfChanged(name, date, winningSets, tables, doubles, lastSaved, onSave) },
label = { Text(tr("tournaments.winningSets", "Gewinnsätze")) },
singleLine = true,
)
OutlinedTextField(
value = tables,
onValueChange = { tables = it.filter { ch -> ch.isDigit() } },
modifier = Modifier.fillMaxWidth().onFocusChanged { fs: FocusState -> if (!fs.isFocused) saveIfChanged(name, date, winningSets, tables, doubles, lastSaved, onSave) },
label = { Text(tr("tournaments.numberOfTables", "Tische")) },
singleLine = true,
)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(tr("mobile.doublesTournament", "Doppel-Turnier"))
Switch(checked = doubles, onCheckedChange = {
doubles = it
saveIfChanged(name, date, winningSets, tables, doubles, lastSaved, onSave)
})
}
Button(
onClick = {
saveIfChanged(name, date, winningSets, tables, doubles, lastSaved, onSave, force = true)
},
modifier = Modifier.fillMaxWidth(),
) {
Text(tr("mobile.save", "Speichern"))
}
Divider(modifier = Modifier.padding(vertical = 12.dp))
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(tr("tournaments.classesSection", "Klassen"), style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.SemiBold)
TextButton(onClick = { showClasses = !showClasses }) {
Text(if (showClasses) tr("mobile.hide", "Verbergen") else tr("mobile.show", "Anzeigen"))
}
}
Spacer(modifier = Modifier.height(6.dp))
}
Button(
onClick = {
saveIfChanged(name, date, winningSets, tables, doubles, lastSaved, onSave, force = true)
},
modifier = Modifier.fillMaxWidth(),
) {
Text(tr("mobile.save", "Speichern"))
if (showClasses) {
TournamentEditorClassesTab(
clubId = clubId,
tournamentId = tournamentId,
classes = classes,
tr = tr,
api = api,
scope = scope,
onReload = onReload,
onError = onError,
)
}
}
}
@@ -534,34 +607,61 @@ private fun TournamentEditorFlowTab(
},
) { Text(tr("tournaments.resetMatches", "Spiele zurücksetzen")) }
}
Divider(modifier = Modifier.padding(vertical = 8.dp))
Text(tr("mobile.tournamentKnockout", "K.-o.-Runde"), fontWeight = FontWeight.SemiBold)
Button(
onClick = {
scope.launch {
runCatching {
kotlinx.coroutines.withContext(Dispatchers.IO) {
api.startKnockout(
de.tsschulz.tt_tagebuch.shared.api.models.TournamentStartKnockoutBody(clubId, tournamentId),
)
}
}.onFailure { onError(it.message) }
onReload()
}
},
modifier = Modifier.fillMaxWidth(),
) {
Text(tr("tournaments.startKnockout", "K.-o. starten"))
}
// K.-o.-Runde starten: verschoben (nicht in Gruppen-Tab)
Text(
tr("mobile.tournamentGroupsPreview", "Gruppen-Daten (Überblick)") + ": " +
when (groupsJson) {
is kotlinx.serialization.json.JsonArray -> "${groupsJson.size} " + tr("mobile.entries", "Einträge")
null -> ""
else -> tr("mobile.dataLoaded", "geladen")
},
tr("mobile.tournamentGroupsPreview", "Gruppen-Daten (Überblick)"),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.75f),
)
when (groupsJson) {
is JsonArray -> {
val arr = groupsJson as JsonArray
if (arr.isEmpty()) {
Text(tr("mobile.noGroups", "Keine Gruppen vorhanden"))
} else {
arr.forEach { gElem ->
val obj = gElem.jsonObject
val classIdVal = obj["classId"]?.jsonPrimitive?.contentOrNull?.toIntOrNull()
val groupNumber = obj["groupNumber"]?.jsonPrimitive?.contentOrNull?.toIntOrNull() ?: 0
Text(
(classIdVal?.toString() ?: "") + "" + tr("tournaments.group", "Gruppe") + " ${groupNumber}",
fontWeight = FontWeight.SemiBold,
)
val participantsElem = obj["participants"]
if (participantsElem is JsonArray) {
// Table header
Row(Modifier.fillMaxWidth()) {
Text("#", fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(0.08f))
Text(tr("tournaments.participant", "Teilnehmer"), fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(0.62f))
Text(tr("tournaments.sets", "Sätze"), fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(0.15f))
Text(tr("tournaments.points", "Punkte"), fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(0.15f))
}
Spacer(modifier = Modifier.height(6.dp))
participantsElem.forEach { p ->
val pObj = p.jsonObject
val pname = pObj["name"]?.jsonPrimitive?.contentOrNull ?: "?"
val pos = pObj["position"]?.jsonPrimitive?.contentOrNull ?: "-"
val setsWon = pObj["setsWon"]?.jsonPrimitive?.contentOrNull ?: "-"
val setsLost = pObj["setsLost"]?.jsonPrimitive?.contentOrNull ?: "-"
val pointsWon = pObj["pointsWon"]?.jsonPrimitive?.contentOrNull ?: "-"
val pointsLost = pObj["pointsLost"]?.jsonPrimitive?.contentOrNull ?: "-"
Row(Modifier.fillMaxWidth()) {
Text(pos, modifier = Modifier.weight(0.08f))
Text(pname, modifier = Modifier.weight(0.62f))
Text("${setsWon}:${setsLost}", modifier = Modifier.weight(0.15f))
Text("${pointsWon}:${pointsLost}", modifier = Modifier.weight(0.15f))
}
}
} else {
Text(tr("mobile.noGroupParticipants", "Keine Teilnehmer"))
}
Divider(modifier = Modifier.padding(vertical = 8.dp))
}
}
}
null -> Text("")
else -> Text(tr("mobile.dataLoaded", "geladen"))
}
}
}