feat: Implement friendly match management with socket integration and UI updates
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 48s

This commit is contained in:
Torsten Schulz (local)
2026-05-18 10:02:31 +02:00
parent f9ab3d9932
commit 197f06989f
5 changed files with 473 additions and 18 deletions

View File

@@ -32,6 +32,7 @@ import de.tsschulz.tt_tagebuch.shared.api.OfficialTournamentsApi
import de.tsschulz.tt_tagebuch.shared.api.PermissionsApi
import de.tsschulz.tt_tagebuch.shared.api.SeasonsApi
import de.tsschulz.tt_tagebuch.shared.api.SessionApi
import de.tsschulz.tt_tagebuch.shared.api.SocketService
import de.tsschulz.tt_tagebuch.shared.api.TeamDocumentsApi
import de.tsschulz.tt_tagebuch.shared.api.TrainingGroupsApi
import de.tsschulz.tt_tagebuch.shared.api.TrainingStatsApi
@@ -71,6 +72,7 @@ class AppDependencies(context: Context) {
val applicationScope = CoroutineScope(applicationJob + Dispatchers.Main.immediate)
val networkConnectivity = NetworkConnectivityHolder(context.applicationContext)
val apiConfig = ApiConfig(baseUrl = BuildConfig.BACKEND_BASE_URL)
val socketService = SocketService(BuildConfig.SOCKET_BASE_URL)
val unauthorizedEvents = MutableStateFlow(0)
private val tokenProvider = MutableTokenProvider()
private val client = AuthedHttpClient(

View File

@@ -169,7 +169,7 @@ private fun visibleMainTabs(perms: UserClubPermissions?): List<MainTab> =
MainTab.Home, MainTab.Settings -> true
MainTab.Diary -> perms == null || perms.canReadDiary()
MainTab.Members -> perms == null || perms.canReadMembers()
MainTab.Schedule -> perms == null || perms.canReadSchedule()
MainTab.Schedule, MainTab.FriendlyMatches -> perms == null || perms.canReadSchedule()
MainTab.Calendar ->
perms == null ||
perms.canReadDiary() || perms.canReadSchedule() || perms.canReadTournaments()
@@ -189,6 +189,7 @@ private enum class MainTab {
Diary,
Members,
Schedule,
FriendlyMatches,
Calendar,
Stats,
Tournaments,
@@ -496,6 +497,7 @@ private fun MainTabContent(
onConsumeOpenMemberGallery = onConsumeOpenMemberGallery,
)
MainTab.Schedule -> ScheduleScreen(dependencies)
MainTab.FriendlyMatches -> ScheduleScreen(dependencies, friendlyOnly = true)
MainTab.Calendar -> CalendarScreen(
dependencies = dependencies,
onOpenDiaryDate = { id ->
@@ -709,6 +711,12 @@ private fun MainNavigationRail(
selected = selectedTab == MainTab.Schedule,
onClick = { onTabSelected(MainTab.Schedule) },
)
NavRailLeafItem(
emoji = "🤝",
label = "Freundschaftsspiele",
selected = selectedTab == MainTab.FriendlyMatches,
onClick = { onTabSelected(MainTab.FriendlyMatches) },
)
}
}
@@ -918,6 +926,7 @@ private fun mainTabEmoji(tab: MainTab): String = when (tab) {
MainTab.Tournaments -> "🏆"
MainTab.OfficialParticipations -> "📋"
MainTab.Schedule -> "📅"
MainTab.FriendlyMatches -> "🤝"
MainTab.Settings -> "⚙️"
}
@@ -6922,6 +6931,7 @@ private fun tabTitle(tab: MainTab): String = when (tab) {
MainTab.Diary -> tr("navigation.diary", "Tagebuch")
MainTab.Members -> tr("navigation.members", "Mitglieder")
MainTab.Schedule -> tr("navigation.schedule", "Terminplan")
MainTab.FriendlyMatches -> "Freundschaftsspiele"
MainTab.Calendar -> tr("navigation.calendar", "Kalender")
MainTab.Tournaments -> tr("navigation.clubTournaments", "Turniere")
MainTab.OfficialParticipations -> tr("navigation.tournamentParticipations", "Turnierteilnahmen")

View File

@@ -33,6 +33,7 @@ import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -58,13 +59,14 @@ 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
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
private val SchedulePad = 20.dp
private val ScheduleTouchMin = 48.dp
@Composable
internal fun ScheduleScreen(dependencies: AppDependencies) {
internal fun ScheduleScreen(dependencies: AppDependencies, friendlyOnly: Boolean = false) {
val clubState by dependencies.clubManager.state.collectAsState()
val scheduleState by dependencies.scheduleManager.state.collectAsState()
val membersState by dependencies.membersManager.state.collectAsState()
@@ -80,6 +82,9 @@ internal fun ScheduleScreen(dependencies: AppDependencies) {
var otherTeamMenu by remember { mutableStateOf(false) }
var detailMatch by remember { mutableStateOf<ScheduleMatchDto?>(null) }
var playerMatch by remember { mutableStateOf<ScheduleMatchDto?>(null) }
var friendlyEditMatch by remember { mutableStateOf<ScheduleMatchDto?>(null) }
var showFriendlyCreate by remember { mutableStateOf(false) }
var friendlyResultMatch by remember { mutableStateOf<ScheduleMatchDto?>(null) }
var playerError by remember { mutableStateOf<String?>(null) }
var playerSaving by remember { mutableStateOf(false) }
@@ -96,7 +101,30 @@ internal fun ScheduleScreen(dependencies: AppDependencies) {
LaunchedEffect(clubId) {
dependencies.scheduleManager.clear()
dependencies.scheduleManager.loadClubTeams(clubId)
if (friendlyOnly) {
dependencies.scheduleManager.loadFriendlyMatches(clubId)
} else {
dependencies.scheduleManager.loadClubTeams(clubId)
}
}
DisposableEffect(clubId) {
dependencies.socketService.connect(clubId)
onDispose { dependencies.socketService.disconnect() }
}
LaunchedEffect(clubId, friendlyOnly) {
dependencies.socketService.events.collectLatest { (event, data) ->
if (event == "schedule:match:updated") {
val matchJson = data.optJSONObject("match")
val isFriendlyEvent = matchJson?.optBoolean("isFriendly", false) == true
if (friendlyOnly && (isFriendlyEvent || matchJson == null)) {
dependencies.scheduleManager.loadFriendlyMatches(clubId)
} else if (!friendlyOnly && !isFriendlyEvent) {
dependencies.scheduleManager.refresh(clubId)
}
}
}
}
if (permissions != null && !permissions.canReadSchedule()) {
@@ -122,9 +150,9 @@ internal fun ScheduleScreen(dependencies: AppDependencies) {
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
Text(tr("navigation.schedule", "Terminplan"), style = MaterialTheme.typography.h5, fontWeight = FontWeight.SemiBold)
Text(if (friendlyOnly) "Freundschaftsspiele" else tr("navigation.schedule", "Terminplan"), style = MaterialTheme.typography.h5, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.height(8.dp))
Row(
if (!friendlyOnly) Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
@@ -138,9 +166,14 @@ internal fun ScheduleScreen(dependencies: AppDependencies) {
) { Text(tr("schedule.adultSchedule", "Erwachsene"), maxLines = 2) }
}
Spacer(modifier = Modifier.height(8.dp))
if (!friendlyOnly) OutlinedButton(
onClick = { scope.launch { dependencies.scheduleManager.loadFriendlyMatches(clubId) } },
modifier = Modifier.fillMaxWidth().heightIn(min = ScheduleTouchMin),
) { Text("Freundschaftsspiele") }
Spacer(modifier = Modifier.height(8.dp))
}
item {
if (scheduleState.viewMode != ScheduleViewMode.Friendly) item {
Box {
OutlinedButton(
onClick = { teamMenu = true },
@@ -222,10 +255,18 @@ internal fun ScheduleScreen(dependencies: AppDependencies) {
item {
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = { scope.launch { dependencies.scheduleManager.refresh(clubId) } },
modifier = Modifier.fillMaxWidth().heightIn(min = ScheduleTouchMin),
) { Text(tr("mobile.refresh", "Aktualisieren")) }
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = { scope.launch { dependencies.scheduleManager.refresh(clubId) } },
modifier = Modifier.weight(1f).heightIn(min = ScheduleTouchMin),
) { Text(tr("mobile.refresh", "Aktualisieren")) }
if (scheduleState.viewMode == ScheduleViewMode.Friendly && permissions?.canWriteSchedule() == true) {
OutlinedButton(
onClick = { showFriendlyCreate = true },
modifier = Modifier.weight(1f).heightIn(min = ScheduleTouchMin),
) { Text("Neu") }
}
}
if (scheduleState.isLoading) {
Row(
@@ -356,14 +397,29 @@ internal fun ScheduleScreen(dependencies: AppDependencies) {
) { Text(tr("schedule.openMatchReport", "Bericht öffnen")) }
}
if (permissions?.canWriteSchedule() == true) {
TextButton(
onClick = {
playerError = null
playerMatch = m
detailMatch = null
},
) {
Text(tr("schedule.players", "Aufstellung / Spieler"))
if (m.isFriendly) {
TextButton(
onClick = {
friendlyResultMatch = m
detailMatch = null
},
) { Text("Ergebnis") }
TextButton(
onClick = {
friendlyEditMatch = m
detailMatch = null
},
) { Text("Bearbeiten") }
} else {
TextButton(
onClick = {
playerError = null
playerMatch = m
detailMatch = null
},
) {
Text(tr("schedule.players", "Aufstellung / Spieler"))
}
}
}
}
@@ -374,6 +430,59 @@ internal fun ScheduleScreen(dependencies: AppDependencies) {
)
}
if (showFriendlyCreate || friendlyEditMatch != null) {
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) } },
onDismiss = {
showFriendlyCreate = false
friendlyEditMatch = null
},
onSave = { body ->
scope.launch {
if (friendlyEditMatch != null) {
dependencies.scheduleManager.updateFriendlyMatch(clubId, friendlyEditMatch!!.id, body)
} else {
dependencies.scheduleManager.createFriendlyMatch(clubId, body)
}
showFriendlyCreate = false
friendlyEditMatch = null
}
},
onDelete = if (friendlyEditMatch != null) {
{
scope.launch {
dependencies.scheduleManager.deleteFriendlyMatch(clubId, friendlyEditMatch!!.id)
friendlyEditMatch = null
}
}
} else null,
)
}
friendlyResultMatch?.let { match ->
FriendlyResultDialog(
match = match,
memberOptions = membersState.members.filter { it.active }.map { FriendlyMemberOption(it.id, "${it.firstName} ${it.lastName}".trim()) },
onDismiss = { friendlyResultMatch = null },
onSave = { body ->
scope.launch {
dependencies.scheduleManager.updateFriendlyMatch(clubId, match.id, body)
val updated = body.toMatchLike(match)
friendlyResultMatch = updated
}
},
onComplete = { body ->
scope.launch {
dependencies.scheduleManager.updateFriendlyMatch(clubId, match.id, body.copy(isCompleted = true))
friendlyResultMatch = null
}
},
)
}
playerMatch?.let { m ->
LaunchedEffect(m.id, clubId) {
dependencies.membersManager.loadMembers(clubId)
@@ -458,6 +567,335 @@ internal fun ScheduleScreen(dependencies: AppDependencies) {
}
}
private data class FriendlyMemberOption(val id: Int, val name: String)
@Composable
private fun FriendlyMatchEditDialog(
match: ScheduleMatchDto?,
clubName: String,
memberOptions: List<FriendlyMemberOption>,
onLoadMembers: () -> Unit,
onDismiss: () -> Unit,
onSave: (FriendlyMatchSaveBody) -> Unit,
onDelete: (() -> Unit)?,
) {
LaunchedEffect(Unit) { onLoadMembers() }
var date by remember(match?.id) { mutableStateOf(match?.date?.take(10) ?: java.time.LocalDate.now().toString()) }
var time by remember(match?.id) { mutableStateOf(match?.time?.take(5) ?: "") }
var homeTeam by remember(match?.id) { mutableStateOf(match?.homeTeam?.name ?: clubName) }
var guestTeam by remember(match?.id) { mutableStateOf(match?.guestTeam?.name ?: "") }
var matchSystem by remember(match?.id) { mutableStateOf(match?.matchSystem ?: "Braunschweiger System") }
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 error by remember { mutableStateOf<String?>(null) }
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())
Spacer(Modifier.height(8.dp))
FriendlyParticipantEditor("Heim-Aufstellung", memberOptions, homeParticipants) { homeParticipants = it }
Spacer(Modifier.height(8.dp))
FriendlyParticipantEditor("Gast-Aufstellung", memberOptions, guestParticipants) { guestParticipants = it }
}
},
confirmButton = {
TextButton(
onClick = {
val winningSets = winningSetsText.toIntOrNull()?.takeIf { it > 0 } ?: 3
if (date.isBlank() || homeTeam.isBlank() || guestTeam.isBlank()) {
error = "Datum, Heimteam und Gastteam sind Pflichtfelder."
return@TextButton
}
onSave(
FriendlyMatchSaveBody(
date = date.trim(),
time = time.trim().ifBlank { null },
homeTeamName = homeTeam.trim(),
guestTeamName = guestTeam.trim(),
matchSystem = matchSystem.trim().ifBlank { "Braunschweiger System" },
winningSets = winningSets,
homeParticipants = homeParticipants,
guestParticipants = guestParticipants,
homeMatchPoints = match?.homeMatchPoints ?: 0,
guestMatchPoints = match?.guestMatchPoints ?: 0,
isCompleted = match?.isCompleted ?: false,
resultDetails = match?.resultDetails ?: emptyList(),
),
)
},
) { Text("Speichern") }
},
dismissButton = {
Row {
onDelete?.let {
TextButton(onClick = it) { Text("Löschen") }
}
TextButton(onClick = onDismiss) { Text("Abbrechen") }
}
},
)
}
@Composable
private fun FriendlyParticipantEditor(
title: String,
members: List<FriendlyMemberOption>,
participants: List<FriendlyParticipantDto>,
onChange: (List<FriendlyParticipantDto>) -> Unit,
) {
var menuOpen by remember { mutableStateOf(false) }
var manualName by remember { mutableStateOf("") }
Column {
Text(title, fontWeight = FontWeight.SemiBold)
Box(Modifier.fillMaxWidth()) {
OutlinedButton(onClick = { menuOpen = true }, modifier = Modifier.fillMaxWidth()) {
Text("Mitglied hinzufügen")
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
members.forEach { member ->
DropdownMenuItem(
onClick = {
menuOpen = false
if (participants.none { it.type == "member" && it.memberId == member.id }) {
onChange(participants + FriendlyParticipantDto(type = "member", memberId = member.id))
}
},
) { Text(member.name) }
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
manualName,
{ manualName = it },
label = { Text("Manueller Name") },
modifier = Modifier.weight(1f),
)
OutlinedButton(
onClick = {
val parts = manualName.trim().split(Regex("\\s+")).filter { it.isNotBlank() }
if (parts.isNotEmpty()) {
onChange(participants + FriendlyParticipantDto(type = "manual", firstName = parts.first(), lastName = parts.drop(1).joinToString(" ")))
manualName = ""
}
},
modifier = Modifier.heightIn(min = ScheduleTouchMin),
) { Text("+") }
}
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") }
}
}
}
}
@Composable
private fun FriendlyResultDialog(
match: ScheduleMatchDto,
memberOptions: List<FriendlyMemberOption>,
onDismiss: () -> Unit,
onSave: (FriendlyMatchSaveBody) -> Unit,
onComplete: (FriendlyMatchSaveBody) -> Unit,
) {
var rows by remember(match.id) { mutableStateOf(buildFriendlyResultRows(match, memberOptions)) }
var error by remember { mutableStateOf<String?>(null) }
val score = friendlyScore(rows, match.winningSets)
fun body(completed: Boolean = match.isCompleted): FriendlyMatchSaveBody =
FriendlyMatchSaveBody(
date = match.date?.take(10) ?: java.time.LocalDate.now().toString(),
time = match.time?.take(5),
homeTeamName = match.homeTeam?.name ?: "",
guestTeamName = match.guestTeam?.name ?: "",
matchSystem = match.matchSystem ?: "Braunschweiger System",
winningSets = match.winningSets,
homeParticipants = match.homeParticipants,
guestParticipants = match.guestParticipants,
homeMatchPoints = score.first,
guestMatchPoints = score.second,
isCompleted = completed,
resultDetails = rows,
)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Ergebniseingabe") },
text = {
Column(Modifier.verticalScroll(rememberScrollState())) {
Text("${match.homeTeam?.name ?: "?"} : ${match.guestTeam?.name ?: "?"}")
Text("Spielstand: ${score.first}:${score.second}", fontWeight = FontWeight.SemiBold)
error?.let { Text(it, color = MaterialTheme.colors.error) }
rows.forEachIndexed { index, row ->
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)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
val state = friendlyRowState(row, match.winningSets)
(0 until 5).forEach { setIndex ->
val disabled = state.second != null && setIndex > state.second!!
OutlinedTextField(
value = row.sets.getOrNull(setIndex).orEmpty(),
onValueChange = { value ->
val nextSets = row.sets.toMutableList().also {
while (it.size < 5) it.add("")
it[setIndex] = value
}
rows = rows.toMutableList().also { list ->
list[index] = row.copy(sets = nextSets)
}
},
label = { Text("${setIndex + 1}") },
enabled = !disabled,
modifier = Modifier.weight(1f),
singleLine = true,
)
}
}
OutlinedButton(
onClick = {
val normalized = normalizeFriendlyRow(row, match.winningSets)
if (normalized == null) {
error = "Bitte gültige Sätze eingeben."
} else {
error = null
val nextRows = rows.toMutableList().also { it[index] = normalized }
rows = nextRows
onSave(body(false).copy(resultDetails = nextRows, homeMatchPoints = friendlyScore(nextRows, match.winningSets).first, guestMatchPoints = friendlyScore(nextRows, match.winningSets).second))
}
},
modifier = Modifier.fillMaxWidth(),
) { Text(if (row.completed) "Gespeichert / abgeschlossen" else "Satz speichern") }
}
}
}
}
},
confirmButton = { TextButton(onClick = { onComplete(body(true)) }) { Text("Abschließen") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Schließen") } },
)
}
private fun FriendlyMatchSaveBody.toMatchLike(match: ScheduleMatchDto): ScheduleMatchDto =
match.copy(
homeMatchPoints = homeMatchPoints,
guestMatchPoints = guestMatchPoints,
isCompleted = isCompleted,
resultDetails = resultDetails,
)
private fun participantLabel(participant: FriendlyParticipantDto, members: List<FriendlyMemberOption>): String {
if (participant.type == "member") {
return members.find { it.id == participant.memberId }?.name ?: "Mitglied #${participant.memberId ?: 0}"
}
return listOf(participant.firstName, participant.lastName).filter { it.isNotBlank() }.joinToString(" ")
}
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() },
)
}
}
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 {
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)
val pair = pairs.getOrElse(index) { (index * 2) to (index * 2 + 1) }
val a = list[pair.first % list.size]
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) { "" }))
}
}
}
private fun normalizeFriendlySet(value: String): String? {
val raw = value.trim()
if (raw.isBlank()) return ""
if (":" in raw) {
val parts = raw.split(":")
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
return "$a:$b"
}
val losing = raw.removePrefix("-").toIntOrNull() ?: return null
val winning = if (losing < 10) 11 else losing + 2
return if (raw.startsWith("-")) "$losing:$winning" else "$winning:$losing"
}
private fun friendlyRowState(row: FriendlyResultRowDto, winningSets: Int): Pair<String?, Int?> {
var home = 0
var guest = 0
val required = winningSets.coerceAtLeast(1)
row.sets.forEachIndexed { index, set ->
val normalized = normalizeFriendlySet(set) ?: return@forEachIndexed
if (normalized.isBlank()) return@forEachIndexed
val parts = normalized.split(":").mapNotNull { it.toIntOrNull() }
if (parts.size == 2) {
if (parts[0] > parts[1]) home += 1 else guest += 1
if (home >= required || guest >= required) return (if (home > guest) "home" else "guest") to index
}
}
return null to null
}
private fun normalizeFriendlyRow(row: FriendlyResultRowDto, winningSets: Int): FriendlyResultRowDto? {
val normalized = row.sets.map { normalizeFriendlySet(it) ?: return null }.toMutableList()
while (normalized.size < 5) normalized.add("")
val temp = row.copy(sets = normalized)
val state = friendlyRowState(temp, winningSets)
if (state.first != null && state.second != null) {
for (i in (state.second!! + 1) until normalized.size) normalized[i] = ""
}
return row.copy(sets = normalized, completed = state.first != null)
}
private fun friendlyScore(rows: List<FriendlyResultRowDto>, winningSets: Int): Pair<Int, Int> {
var home = 0
var guest = 0
rows.forEach { row ->
when (friendlyRowState(row, winningSets).first) {
"home" -> home += 1
"guest" -> guest += 1
}
}
return home to guest
}
@Composable
private fun ScheduleScopeChip(label: String, selected: Boolean, onClick: () -> Unit) {
OutlinedButton(

View File

@@ -34,6 +34,11 @@ class SocketService(private val socketUrl: String) {
val data = args[0] as JSONObject
_events.tryEmit("diary:note:added" to data)
}
socket?.on("schedule:match:updated") { args ->
val data = args[0] as JSONObject
_events.tryEmit("schedule:match:updated" to data)
}
// Add more events as needed