Freundschaftsspiele korrigiert
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 52s

This commit is contained in:
Torsten Schulz (local)
2026-06-06 12:42:17 +02:00
parent 5727404f88
commit 5194d4582f
22 changed files with 1527 additions and 177 deletions

View File

@@ -1,3 +1,4 @@
import java.util.Properties
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
val backendBaseUrlForRelease = providers.gradleProperty("backendBaseUrl")
@@ -12,6 +13,22 @@ val socketBaseUrl = providers.gradleProperty("socketBaseUrl")
.orElse("wss://tt-tagebuch.de:3051")
.get()
val signingPropertiesFile = rootProject.file("signing.properties")
val signingProperties = Properties().apply {
if (signingPropertiesFile.isFile) {
signingPropertiesFile.inputStream().use(::load)
}
}
fun signingValue(name: String, envName: String): String? =
signingProperties.getProperty(name)?.takeIf { it.isNotBlank() }
?: System.getenv(envName)?.takeIf { it.isNotBlank() }
val releaseStoreFile = signingValue("storeFile", "TTT_RELEASE_STORE_FILE")
val releaseStorePassword = signingValue("storePassword", "TTT_RELEASE_STORE_PASSWORD")
val releaseKeyAlias = signingValue("keyAlias", "TTT_RELEASE_KEY_ALIAS")
val releaseKeyPassword = signingValue("keyPassword", "TTT_RELEASE_KEY_PASSWORD")
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidApplication)
@@ -70,6 +87,14 @@ android {
buildFeatures {
buildConfig = true
}
signingConfigs {
create("releaseSigning") {
if (releaseStoreFile != null) storeFile = file(releaseStoreFile)
if (releaseStorePassword != null) storePassword = releaseStorePassword
if (releaseKeyAlias != null) keyAlias = releaseKeyAlias
if (releaseKeyPassword != null) keyPassword = releaseKeyPassword
}
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
@@ -77,6 +102,9 @@ android {
}
buildTypes {
getByName("release") {
if (releaseStoreFile != null && releaseStorePassword != null && releaseKeyAlias != null && releaseKeyPassword != null) {
signingConfig = signingConfigs.getByName("releaseSigning")
}
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),

View File

@@ -1596,6 +1596,8 @@ private fun DiaryListScreen(
var newDateGroupMenuExpanded by remember { mutableStateOf(false) }
var selectedNewDateGroupId by remember { mutableStateOf<Int?>(null) }
var quickCreateBusy by remember { mutableStateOf(false) }
var autoSelectedEntryForClubId by rememberSaveable(clubId) { mutableStateOf<Int?>(null) }
var suppressAutoSelect by rememberSaveable(clubId) { mutableStateOf(false) }
val diaryDatesNormKey = remember(diaryState.dates) {
diaryState.dates.map { it.date.take(10).trim() }.sorted().joinToString("|")
@@ -1644,35 +1646,35 @@ private fun DiaryListScreen(
LaunchedEffect(diaryState.dates, selectedEntryId) {
try {
Log.d("DiaryListDebug", "LaunchedEffect: selectedEntryId=$selectedEntryId dates=${diaryState.dates.map { it.id }}")
if (selectedEntryId != null) {
val found = diaryState.dates.any { it.id == selectedEntryId }
Log.d("DiaryListDebug", "selectedEntryId present in dates? $found")
if (found) {
// Force parent/state sync in case order of events produced a stale state.
onSelectedEntryId(selectedEntryId)
}
} else {
} else if (!suppressAutoSelect && autoSelectedEntryForClubId == null) {
// No selection set — if we have dates, default to the first one so details show immediately.
if (diaryState.dates.isNotEmpty()) {
val firstId = diaryState.dates.first().id
Log.d("DiaryListDebug", "No selectedEntryId - defaulting to first date id=$firstId")
autoSelectedEntryForClubId = firstId
onSelectedEntryId(firstId)
}
}
} catch (t: Throwable) {
Log.d("DiaryListDebug", "error in debug effect: ${t.message}")
// ignore
}
}
val selectedEntry = diaryState.dates.firstOrNull { it.id == selectedEntryId }
if (selectedEntry != null) {
Log.d("DiaryListDebug", "selectedEntry found -> id=${selectedEntry.id} date=${selectedEntry.date}")
DiaryDetailScreen(
clubId = clubId,
entry = selectedEntry,
dependencies = dependencies,
onBack = { onSelectedEntryId(null) },
onBack = {
suppressAutoSelect = true
onSelectedEntryId(null)
},
onOpenMemberPortraitCrop = onOpenMemberPortraitCrop,
onOpenMembersGallery = onOpenMembersGallery,
)

View File

@@ -77,7 +77,6 @@ internal fun ScheduleScreen(dependencies: AppDependencies, friendlyOnly: Boolean
val cm = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
cm.setPrimaryClip(ClipData.newPlainText("tt_tagebuch", value))
}
var teamMenu by remember { mutableStateOf(false) }
var otherTeamMenu by remember { mutableStateOf(false) }
var detailMatch by remember { mutableStateOf<ScheduleMatchDto?>(null) }
@@ -85,8 +84,38 @@ internal fun ScheduleScreen(dependencies: AppDependencies, friendlyOnly: Boolean
var friendlyEditMatch by remember { mutableStateOf<ScheduleMatchDto?>(null) }
var showFriendlyCreate by remember { mutableStateOf(false) }
var friendlyResultMatch by remember { mutableStateOf<ScheduleMatchDto?>(null) }
var friendlyHomeMembers by remember { mutableStateOf(emptyList<FriendlyMemberOption>()) }
var friendlyGuestMembers by remember { mutableStateOf(emptyList<FriendlyMemberOption>()) }
var playerError by remember { mutableStateOf<String?>(null) }
var playerSaving by remember { mutableStateOf(false) }
var playerDialogMembers by remember { mutableStateOf(emptyList<de.tsschulz.tt_tagebuch.shared.api.models.Member>()) }
suspend fun loadFriendlyMembersFor(match: ScheduleMatchDto?) {
fun de.tsschulz.tt_tagebuch.shared.api.models.Member.toOption() = FriendlyMemberOption(id, "$firstName $lastName".trim())
if (match?.isSharedFriendly == true) {
friendlyHomeMembers = dependencies.matchesApi.listSharedFriendlyMembers(clubId, match.id, "home").filter { it.active }.map { it.toOption() }.sortedBy { it.name.lowercase() }
friendlyGuestMembers = dependencies.matchesApi.listSharedFriendlyMembers(clubId, match.id, "guest").filter { it.active }.map { it.toOption() }.sortedBy { it.name.lowercase() }
} else {
dependencies.membersManager.loadMembers(clubId)
val options = dependencies.matchesApi.listFriendlyMembers(clubId).filter { it.active }.map { it.toOption() }.sortedBy { it.name.lowercase() }
friendlyHomeMembers = options
friendlyGuestMembers = emptyList()
}
}
suspend fun loadPlayerDialogMembersFor(match: ScheduleMatchDto) {
playerDialogMembers = if (match.isFriendly && match.isSharedFriendly) {
val ownSide = if (match.homeClubId == clubId) "home" else "guest"
dependencies.matchesApi.listSharedFriendlyMembers(clubId, match.id, ownSide)
.filter { it.active }
.sortedBy { "${it.firstName} ${it.lastName}".lowercase() }
} else if (match.isFriendly) {
dependencies.matchesApi.listFriendlyMembers(clubId).filter { it.active }.sortedBy { "${it.firstName} ${it.lastName}".lowercase() }
} else {
dependencies.membersManager.loadMembers(clubId)
dependencies.membersManager.state.value.members.filter { it.active }.sortedBy { "${it.firstName} ${it.lastName}".lowercase() }
}
}
var readyIds by remember { mutableStateOf(emptyList<Int>()) }
var plannedIds by remember { mutableStateOf(emptyList<Int>()) }
@@ -393,13 +422,13 @@ internal fun ScheduleScreen(dependencies: AppDependencies, friendlyOnly: Boolean
friendlyResultMatch = m
detailMatch = null
},
) { Text("Ergebnis") }
) { Text(if (isFriendlyMatchLocked(m)) "Ansehen" else "Ergebnis") }
TextButton(
onClick = {
friendlyEditMatch = m
detailMatch = null
},
) { Text("Bearbeiten") }
) { Text(if (isFriendlyMatchLocked(m)) "Details" else "Bearbeiten") }
} else {
TextButton(
onClick = {
@@ -421,11 +450,16 @@ internal fun ScheduleScreen(dependencies: AppDependencies, friendlyOnly: Boolean
}
if (showFriendlyCreate || friendlyEditMatch != null) {
LaunchedEffect(friendlyEditMatch?.id, showFriendlyCreate) {
loadFriendlyMembersFor(friendlyEditMatch)
}
FriendlyMatchEditDialog(
match = friendlyEditMatch,
clubName = clubState.clubs.find { it.id == clubId }?.name.orEmpty(),
memberOptions = membersState.members.filter { it.active }.map { FriendlyMemberOption(it.id, "${it.firstName} ${it.lastName}".trim()) },
onLoadMembers = { scope.launch { dependencies.membersManager.loadMembers(clubId) } },
homeMemberOptions = friendlyFilteredMembers(friendlyEditMatch, friendlyHomeMembers),
guestMemberOptions = friendlyFilteredMembers(friendlyEditMatch, friendlyGuestMembers),
readonly = isFriendlyMatchLocked(friendlyEditMatch),
onLoadMembers = { scope.launch { loadFriendlyMembersFor(friendlyEditMatch) } },
onDismiss = {
showFriendlyCreate = false
friendlyEditMatch = null
@@ -433,6 +467,7 @@ internal fun ScheduleScreen(dependencies: AppDependencies, friendlyOnly: Boolean
onSave = { body ->
scope.launch {
if (friendlyEditMatch != null) {
if (isFriendlyMatchLocked(friendlyEditMatch)) return@launch
dependencies.scheduleManager.updateFriendlyMatch(clubId, friendlyEditMatch!!.id, body)
} else {
dependencies.scheduleManager.createFriendlyMatch(clubId, body)
@@ -444,6 +479,7 @@ internal fun ScheduleScreen(dependencies: AppDependencies, friendlyOnly: Boolean
onDelete = if (friendlyEditMatch != null) {
{
scope.launch {
if (isFriendlyMatchLocked(friendlyEditMatch)) return@launch
dependencies.scheduleManager.deleteFriendlyMatch(clubId, friendlyEditMatch!!.id)
friendlyEditMatch = null
}
@@ -453,12 +489,15 @@ internal fun ScheduleScreen(dependencies: AppDependencies, friendlyOnly: Boolean
}
friendlyResultMatch?.let { match ->
LaunchedEffect(match.id) { loadFriendlyMembersFor(match) }
FriendlyResultDialog(
match = match,
memberOptions = membersState.members.filter { it.active }.map { FriendlyMemberOption(it.id, "${it.firstName} ${it.lastName}".trim()) },
memberOptions = friendlyHomeMembers + friendlyGuestMembers,
readonly = isFriendlyMatchLocked(match),
onDismiss = { friendlyResultMatch = null },
onSave = { body ->
scope.launch {
if (isFriendlyMatchLocked(match)) return@launch
dependencies.scheduleManager.updateFriendlyMatch(clubId, match.id, body)
val updated = body.toMatchLike(match)
friendlyResultMatch = updated
@@ -466,6 +505,7 @@ internal fun ScheduleScreen(dependencies: AppDependencies, friendlyOnly: Boolean
},
onComplete = { body ->
scope.launch {
if (isFriendlyMatchLocked(match)) return@launch
dependencies.scheduleManager.updateFriendlyMatch(clubId, match.id, body.copy(isCompleted = true))
friendlyResultMatch = null
}
@@ -475,7 +515,7 @@ internal fun ScheduleScreen(dependencies: AppDependencies, friendlyOnly: Boolean
playerMatch?.let { m ->
LaunchedEffect(m.id, clubId) {
dependencies.membersManager.loadMembers(clubId)
loadPlayerDialogMembersFor(m)
}
AlertDialog(
onDismissRequest = { if (!playerSaving) playerMatch = null },
@@ -483,8 +523,8 @@ internal fun ScheduleScreen(dependencies: AppDependencies, friendlyOnly: Boolean
text = {
Column(modifier = Modifier.heightIn(max = 400.dp)) {
playerError?.let { Text(it, color = MaterialTheme.colors.error) }
val memberList = membersState.members.filter { it.active }
if (membersState.isLoading) {
val memberList = playerDialogMembers
if (membersState.isLoading && memberList.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.padding(16.dp))
} else {
val scroll = rememberScrollState()
@@ -528,18 +568,21 @@ internal fun ScheduleScreen(dependencies: AppDependencies, friendlyOnly: Boolean
},
confirmButton = {
TextButton(
enabled = !playerSaving,
enabled = !playerSaving && !isFriendlyMatchLocked(m),
onClick = {
scope.launch {
playerSaving = true
playerError = null
runCatching {
val visibleIds = playerDialogMembers.map { it.id }.toSet()
fun mergeVisible(existing: List<Int>, selected: List<Int>): List<Int> =
(existing.filter { it !in visibleIds } + selected.filter { it in visibleIds }).distinct()
dependencies.scheduleManager.updateMatchPlayersForMatch(
clubId = clubId,
match = m,
ready = readyIds,
planned = plannedIds,
played = playedIds,
ready = mergeVisible(m.playersReady, readyIds),
planned = mergeVisible(m.playersPlanned, plannedIds),
played = mergeVisible(m.playersPlayed, playedIds),
)
playerMatch = null
}.onFailure { playerError = it.message ?: tr("schedule.errorSavingPlayerSelection", "Speichern fehlgeschlagen") }
@@ -563,7 +606,9 @@ private data class FriendlyMemberOption(val id: Int, val name: String)
private fun FriendlyMatchEditDialog(
match: ScheduleMatchDto?,
clubName: String,
memberOptions: List<FriendlyMemberOption>,
homeMemberOptions: List<FriendlyMemberOption>,
guestMemberOptions: List<FriendlyMemberOption>,
readonly: Boolean,
onLoadMembers: () -> Unit,
onDismiss: () -> Unit,
onSave: (FriendlyMatchSaveBody) -> Unit,
@@ -578,28 +623,72 @@ private fun FriendlyMatchEditDialog(
var winningSetsText by remember(match?.id) { mutableStateOf((match?.winningSets ?: 3).toString()) }
var homeParticipants by remember(match?.id) { mutableStateOf(match?.homeParticipants ?: emptyList()) }
var guestParticipants by remember(match?.id) { mutableStateOf(match?.guestParticipants ?: emptyList()) }
var resultRows by remember(match?.id) { mutableStateOf(match?.resultDetails ?: emptyList()) }
var error by remember { mutableStateOf<String?>(null) }
fun doubleRows(): List<FriendlyResultRowDto> {
val template = friendlyResultTemplate(matchSystem, homeParticipants.size, guestParticipants.size, match?.doublesCount ?: 4, match?.singlesCount ?: 12)
val doubleIds = template.filter { it.type == "double" }.map { it.id }
val existingById = resultRows.filter { it.id.isNotBlank() }.associateBy { it.id }
val singles = resultRows.filter { it.type != "double" }
val normalized = doubleIds.map { id ->
val row = existingById[id] ?: FriendlyResultRowDto(id = id, type = "double", sets = List(5) { "" })
row.copy(id = id, type = "double", sets = List(5) { row.sets.getOrNull(it).orEmpty() })
}
resultRows = normalized + singles
return normalized
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(if (match == null) "Freundschaftsspiel anlegen" else "Freundschaftsspiel bearbeiten") },
text = {
Column(Modifier.verticalScroll(rememberScrollState())) {
error?.let { Text(it, color = MaterialTheme.colors.error) }
OutlinedTextField(date, { date = it }, label = { Text("Datum") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(time, { time = it }, label = { Text("Uhrzeit") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(homeTeam, { homeTeam = it }, label = { Text("Heimteam") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(guestTeam, { guestTeam = it }, label = { Text("Gastteam") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(matchSystem, { matchSystem = it }, label = { Text("Spielsystem") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(winningSetsText, { winningSetsText = it.filter(Char::isDigit) }, label = { Text("Gewinnsätze") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(date, { date = it }, label = { Text("Datum") }, enabled = !readonly, modifier = Modifier.fillMaxWidth())
OutlinedTextField(time, { time = it }, label = { Text("Uhrzeit") }, enabled = !readonly, modifier = Modifier.fillMaxWidth())
OutlinedTextField(homeTeam, { homeTeam = it }, label = { Text("Heimteam") }, enabled = !readonly, modifier = Modifier.fillMaxWidth())
OutlinedTextField(guestTeam, { guestTeam = it }, label = { Text("Gastteam") }, enabled = !readonly, modifier = Modifier.fillMaxWidth())
OutlinedTextField(matchSystem, { matchSystem = it }, label = { Text("Spielsystem") }, enabled = !readonly, modifier = Modifier.fillMaxWidth())
OutlinedTextField(winningSetsText, { winningSetsText = it.filter(Char::isDigit) }, label = { Text("Gewinnsätze") }, enabled = !readonly, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.height(8.dp))
FriendlyParticipantEditor("Heim-Aufstellung", memberOptions, homeParticipants) { homeParticipants = it }
FriendlyParticipantEditor("Heim-Aufstellung", homeMemberOptions, homeParticipants, allowMembers = true, allowManual = false, readonly = readonly) { homeParticipants = it }
Spacer(Modifier.height(8.dp))
FriendlyParticipantEditor("Gast-Aufstellung", memberOptions, guestParticipants) { guestParticipants = it }
FriendlyParticipantEditor("Gast-Aufstellung", guestMemberOptions, guestParticipants, allowMembers = guestMemberOptions.isNotEmpty(), allowManual = guestMemberOptions.isEmpty(), readonly = readonly) { guestParticipants = it }
val doubles = doubleRows()
if (doubles.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text("Doppel", fontWeight = FontWeight.SemiBold)
doubles.forEachIndexed { index, row ->
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp), elevation = 1.dp) {
Column(Modifier.padding(8.dp)) {
Text("Doppel ${index + 1}", fontWeight = FontWeight.SemiBold)
FriendlyResultPlayerSelector(
label = "Heim",
row = row,
fieldValue = row.homeName,
sideLabels = friendlyResultSideLabels(homeParticipants, homeMemberOptions),
rows = doubles,
readonly = readonly,
onChange = { value -> resultRows = resultRows.map { if (it.id == row.id) it.copy(homeName = value) else it } },
)
FriendlyResultPlayerSelector(
label = "Gast",
row = row,
fieldValue = row.guestName,
sideLabels = friendlyResultSideLabels(guestParticipants, guestMemberOptions),
rows = doubles,
readonly = readonly,
onChange = { value -> resultRows = resultRows.map { if (it.id == row.id) it.copy(guestName = value) else it } },
)
}
}
}
}
}
},
confirmButton = {
TextButton(
if (!readonly) TextButton(
onClick = {
val winningSets = winningSetsText.toIntOrNull()?.takeIf { it > 0 } ?: 3
if (date.isBlank() || homeTeam.isBlank() || guestTeam.isBlank()) {
@@ -619,7 +708,7 @@ private fun FriendlyMatchEditDialog(
homeMatchPoints = match?.homeMatchPoints ?: 0,
guestMatchPoints = match?.guestMatchPoints ?: 0,
isCompleted = match?.isCompleted ?: false,
resultDetails = match?.resultDetails ?: emptyList(),
resultDetails = resultRows,
),
)
},
@@ -627,7 +716,7 @@ private fun FriendlyMatchEditDialog(
},
dismissButton = {
Row {
onDelete?.let {
if (!readonly) onDelete?.let {
TextButton(onClick = it) { Text("Löschen") }
}
TextButton(onClick = onDismiss) { Text("Abbrechen") }
@@ -641,18 +730,21 @@ private fun FriendlyParticipantEditor(
title: String,
members: List<FriendlyMemberOption>,
participants: List<FriendlyParticipantDto>,
allowMembers: Boolean = true,
allowManual: Boolean = true,
readonly: Boolean = false,
onChange: (List<FriendlyParticipantDto>) -> Unit,
) {
var menuOpen by remember { mutableStateOf(false) }
var manualName by remember { mutableStateOf("") }
Column {
Text(title, fontWeight = FontWeight.SemiBold)
Box(Modifier.fillMaxWidth()) {
if (!readonly && allowMembers) Box(Modifier.fillMaxWidth()) {
OutlinedButton(onClick = { menuOpen = true }, modifier = Modifier.fillMaxWidth()) {
Text("Mitglied hinzufügen")
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
members.forEach { member ->
members.filter { member -> participants.none { it.type == "member" && it.memberId == member.id } }.forEach { member ->
DropdownMenuItem(
onClick = {
menuOpen = false
@@ -664,7 +756,7 @@ private fun FriendlyParticipantEditor(
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
if (!readonly && allowManual) Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
manualName,
{ manualName = it },
@@ -685,7 +777,7 @@ private fun FriendlyParticipantEditor(
participants.forEachIndexed { index, participant ->
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(participantLabel(participant, members), modifier = Modifier.weight(1f))
TextButton(onClick = { onChange(participants.filterIndexed { i, _ -> i != index }) }) { Text("x") }
if (!readonly) TextButton(onClick = { onChange(participants.filterIndexed { i, _ -> i != index }) }) { Text("x") }
}
}
}
@@ -695,6 +787,7 @@ private fun FriendlyParticipantEditor(
private fun FriendlyResultDialog(
match: ScheduleMatchDto,
memberOptions: List<FriendlyMemberOption>,
readonly: Boolean,
onDismiss: () -> Unit,
onSave: (FriendlyMatchSaveBody) -> Unit,
onComplete: (FriendlyMatchSaveBody) -> Unit,
@@ -731,7 +824,8 @@ private fun FriendlyResultDialog(
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp), elevation = 1.dp) {
Column(Modifier.padding(8.dp)) {
Text("${index + 1}. ${if (row.type == "double") "Doppel" else "Einzel"}", fontWeight = FontWeight.SemiBold)
Text("${row.homeName} : ${row.guestName}", style = MaterialTheme.typography.caption)
Text("Heim: ${row.homeName.ifBlank { "-" }}", style = MaterialTheme.typography.caption)
Text("Gast: ${row.guestName.ifBlank { "-" }}", style = MaterialTheme.typography.caption)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
val state = friendlyRowState(row, match.winningSets)
(0 until 5).forEach { setIndex ->
@@ -748,13 +842,13 @@ private fun FriendlyResultDialog(
}
},
label = { Text("${setIndex + 1}") },
enabled = !disabled,
enabled = !readonly && !disabled,
modifier = Modifier.weight(1f),
singleLine = true,
)
}
}
OutlinedButton(
if (!readonly) OutlinedButton(
onClick = {
val normalized = normalizeFriendlyRow(row, match.winningSets)
if (normalized == null) {
@@ -773,11 +867,134 @@ private fun FriendlyResultDialog(
}
}
},
confirmButton = { TextButton(onClick = { onComplete(body(true)) }) { Text("Abschließen") } },
confirmButton = { if (!readonly) TextButton(onClick = { onComplete(body(true)) }) { Text("Abschließen") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Schließen") } },
)
}
@Composable
private fun FriendlyResultPlayerSelector(
label: String,
row: FriendlyResultRowDto,
fieldValue: String,
sideLabels: List<String>,
rows: List<FriendlyResultRowDto>,
readonly: Boolean,
onChange: (String) -> Unit,
) {
if (row.type == "double") {
Column {
FriendlyResultNameMenu(
label = "$label 1",
value = friendlyDoublePart(fieldValue, 0),
options = friendlyResultNameOptions(sideLabels, rows, row, label, fieldValue, 0),
readonly = readonly,
onChange = { value -> onChange(friendlySetDoublePart(fieldValue, 0, value)) },
)
FriendlyResultNameMenu(
label = "$label 2",
value = friendlyDoublePart(fieldValue, 1),
options = friendlyResultNameOptions(sideLabels, rows, row, label, fieldValue, 1),
readonly = readonly,
onChange = { value -> onChange(friendlySetDoublePart(fieldValue, 1, value)) },
)
}
} else {
FriendlyResultNameMenu(
label = label,
value = fieldValue,
options = friendlyResultNameOptions(sideLabels, rows, row, label, fieldValue, null),
readonly = readonly,
onChange = onChange,
)
}
}
@Composable
private fun FriendlyResultNameMenu(
label: String,
value: String,
options: List<String>,
readonly: Boolean,
onChange: (String) -> Unit,
) {
var open by remember { mutableStateOf(false) }
Box(Modifier.fillMaxWidth()) {
OutlinedButton(
enabled = !readonly,
onClick = { open = true },
modifier = Modifier.fillMaxWidth(),
) { Text(value.ifBlank { label }) }
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
DropdownMenuItem(onClick = { open = false; onChange("") }) { Text("-") }
options.forEach { option ->
DropdownMenuItem(onClick = { open = false; onChange(option) }) { Text(option) }
}
}
}
}
private fun friendlyResultSideLabels(participants: List<FriendlyParticipantDto>, members: List<FriendlyMemberOption>): List<String> =
participants.map { participantLabel(it, members) }.filter { it.isNotBlank() }
private fun friendlyDoublePart(value: String, index: Int): String =
value.split("/").map { it.trim() }.getOrNull(index).orEmpty()
private fun friendlySetDoublePart(value: String, index: Int, part: String): String {
val parts = mutableListOf(friendlyDoublePart(value, 0), friendlyDoublePart(value, 1))
parts[index] = part
return parts.filter { it.isNotBlank() }.joinToString(" / ")
}
private fun friendlyResultNameOptions(
labels: List<String>,
rows: List<FriendlyResultRowDto>,
currentRow: FriendlyResultRowDto,
label: String,
currentValue: String,
doubleIndex: Int?,
): List<String> {
val fieldSelector: (FriendlyResultRowDto) -> String = if (label.startsWith("Heim")) { row -> row.homeName } else { row -> row.guestName }
val used = mutableSetOf<String>()
rows.filter { it != currentRow }.forEach { row ->
val value = fieldSelector(row)
if (row.type == "double") {
friendlyDoublePart(value, 0).takeIf { it.isNotBlank() }?.let { used.add(it) }
friendlyDoublePart(value, 1).takeIf { it.isNotBlank() }?.let { used.add(it) }
} else if (value.isNotBlank()) {
used.add(value)
}
}
val current = if (doubleIndex == null) currentValue else friendlyDoublePart(currentValue, doubleIndex)
if (doubleIndex != null) {
friendlyDoublePart(currentValue, if (doubleIndex == 0) 1 else 0).takeIf { it.isNotBlank() }?.let { used.add(it) }
}
return labels.filter { it == current || it !in used }
}
private fun isFriendlyMatchLocked(match: ScheduleMatchDto?): Boolean {
if (match?.isFriendly != true) return false
if (match.isLocked) return true
val date = match.date?.take(10) ?: return false
return runCatching {
val endsAt = java.time.LocalDate.parse(date).atTime(23, 59, 59)
!endsAt.isAfter(java.time.LocalDateTime.now())
}.getOrDefault(false)
}
private fun friendlyEligibleMemberIds(match: ScheduleMatchDto?): Set<Int>? {
fun ids(value: List<Int>) = value.toSet()
val ready = ids(match?.playersReady ?: emptyList())
val planned = ids(match?.playersPlanned ?: emptyList())
if (ready.isEmpty() || planned.isEmpty()) return null
return ready.intersect(planned)
}
private fun friendlyFilteredMembers(match: ScheduleMatchDto?, members: List<FriendlyMemberOption>): List<FriendlyMemberOption> {
val eligible = friendlyEligibleMemberIds(match) ?: return members
return members.filter { it.id in eligible }
}
private fun FriendlyMatchSaveBody.toMatchLike(match: ScheduleMatchDto): ScheduleMatchDto =
match.copy(
homeMatchPoints = homeMatchPoints,
@@ -793,26 +1010,113 @@ private fun participantLabel(participant: FriendlyParticipantDto, members: List<
return listOf(participant.firstName, participant.lastName).filter { it.isNotBlank() }.joinToString(" ")
}
private data class FriendlyResultTemplateRow(
val id: String,
val type: String,
val home: String,
val guest: String,
)
private fun friendlyResultTemplate(
matchSystem: String?,
homeCount: Int,
guestCount: Int,
fallbackDoublesCount: Int,
fallbackSinglesCount: Int,
): List<FriendlyResultTemplateRow> {
val system = matchSystem.orEmpty().trim().lowercase()
fun d(id: String, home: String, guest: String) = FriendlyResultTemplateRow(id, "double", home, guest)
fun s(id: String, home: String, guest: String) = FriendlyResultTemplateRow(id, "single", home, guest)
if ("bundessystem" in system) return listOf(
d("d-1", "D1", "D1"), d("d-2", "D2", "D2"),
s("s-1", "A1", "B1"), s("s-2", "A2", "B2"), s("s-3", "A3", "B3"), s("s-4", "A4", "B4"),
s("s-5", "A1", "B2"), s("s-6", "A2", "B1"), s("s-7", "A3", "B4"), s("s-8", "A4", "B3"),
)
if ("werner" in system) return listOf(
d("d-1", "D1", "D1"), d("d-2", "D2", "D2"),
s("s-1", "A1", "B2"), s("s-2", "A2", "B1"), s("s-3", "A3", "B4"), s("s-4", "A4", "B3"),
s("s-5", "A1", "B1"), s("s-6", "A2", "B2"), s("s-7", "A3", "B3"), s("s-8", "A4", "B4"),
)
if ("sechser" in system) return listOf(
d("d-1", "D1", "D1"), d("d-2", "D2", "D2"), d("d-3", "D3", "D3"),
s("s-1", "A1", "B2"), s("s-2", "A2", "B1"), s("s-3", "A1", "B1"), s("s-4", "A2", "B2"),
s("s-5", "A3", "B4"), s("s-6", "A4", "B3"), s("s-7", "A3", "B3"), s("s-8", "A4", "B4"),
s("s-9", "A5", "B6"), s("s-10", "A6", "B5"), s("s-11", "A5", "B5"), s("s-12", "A6", "B6"),
d("d-4", "D1", "D1"),
)
if ("europaliga" in system) return listOf(
d("d-1", "D1", "D1"), d("d-2", "D2", "D2"), d("d-3", "D3", "D3"),
s("s-1", "A1", "B1"), s("s-2", "A2", "B2"), s("s-3", "A1", "B2"), s("s-4", "A2", "B1"),
s("s-5", "A3", "B3"), s("s-6", "A4", "B4"), s("s-7", "A3", "B4"), s("s-8", "A4", "B3"),
s("s-9", "A5", "B5"), s("s-10", "A6", "B6"), s("s-11", "A5", "B6"), s("s-12", "A6", "B5"),
)
if ("corbillon" in system) return listOf(
s("s-1", "A1", "B1"), s("s-2", "A2", "B2"), d("d-1", "D1", "D1"), s("s-3", "A1", "B2"), s("s-4", "A2", "B1"),
)
if ("modifiziertes swaythling" in system) return listOf(
s("s-1", "A1", "B2"), s("s-2", "A2", "B1"), s("s-3", "A3", "B3"), d("d-1", "D1", "D1"),
s("s-4", "A1", "B1"), s("s-5", "A3", "B2"), s("s-6", "A2", "B3"),
)
if ("swaythling" in system) return listOf(
s("s-1", "A1", "B1"), s("s-2", "A2", "B2"), s("s-3", "A3", "B3"), s("s-4", "A1", "B2"), s("s-5", "A2", "B1"),
)
if ("braunschweiger" in system) {
if (homeCount >= 4 && guestCount >= 4) return listOf(
d("d-1", "D1", "D1"), d("d-2", "D2", "D2"),
s("s-1", "A1", "B1"), s("s-2", "A2", "B2"), s("s-3", "A3", "B3"), s("s-4", "A4", "B4"),
s("s-5", "A1", "B2"), s("s-6", "A2", "B1"), s("s-7", "A3", "B4"), s("s-8", "A4", "B3"),
)
if (homeCount >= 4 && guestCount <= 3) return listOf(
d("d-1", "D1", "D1"),
s("s-1", "A3", "B3"), s("s-2", "A1", "B2"), s("s-3", "A2", "B1"), s("s-4", "A4", "B2"),
s("s-5", "A1", "B1"), s("s-6", "A4", "B3"), s("s-7", "A2", "B2"), s("s-8", "A1", "B3"), s("s-9", "A3", "B1"),
)
if (homeCount <= 3 && guestCount >= 4) return listOf(
d("d-1", "D1", "D1"),
s("s-1", "A3", "B3"), s("s-2", "A2", "B1"), s("s-3", "A1", "B2"), s("s-4", "A2", "B4"),
s("s-5", "A1", "B1"), s("s-6", "A3", "B4"), s("s-7", "A2", "B2"), s("s-8", "A3", "B1"), s("s-9", "A1", "B3"),
)
return listOf(
d("d-1", "D1", "D1"),
s("s-1", "A1", "B2"), s("s-2", "A2", "B1"), s("s-3", "A3", "B2"), s("s-4", "A2", "B3"),
s("s-5", "A1", "B1"), s("s-6", "A3", "B3"), s("s-7", "A2", "B2"), s("s-8", "A3", "B1"), s("s-9", "A1", "B3"),
)
}
val homeSlots = homeCount.coerceAtLeast(1)
val guestSlots = guestCount.coerceAtLeast(1)
return buildList {
repeat(fallbackDoublesCount.coerceAtLeast(0)) { add(d("d-${it + 1}", "D${it + 1}", "D${it + 1}")) }
repeat(fallbackSinglesCount.coerceAtLeast(0)) { add(s("s-${it + 1}", "A${(it % homeSlots) + 1}", "B${(it % guestSlots) + 1}")) }
}
}
private fun buildFriendlyResultRows(match: ScheduleMatchDto, members: List<FriendlyMemberOption>): List<FriendlyResultRowDto> {
val existing = match.resultDetails
val generated = generateFriendlyResultRows(match, members)
if (existing.isNotEmpty()) {
return existing.mapIndexed { index, row ->
row.copy(
homeName = row.homeName.ifBlank { generated.getOrNull(index)?.homeName.orEmpty() },
guestName = row.guestName.ifBlank { generated.getOrNull(index)?.guestName.orEmpty() },
sets = List(5) { row.sets.getOrNull(it).orEmpty() },
if (existing.isEmpty()) return generated
val existingById = existing.filter { it.id.isNotBlank() }.associateBy { it.id }
return generated.mapIndexed { index, generatedRow ->
val existingRow = existingById[generatedRow.id] ?: existing.getOrNull(index)
if (existingRow == null) {
generatedRow
} else {
generatedRow.copy(
sets = List(5) { existingRow.sets.getOrNull(it) ?: generatedRow.sets.getOrNull(it).orEmpty() },
completed = existingRow.completed,
)
}
}
return generated
}
private fun generateFriendlyResultRows(match: ScheduleMatchDto, members: List<FriendlyMemberOption>): List<FriendlyResultRowDto> {
val home = match.homeParticipants.map { participantLabel(it, members) }.filter { it.isNotBlank() }
val guest = match.guestParticipants.map { participantLabel(it, members) }.filter { it.isNotBlank() }
fun single(list: List<String>, index: Int): String = list.getOrNull(index % kotlin.math.max(list.size, 1)).orEmpty()
fun double(list: List<String>, index: Int): String {
fun player(list: List<String>, code: String): String {
val number = Regex("[AB](\\d+)", RegexOption.IGNORE_CASE).find(code)?.groupValues?.getOrNull(1)?.toIntOrNull() ?: return ""
return list.getOrNull(number - 1).orEmpty()
}
fun fallbackDouble(list: List<String>, index: Int): String {
if (list.isEmpty()) return ""
if (list.size == 1) return list.first()
val pairs = listOf(0 to 1, 2 to 3, 0 to 2, 1 to 3)
@@ -821,13 +1125,20 @@ private fun generateFriendlyResultRows(match: ScheduleMatchDto, members: List<Fr
val b = list[pair.second % list.size]
return if (a == b) a else "$a / $b"
}
return buildList {
repeat(match.doublesCount.coerceAtLeast(0)) { i ->
add(FriendlyResultRowDto(id = "d-${i + 1}", type = "double", homeName = double(home, i), guestName = double(guest, i), sets = List(5) { "" }))
}
repeat(match.singlesCount.coerceAtLeast(0)) { i ->
add(FriendlyResultRowDto(id = "s-${i + 1}", type = "single", homeName = single(home, i), guestName = single(guest, i), sets = List(5) { "" }))
}
fun doubleName(list: List<String>, side: String, code: String): String {
val number = Regex("D[A-Z]?(\\d+)", RegexOption.IGNORE_CASE).find(code)?.groupValues?.getOrNull(1)?.toIntOrNull() ?: 1
val existing = match.resultDetails.find { it.id == "d-$number" }
val value = if (side == "guest") existing?.guestName else existing?.homeName
return value?.takeIf { it.isNotBlank() } ?: fallbackDouble(list, number - 1)
}
return friendlyResultTemplate(match.matchSystem, home.size, guest.size, match.doublesCount, match.singlesCount).map { row ->
FriendlyResultRowDto(
id = row.id,
type = row.type,
homeName = if (row.type == "double") doubleName(home, "home", row.home) else player(home, row.home),
guestName = if (row.type == "double") doubleName(guest, "guest", row.guest) else player(guest, row.guest),
sets = List(5) { "" },
)
}
}
@@ -839,7 +1150,7 @@ private fun normalizeFriendlySet(value: String): String? {
if (parts.size != 2) return null
val a = parts[0].toIntOrNull() ?: return null
val b = parts[1].toIntOrNull() ?: return null
if (a < 0 || b < 0 || (a < 11 && b < 11) || kotlin.math.abs(a - b) < 2) return null
if (a < 0 || b < 0 || kotlin.math.max(a, b) < 11 || kotlin.math.abs(a - b) < 2) return null
return "$a:$b"
}
val losing = raw.removePrefix("-").toIntOrNull() ?: return null