feat: Implement friendly match management with socket integration and UI updates
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 48s
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 48s
This commit is contained in:
Binary file not shown.
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user