diff --git a/mobile-app/composeApp/release/composeApp-release.aab b/mobile-app/composeApp/release/composeApp-release.aab index 9c016444..5044ef8d 100644 Binary files a/mobile-app/composeApp/release/composeApp-release.aab and b/mobile-app/composeApp/release/composeApp-release.aab differ diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/AppDependencies.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/AppDependencies.kt index 94241bda..1dc40a03 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/AppDependencies.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/AppDependencies.kt @@ -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( diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/AppRoot.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/AppRoot.kt index 09de3e29..09b8d00a 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/AppRoot.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/AppRoot.kt @@ -169,7 +169,7 @@ private fun visibleMainTabs(perms: UserClubPermissions?): List = 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") diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/ScheduleScreen.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/ScheduleScreen.kt index 322b55be..6d9f817c 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/ScheduleScreen.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/ScheduleScreen.kt @@ -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(null) } var playerMatch by remember { mutableStateOf(null) } + var friendlyEditMatch by remember { mutableStateOf(null) } + var showFriendlyCreate by remember { mutableStateOf(false) } + var friendlyResultMatch by remember { mutableStateOf(null) } var playerError by remember { mutableStateOf(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, + 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(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, + participants: List, + onChange: (List) -> 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, + onDismiss: () -> Unit, + onSave: (FriendlyMatchSaveBody) -> Unit, + onComplete: (FriendlyMatchSaveBody) -> Unit, +) { + var rows by remember(match.id) { mutableStateOf(buildFriendlyResultRows(match, memberOptions)) } + var error by remember { mutableStateOf(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): 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): List { + 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): List { + 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, index: Int): String = list.getOrNull(index % kotlin.math.max(list.size, 1)).orEmpty() + fun double(list: List, 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 { + 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, winningSets: Int): Pair { + 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( diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/SocketService.kt b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/SocketService.kt index 6fde3192..0ba17b15 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/SocketService.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/SocketService.kt @@ -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