Completa multi-sport su web, API score_action e controlli Android board-aware.

Allinea i form società/squadra al catalogo sport, espone score_action per basket/timed e corregge bug namespace Sports e Result nel period engine; l'app nativa gestisce overlay e punteggio per board con wizard regole esteso.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-09 21:48:29 +02:00
parent c0ede48091
commit ad9d67ddb6
25 changed files with 513 additions and 116 deletions

View File

@@ -195,6 +195,7 @@ data class SetPartialDto(
)
data class ScoreStateDto(
@Json(name = "board_type") val boardType: String? = null,
@Json(name = "home_sets") val homeSets: Int? = null,
@Json(name = "away_sets") val awaySets: Int? = null,
@Json(name = "home_points") val homePoints: Int? = null,
@@ -203,6 +204,9 @@ data class ScoreStateDto(
@Json(name = "set_partials") val setPartials: List<SetPartialDto>? = null,
@Json(name = "timeout_home") val timeoutHome: Boolean? = null,
@Json(name = "timeout_away") val timeoutAway: Boolean? = null,
val period: String? = null,
@Json(name = "clock_secs") val clockSecs: Int? = null,
@Json(name = "clock_running") val clockRunning: Boolean? = null,
) {
fun toDomain() = com.matchlivetv.match_live_tv.domain.ScoreState(
homeSets = homeSets ?: 0,
@@ -215,9 +219,18 @@ data class ScoreStateDto(
},
timeoutHome = timeoutHome ?: false,
timeoutAway = timeoutAway ?: false,
boardType = boardType ?: "volley",
period = currentSet ?: 1,
periodLabel = period,
clockSecs = clockSecs ?: 0,
clockRunning = clockRunning ?: false,
)
}
data class ScoreActionRequest(
@Json(name = "score_action") val scoreAction: String,
)
data class ScoreSyncRequest(
@Json(name = "home_sets") val homeSets: Int,
@Json(name = "away_sets") val awaySets: Int,

View File

@@ -120,6 +120,12 @@ interface MatchLiveApi {
@Body body: ScoreSyncRequest,
): StreamSessionDto
@POST("sessions/{sessionId}/score_action")
suspend fun applyScoreAction(
@Path("sessionId") sessionId: String,
@Body body: ScoreActionRequest,
): StreamSessionDto
@POST("sessions/{sessionId}/telemetry")
suspend fun postTelemetry(
@Path("sessionId") sessionId: String,

View File

@@ -126,35 +126,7 @@ class SessionCableService(
return nested as? Map<String, Any?> ?: raw
}
private fun parseScore(data: Map<String, Any?>): ScoreState {
val partialsRaw = data["set_partials"] as? List<*> ?: data["setPartials"] as? List<*> ?: emptyList<Any>()
return ScoreState(
homeSets = intField(data, "home_sets", "homeSets"),
awaySets = intField(data, "away_sets", "awaySets"),
homePoints = intField(data, "home_points", "homePoints"),
awayPoints = intField(data, "away_points", "awayPoints"),
currentSet = intField(data, "current_set", "currentSet").coerceAtLeast(1),
setPartials = partialsRaw.mapNotNull { item ->
val row = item as? Map<*, *> ?: return@mapNotNull null
com.matchlivetv.match_live_tv.domain.SetPartial(
set = (row["set"] as? Number)?.toInt() ?: 1,
home = (row["home"] as? Number)?.toInt() ?: 0,
away = (row["away"] as? Number)?.toInt() ?: 0,
)
},
timeoutHome = data["timeout_home"] as? Boolean ?: false,
timeoutAway = data["timeout_away"] as? Boolean ?: false,
)
}
private fun intField(data: Map<String, Any?>, snake: String, camel: String): Int {
val raw = data[snake] ?: data[camel]
return when (raw) {
is Number -> raw.toInt()
is String -> raw.toIntOrNull() ?: 0
else -> 0
}
}
private fun parseScore(data: Map<String, Any?>): ScoreState = ScoreState.fromCablePayload(data)
companion object {
private const val TAG = "SessionCableService"

View File

@@ -1,6 +1,7 @@
package com.matchlivetv.match_live_tv.data.repository
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
import com.matchlivetv.match_live_tv.data.api.ScoreActionRequest
import com.matchlivetv.match_live_tv.data.api.ScoreSyncRequest
import com.matchlivetv.match_live_tv.data.api.SetPartialDto
import com.matchlivetv.match_live_tv.domain.ScoreState
@@ -24,4 +25,9 @@ class ScoreRepository(
)
return response.score?.toDomain()
}
suspend fun applyScoreAction(sessionId: String, action: String): ScoreState? {
val response = api.applyScoreAction(sessionId, ScoreActionRequest(action))
return response.score?.toDomain()
}
}

View File

@@ -74,6 +74,27 @@ class ScoreController(
schedulePush()
}
fun applyAction(action: String) {
scheduleAction(action)
}
private fun scheduleAction(action: String) {
syncJob?.cancel()
syncJob = scope.launch {
syncMutex.withLock {
val id = sessionId ?: return@withLock
runCatching { scoreRepository.applyScoreAction(id, action) }
.onSuccess { remote ->
suppressRemoteUntilMs = System.currentTimeMillis() + 500
if (remote != null) {
_score.value = remote
sessionCable.sendScoreUpdate(remote)
}
}
}
}
}
fun closeSet() {
val current = _score.value
val homeWon = current.homePoints > current.awayPoints

View File

@@ -15,23 +15,107 @@ data class ScoreState(
val setPartials: List<SetPartial> = emptyList(),
val timeoutHome: Boolean = false,
val timeoutAway: Boolean = false,
val boardType: String = "volley",
val period: Int = 1,
val periodLabel: String? = null,
val clockSecs: Int = 0,
val clockRunning: Boolean = false,
val overtime: Boolean = false,
) {
fun cablePayload(): Map<String, Any?> = mapOf(
"type" to "score_update",
"home_sets" to homeSets,
"away_sets" to awaySets,
"home_points" to homePoints,
"away_points" to awayPoints,
"current_set" to currentSet,
"set_partials" to setPartials.map {
mapOf("set" to it.set, "home" to it.home, "away" to it.away)
},
)
fun cablePayload(): Map<String, Any?> {
val payload = mutableMapOf<String, Any?>(
"type" to "score_update",
"board_type" to boardType,
"home_sets" to homeSets,
"away_sets" to awaySets,
"home_points" to homePoints,
"away_points" to awayPoints,
"current_set" to currentSet,
"set_partials" to setPartials.map {
mapOf("set" to it.set, "home" to it.home, "away" to it.away)
},
"timeout_home" to timeoutHome,
"timeout_away" to timeoutAway,
)
if (boardType in PERIOD_BOARDS) {
payload["period"] = periodLabel ?: period.toString()
payload["clock_secs"] = clockSecs
payload["clock_running"] = clockRunning
payload["home_score"] = homePoints
payload["away_score"] = awayPoints
payload["overtime"] = overtime
}
if (boardType == "timer") {
payload["clock_secs"] = clockSecs
payload["clock_running"] = clockRunning
}
return payload
}
fun progressKey(): Long =
currentSet * 10_000_000_000L +
homeSets * 1_000_000L +
awaySets * 100_000L +
homePoints * 1_000L +
awayPoints
awayPoints +
clockSecs
companion object {
private val PERIOD_BOARDS = setOf("basket", "timed")
fun fromCablePayload(data: Map<String, Any?>): ScoreState {
val boardType = data["board_type"] as? String ?: "volley"
@Suppress("UNCHECKED_CAST")
val nested = data["data"] as? Map<String, Any?>
val partialsRaw = data["set_partials"] as? List<*> ?: data["setPartials"] as? List<*> ?: emptyList<Any>()
val periodNumber = intFrom(nested, "period")
.takeIf { it > 0 }
?: intField(data, "current_set", "currentSet").coerceAtLeast(1)
return ScoreState(
homeSets = intField(data, "home_sets", "homeSets"),
awaySets = intField(data, "away_sets", "awaySets"),
homePoints = intField(data, "home_points", "homePoints"),
awayPoints = intField(data, "away_points", "awayPoints"),
currentSet = intField(data, "current_set", "currentSet").coerceAtLeast(1),
setPartials = partialsRaw.mapNotNull { item ->
val row = item as? Map<*, *> ?: return@mapNotNull null
SetPartial(
set = (row["set"] as? Number)?.toInt() ?: 1,
home = (row["home"] as? Number)?.toInt() ?: 0,
away = (row["away"] as? Number)?.toInt() ?: 0,
)
},
timeoutHome = data["timeout_home"] as? Boolean ?: false,
timeoutAway = data["timeout_away"] as? Boolean ?: false,
boardType = boardType,
period = periodNumber,
periodLabel = data["period"] as? String,
clockSecs = intField(data, "clock_secs", "clockSecs")
.takeIf { it > 0 || data.containsKey("clock_secs") || data.containsKey("clockSecs") }
?: intFrom(nested, "clock_secs"),
clockRunning = data["clock_running"] as? Boolean
?: nested?.get("clock_running") as? Boolean
?: false,
overtime = nested?.get("overtime") as? Boolean ?: false,
)
}
private fun intField(data: Map<String, Any?>, snake: String, camel: String): Int {
val raw = data[snake] ?: data[camel]
return when (raw) {
is Number -> raw.toInt()
is String -> raw.toIntOrNull() ?: 0
else -> 0
}
}
private fun intFrom(data: Map<String, Any?>?, key: String): Int {
if (data == null) return 0
return when (val raw = data[key]) {
is Number -> raw.toInt()
is String -> raw.toIntOrNull() ?: 0
else -> 0
}
}
}
}

View File

@@ -27,6 +27,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.Timer
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.Visibility
@@ -63,6 +65,7 @@ import com.matchlivetv.match_live_tv.core.resolveMediaUrl
import com.matchlivetv.match_live_tv.core.DeviceHealthSnapshot
import com.matchlivetv.match_live_tv.core.ThermalLevel
import com.matchlivetv.match_live_tv.domain.ScoreState
import com.matchlivetv.match_live_tv.streaming.overlay.formatOverlayClock
import com.matchlivetv.match_live_tv.ui.components.MatchStatusBadge
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
@@ -85,12 +88,19 @@ fun BroadcastControlsOverlay(
homeLogoUrl: String?,
awayLogoUrl: String?,
score: ScoreState,
boardType: String,
pointsTarget: Int,
onPointHome: () -> Unit,
onPointAway: () -> Unit,
onMinusHome: () -> Unit,
onMinusAway: () -> Unit,
onCloseSet: () -> Unit,
onPoint2Home: (() -> Unit)? = null,
onPoint3Home: (() -> Unit)? = null,
onPoint2Away: (() -> Unit)? = null,
onPoint3Away: (() -> Unit)? = null,
onCloseSet: (() -> Unit)? = null,
onAdvancePeriod: (() -> Unit)? = null,
onClockToggle: (() -> Unit)? = null,
onPauseOrResume: () -> Unit,
onTerminate: () -> Unit,
onShareLive: () -> Unit,
@@ -181,11 +191,28 @@ fun BroadcastControlsOverlay(
contentDescription = "Condividi link regia",
onClick = onShareRegia,
)
SideIconButton(
icon = Icons.Default.Check,
contentDescription = "Chiudi set",
onClick = onCloseSet,
)
if (onCloseSet != null) {
SideIconButton(
icon = Icons.Default.Check,
contentDescription = "Chiudi set",
onClick = onCloseSet,
)
}
if (onAdvancePeriod != null) {
SideIconButton(
icon = Icons.Default.SkipNext,
contentDescription = "Periodo successivo",
onClick = onAdvancePeriod,
)
}
if (onClockToggle != null) {
SideIconButton(
icon = Icons.Default.Timer,
contentDescription = if (score.clockRunning) "Ferma cronometro" else "Avvia cronometro",
onClick = onClockToggle,
highlighted = score.clockRunning,
)
}
}
Column(
@@ -223,34 +250,17 @@ fun BroadcastControlsOverlay(
points = score.homePoints,
onPlus = onPointHome,
onMinus = onMinusHome,
onPlus2 = onPoint2Home,
onPlus3 = onPoint3Home,
showBasketButtons = boardType == "basket",
alignEnd = false,
modifier = Modifier.weight(1f),
)
Column(
Modifier
.padding(horizontal = 12.dp)
.padding(bottom = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"${score.homePoints} - ${score.awayPoints}",
style = MaterialTheme.typography.titleLarge,
color = Color.White,
fontWeight = FontWeight.Bold,
)
Text(
"Set ${score.currentSet} · $pointsTarget pt",
style = MaterialTheme.typography.labelSmall,
color = MatchColors.TextSecondary,
)
if (score.homeSets > 0 || score.awaySets > 0) {
Text(
"Set vinti ${score.homeSets}-${score.awaySets}",
style = MaterialTheme.typography.labelSmall,
color = MatchColors.TextSecondary,
)
}
}
ScoreCenterPanel(
score = score,
boardType = boardType,
pointsTarget = pointsTarget,
)
TeamScoreColumn(
teamLabel = "OSPITE",
teamName = awayName,
@@ -259,6 +269,9 @@ fun BroadcastControlsOverlay(
points = score.awayPoints,
onPlus = onPointAway,
onMinus = onMinusAway,
onPlus2 = onPoint2Away,
onPlus3 = onPoint3Away,
showBasketButtons = boardType == "basket",
alignEnd = true,
modifier = Modifier.weight(1f),
)
@@ -365,6 +378,63 @@ private fun formatBitrateKbps(kbps: Long): String {
return "$kbps kbps"
}
@Composable
private fun ScoreCenterPanel(
score: ScoreState,
boardType: String,
pointsTarget: Int,
) {
Column(
Modifier
.padding(horizontal = 12.dp)
.padding(bottom = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"${score.homePoints} - ${score.awayPoints}",
style = MaterialTheme.typography.titleLarge,
color = Color.White,
fontWeight = FontWeight.Bold,
)
when (boardType) {
"basket", "timed" -> {
Text(
score.periodLabel ?: if (boardType == "basket") "Q${score.period}" else "${score.period}° tempo",
style = MaterialTheme.typography.labelSmall,
color = MatchColors.TextSecondary,
)
Text(
formatOverlayClock(score.clockSecs),
style = MaterialTheme.typography.labelMedium,
color = if (score.clockRunning) MatchColors.SuccessGreen else MatchColors.TextSecondary,
)
}
"timer" -> {
Text(
formatOverlayClock(score.clockSecs, countUp = true),
style = MaterialTheme.typography.labelMedium,
color = if (score.clockRunning) MatchColors.SuccessGreen else MatchColors.TextSecondary,
)
}
"generic" -> Unit
else -> {
Text(
"Set ${score.currentSet} · $pointsTarget pt",
style = MaterialTheme.typography.labelSmall,
color = MatchColors.TextSecondary,
)
if (score.homeSets > 0 || score.awaySets > 0) {
Text(
"Set vinti ${score.homeSets}-${score.awaySets}",
style = MaterialTheme.typography.labelSmall,
color = MatchColors.TextSecondary,
)
}
}
}
}
}
@Composable
private fun TeamScoreColumn(
teamLabel: String,
@@ -374,6 +444,9 @@ private fun TeamScoreColumn(
points: Int,
onPlus: () -> Unit,
onMinus: () -> Unit,
onPlus2: (() -> Unit)? = null,
onPlus3: (() -> Unit)? = null,
showBasketButtons: Boolean = false,
alignEnd: Boolean,
modifier: Modifier = Modifier,
) {
@@ -407,11 +480,19 @@ private fun TeamScoreColumn(
) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
if (alignEnd) {
if (showBasketButtons) {
onPlus3?.let {
ScoreIconButton(label = "+3", tooltip = "+3 $teamSide", onClick = it, primary = true)
}
onPlus2?.let {
ScoreIconButton(label = "+2", tooltip = "+2 $teamSide", onClick = it, primary = true)
}
}
ScoreIconButton(
label = "+1",
tooltip = "Aggiungi punto $teamSide",
onClick = onPlus,
primary = true,
primary = !showBasketButtons,
)
ScoreIconButton(label = "", tooltip = "Togli punto $teamSide", onClick = onMinus)
} else {
@@ -420,8 +501,16 @@ private fun TeamScoreColumn(
label = "+1",
tooltip = "Aggiungi punto $teamSide",
onClick = onPlus,
primary = true,
primary = !showBasketButtons,
)
if (showBasketButtons) {
onPlus2?.let {
ScoreIconButton(label = "+2", tooltip = "+2 $teamSide", onClick = it, primary = true)
}
onPlus3?.let {
ScoreIconButton(label = "+3", tooltip = "+3 $teamSide", onClick = it, primary = true)
}
}
}
}
}

View File

@@ -233,7 +233,10 @@ fun BroadcastScreen(
)
OverlayKind.TIMER -> OverlayState(
overlayKind = overlayKind,
timerText = formatOverlayClock(score.homePoints, countUp = true),
timerText = formatOverlayClock(
if (score.boardType == "timer") score.clockSecs else score.homePoints,
countUp = true,
),
watermarkVisible = true,
broadcastStatus = metrics.phase.toOverlayStatus(),
)
@@ -244,7 +247,11 @@ fun BroadcastScreen(
awayTeamName = currentMatch.opponentName,
homeScore = score.homePoints,
awayScore = score.awayPoints,
periodLabel = "${score.currentSet}°",
periodLabel = score.periodLabel ?: when (overlayKind) {
OverlayKind.BASKET -> "Q${score.period}"
else -> "${score.period}°"
},
clockText = formatOverlayClock(score.clockSecs),
homeAccentColor = homeColor,
awayAccentColor = awayColor,
),
@@ -286,6 +293,16 @@ fun BroadcastScreen(
}
}
LaunchedEffect(sessionId) {
while (true) {
delay(1_000)
val current = container.scoreController.score.value
if (current.clockRunning && current.boardType in setOf("basket", "timed", "timer")) {
container.scoreController.applyAction("clock_tick")
}
}
}
LaunchedEffect(sessionId) {
while (true) {
delay(4_000)
@@ -346,6 +363,12 @@ fun BroadcastScreen(
val pointsTarget = scoringRules?.pointsTarget(score.currentSet) ?: 25
val currentSession = session
val currentMatch = match
val boardType = currentMatch?.boardType?.ifBlank { score.boardType } ?: score.boardType
val usesActionScoring = boardType in setOf("basket", "timed", "timer")
fun applyScoreAction(action: String) {
container.scoreController.applyAction(action)
}
val shareUrl = currentSession?.watchShareUrl()
val shareSubject = currentMatch?.let { "${it.teamName} vs ${it.opponentName}" } ?: "Diretta Match Live TV"
@@ -419,29 +442,76 @@ fun BroadcastScreen(
homeLogoUrl = currentMatch.homeLogoUrl,
awayLogoUrl = currentMatch.opponentLogoUrl,
score = score,
boardType = boardType,
pointsTarget = pointsTarget,
onPointHome = {
scope.launch {
container.scoreController.incrementHome()
scoreActions.afterPointChange(container.scoreController.score.value) {
stopStreamPermanently()
if (usesActionScoring) {
applyScoreAction("home_point")
} else {
container.scoreController.incrementHome()
scoreActions.afterPointChange(container.scoreController.score.value) {
stopStreamPermanently()
}
}
}
},
onPointAway = {
scope.launch {
container.scoreController.incrementAway()
scoreActions.afterPointChange(container.scoreController.score.value) {
stopStreamPermanently()
if (usesActionScoring) {
applyScoreAction("away_point")
} else {
container.scoreController.incrementAway()
scoreActions.afterPointChange(container.scoreController.score.value) {
stopStreamPermanently()
}
}
}
},
onMinusHome = { container.scoreController.decrementHome() },
onMinusAway = { container.scoreController.decrementAway() },
onCloseSet = {
scope.launch {
scoreActions.requestCloseSet { stopStreamPermanently() }
onMinusHome = {
if (usesActionScoring) applyScoreAction("home_undo") else container.scoreController.decrementHome()
},
onMinusAway = {
if (usesActionScoring) applyScoreAction("away_undo") else container.scoreController.decrementAway()
},
onPoint2Home = if (boardType == "basket") {
{ applyScoreAction("home_point_2") }
} else {
null
},
onPoint3Home = if (boardType == "basket") {
{ applyScoreAction("home_point_3") }
} else {
null
},
onPoint2Away = if (boardType == "basket") {
{ applyScoreAction("away_point_2") }
} else {
null
},
onPoint3Away = if (boardType == "basket") {
{ applyScoreAction("away_point_3") }
} else {
null
},
onCloseSet = if (boardType in setOf("volley", "racket")) {
{
scope.launch {
scoreActions.requestCloseSet { stopStreamPermanently() }
}
}
} else {
null
},
onAdvancePeriod = if (boardType in setOf("basket", "timed")) {
{ applyScoreAction("advance_period") }
} else {
null
},
onClockToggle = if (boardType in setOf("basket", "timed", "timer")) {
{ applyScoreAction("clock_toggle") }
} else {
null
},
onPauseOrResume = {
scope.launch {

View File

@@ -98,10 +98,15 @@ class LiveScoreActions(
private val scoreController: ScoreController,
private val dialogHost: LiveScoreDialogHost,
) {
private val usesSetLogic: Boolean
get() = rules.boardType in setOf("volley", "racket")
suspend fun afterPointChange(
score: ScoreState,
onStopStream: suspend () -> Unit = {},
) {
if (!usesSetLogic) return
val winner = rules.setWinnerFromPoints(
homePoints = score.homePoints,
awayPoints = score.awayPoints,
@@ -123,6 +128,8 @@ class LiveScoreActions(
}
suspend fun requestCloseSet(onStopStream: suspend () -> Unit) {
if (!usesSetLogic) return
val score = scoreController.score.value
val winner = rules.setWinnerFromPoints(
homePoints = score.homePoints,

View File

@@ -45,15 +45,27 @@ private const val DEFAULT_SETS_TO_WIN = 3
private const val DEFAULT_POINTS_PER_SET = 25
private const val DEFAULT_POINTS_DECIDING_SET = 15
private const val DEFAULT_MIN_POINT_LEAD = 2
private const val DEFAULT_PERIODS = 4
private const val DEFAULT_PERIOD_DURATION_SECS = 600
private const val DEFAULT_OVERTIME_DURATION_SECS = 300
private const val DEFAULT_OPPONENT_COLOR = "#1E3A8A"
/** True solo se la partita ha regole non standard (non i default FIPAV). */
private fun matchHasCustomScoring(match: Match): Boolean {
if (match.setsToWin != DEFAULT_SETS_TO_WIN) return true
/** True se la partita ha regole non standard rispetto ai default del board. */
private fun matchHasCustomScoring(match: Match, boardType: String): Boolean {
val rules = match.scoringRules ?: return false
return rules.pointsPerSet != DEFAULT_POINTS_PER_SET ||
rules.pointsDecidingSet != DEFAULT_POINTS_DECIDING_SET ||
rules.minPointLead != DEFAULT_MIN_POINT_LEAD
return when (boardType) {
"basket", "timed" -> {
rules.periods != DEFAULT_PERIODS ||
rules.periodDurationSecs != DEFAULT_PERIOD_DURATION_SECS ||
rules.overtimeDurationSecs != DEFAULT_OVERTIME_DURATION_SECS
}
else -> {
if (match.setsToWin != DEFAULT_SETS_TO_WIN) return true
rules.pointsPerSet != DEFAULT_POINTS_PER_SET ||
rules.pointsDecidingSet != DEFAULT_POINTS_DECIDING_SET ||
rules.minPointLead != DEFAULT_MIN_POINT_LEAD
}
}
}
@Composable
@@ -102,7 +114,7 @@ fun StepMatchScreen(
) { uri -> opponentLogoUri = uri }
val existingRules = match.scoringRules
var customRules by remember { mutableStateOf(matchHasCustomScoring(match)) }
var customRules by remember { mutableStateOf(false) }
var setsToWin by remember { mutableIntStateOf(match.setsToWin.coerceIn(2, 3)) }
var pointsPerSet by remember {
mutableIntStateOf(existingRules?.pointsPerSet ?: DEFAULT_POINTS_PER_SET)
@@ -112,6 +124,15 @@ fun StepMatchScreen(
}
var pointsPerSetText by remember { mutableStateOf(pointsPerSet.toString()) }
var pointsDecidingSetText by remember { mutableStateOf(pointsDecidingSet.toString()) }
var periods by remember { mutableIntStateOf(existingRules?.periods ?: DEFAULT_PERIODS) }
var periodDurationMins by remember {
mutableIntStateOf((existingRules?.periodDurationSecs ?: DEFAULT_PERIOD_DURATION_SECS) / 60)
}
var overtimeDurationMins by remember {
mutableIntStateOf((existingRules?.overtimeDurationSecs ?: DEFAULT_OVERTIME_DURATION_SECS) / 60)
}
var periodDurationText by remember { mutableStateOf(periodDurationMins.toString()) }
var overtimeDurationText by remember { mutableStateOf(overtimeDurationMins.toString()) }
var saving by remember { mutableStateOf(false) }
var sports by remember { mutableStateOf<List<SportOption>>(emptyList()) }
var selectedSportKey by remember { mutableStateOf(match.sportKey) }
@@ -127,6 +148,10 @@ fun StepMatchScreen(
val allowedOverlays = currentSport?.allowedOverlays.orEmpty()
val boardType = currentSport?.board ?: homeTeam?.boardType ?: match.boardType
LaunchedEffect(boardType, match.id) {
customRules = matchHasCustomScoring(match, boardType)
}
Column(
Modifier
.verticalScroll(rememberScrollState())
@@ -227,10 +252,15 @@ fun StepMatchScreen(
Column(Modifier.weight(1f)) {
Text("Regole punteggio personalizzate")
Text(
if (customRules) {
"Torneo non standard: imposta set e punteggi."
} else {
"Standard FIPAV: 3 set per vincere, set a 25, tie-break a 15."
when {
!customRules && boardType in setOf("basket", "timed") ->
"Regole standard dello sport selezionato."
customRules && boardType in setOf("basket", "timed") ->
"Torneo non standard: tempi e periodi personalizzati."
customRules ->
"Torneo non standard: imposta set e punteggi."
else ->
"Standard FIPAV: 3 set per vincere, set a 25, tie-break a 15."
},
style = MaterialTheme.typography.bodyMedium,
color = MatchColors.TextSecondary,
@@ -239,6 +269,51 @@ fun StepMatchScreen(
Switch(checked = customRules, onCheckedChange = { customRules = it })
}
if (customRules && boardType in setOf("basket", "timed")) {
Spacer(Modifier.height(16.dp))
Text(
if (boardType == "basket") "Quarti" else "Tempi",
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf(2, 4).forEach { value ->
val selected = periods == value
if (selected) {
MatchPrimaryButton(
label = value.toString(),
onClick = { periods = value },
modifier = Modifier.weight(1f),
)
} else {
MatchSecondaryButton(
label = value.toString(),
onClick = { periods = value },
modifier = Modifier.weight(1f),
)
}
}
}
Spacer(Modifier.height(12.dp))
MatchOutlinedField(
value = periodDurationText,
onValueChange = { text ->
periodDurationText = text.filter { it.isDigit() }.take(3)
periodDurationText.toIntOrNull()?.let { periodDurationMins = it.coerceIn(1, 120) }
},
label = if (boardType == "basket") "Minuti per quarto" else "Minuti per tempo",
)
Spacer(Modifier.height(12.dp))
MatchOutlinedField(
value = overtimeDurationText,
onValueChange = { text ->
overtimeDurationText = text.filter { it.isDigit() }.take(3)
overtimeDurationText.toIntOrNull()?.let { overtimeDurationMins = it.coerceIn(1, 60) }
},
label = "Minuti supplementari",
)
}
if (customRules && boardType in setOf("volley", "racket")) {
Spacer(Modifier.height(16.dp))
Text("Set da vincere la partita", style = MaterialTheme.typography.bodyMedium)
@@ -290,7 +365,7 @@ fun StepMatchScreen(
onError("Inserisci il nome avversario")
return@MatchPrimaryButton
}
if (customRules) {
if (customRules && boardType in setOf("volley", "racket")) {
val perSet = pointsPerSetText.toIntOrNull()
val deciding = pointsDecidingSetText.toIntOrNull()
if (perSet == null || perSet < 1) {
@@ -304,17 +379,39 @@ fun StepMatchScreen(
pointsPerSet = perSet
pointsDecidingSet = deciding
}
if (customRules && boardType in setOf("basket", "timed")) {
val periodMins = periodDurationText.toIntOrNull()
val overtimeMins = overtimeDurationText.toIntOrNull()
if (periodMins == null || periodMins < 1) {
onError("Inserisci la durata del periodo in minuti")
return@MatchPrimaryButton
}
if (overtimeMins == null || overtimeMins < 1) {
onError("Inserisci la durata dei supplementari in minuti")
return@MatchPrimaryButton
}
periodDurationMins = periodMins
overtimeDurationMins = overtimeMins
}
val resolvedSets = if (customRules) setsToWin else DEFAULT_SETS_TO_WIN
val resolvedRules: ScoringRulesBody? = if (customRules && boardType in setOf("volley", "racket")) {
ScoringRulesBody(
val resolvedSets = if (customRules && boardType in setOf("volley", "racket")) {
setsToWin
} else {
DEFAULT_SETS_TO_WIN
}
val resolvedRules: ScoringRulesBody? = when {
customRules && boardType in setOf("volley", "racket") -> ScoringRulesBody(
setsToWin = resolvedSets,
pointsPerSet = pointsPerSet,
pointsDecidingSet = pointsDecidingSet,
minPointLead = existingRules?.minPointLead ?: DEFAULT_MIN_POINT_LEAD,
)
} else {
null
customRules && boardType in setOf("basket", "timed") -> ScoringRulesBody(
periods = periods,
periodDurationSecs = periodDurationMins * 60,
overtimeDurationSecs = overtimeDurationMins * 60,
)
else -> null
}
val initialHomeColor = normalizeHexColor(