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

@@ -2,7 +2,7 @@ module Api
module V1 module V1
class SportsController < BaseController class SportsController < BaseController
def index def index
render json: Sports::Catalog.as_api_list render json: ::Sports::Catalog.as_api_list
end end
end end
end end

View File

@@ -39,6 +39,20 @@ module Api
render json: session_json(@session, detail: true) render json: session_json(@session, detail: true)
end end
def score_action
action = params[:score_action].presence
raise ArgumentError, "Azione mancante" if action.blank?
result = Scoring::ApplyAction.new(session: @session, action: action).call
render json: session_json(@session, detail: true).merge(
set_won: result.set_won,
match_won: result.match_won,
winner: result.winner&.to_s
)
rescue ArgumentError => e
render json: { error: e.message }, status: :unprocessable_entity
end
def events def events
events = @session.stream_events.recent.limit(100) events = @session.stream_events.recent.limit(100)
render json: events.map { |e| event_json(e) } render json: events.map { |e| event_json(e) }

View File

@@ -21,7 +21,7 @@ module Api
}, status: :unprocessable_entity }, status: :unprocessable_entity
end end
sport_key = Sports::Catalog.normalize_key(team_params[:sport_key] || team_params[:sport] || "pallavolo") sport_key = ::Sports::Catalog.normalize_key(team_params[:sport_key] || team_params[:sport] || "pallavolo")
club = Club.create!(name: team_params[:name], sport: sport_key, club = Club.create!(name: team_params[:name], sport: sport_key,
primary_color: "#e53935", secondary_color: "#ffffff") primary_color: "#e53935", secondary_color: "#ffffff")
ClubMembership.create!(user: current_user, club: club, role: "owner") ClubMembership.create!(user: current_user, club: club, role: "owner")

View File

@@ -190,6 +190,7 @@ module Public
) )
p[:primary_color] = normalize_hex_color(p[:primary_color], "#e53935") p[:primary_color] = normalize_hex_color(p[:primary_color], "#e53935")
p[:secondary_color] = normalize_hex_color(p[:secondary_color], "#ffffff") p[:secondary_color] = normalize_hex_color(p[:secondary_color], "#ffffff")
p[:sport] = Sports::Catalog.normalize_key(p[:sport]) if p[:sport].present?
p p
end end

View File

@@ -165,7 +165,12 @@ module Public
end end
def team_params def team_params
p = params.require(:team).permit(:name, :sport, :description, :logo_url, :primary_color, :secondary_color) p = params.require(:team).permit(:name, :sport, :sport_key, :description, :logo_url, :primary_color, :secondary_color)
if p[:sport].present? && p[:sport_key].blank?
p[:sport_key] = p.delete(:sport)
elsif p[:sport_key].present?
p.delete(:sport)
end
p[:primary_color] = p[:primary_color].presence p[:primary_color] = p[:primary_color].presence
p[:secondary_color] = p[:secondary_color].presence p[:secondary_color] = p[:secondary_color].presence
if p[:primary_color].present? if p[:primary_color].present?

View File

@@ -16,4 +16,9 @@ module ApplicationHelper
value = date_or_time.respond_to?(:in_time_zone) ? date_or_time.in_time_zone : date_or_time value = date_or_time.respond_to?(:in_time_zone) ? date_or_time.in_time_zone : date_or_time
I18n.with_locale(:it) { I18n.l(value, format: format) } I18n.with_locale(:it) { I18n.l(value, format: format) }
end end
def sport_catalog_options(selected = nil)
options = Sports::Catalog.as_api_list.map { |entry| [entry[:label], entry[:key]] }
options_for_select(options, Sports::Catalog.normalize_key(selected))
end
end end

View File

@@ -1,6 +1,8 @@
module Scoring module Scoring
module Engines module Engines
module PeriodEngineMixin module PeriodEngineMixin
Result = Scoring::ApplyAction::Result
private private
def period_rules def period_rules

View File

@@ -8,7 +8,7 @@
<%= label_tag "club[name]", "Nome società" %> <%= label_tag "club[name]", "Nome società" %>
<%= text_field_tag "club[name]", @club.name, required: true %> <%= text_field_tag "club[name]", @club.name, required: true %>
<%= label_tag "club[sport]", "Sport principale" %> <%= label_tag "club[sport]", "Sport principale" %>
<%= select_tag "club[sport]", options_for_select([["Pallavolo", "volleyball"], ["Calcio", "football"], ["Basket", "basketball"]], @club.sport) %> <%= select_tag "club[sport]", sport_catalog_options(@club.sport) %>
<%= render "shared/branding_fields", record: @club, legend: "Logo e colori societari" %> <%= render "shared/branding_fields", record: @club, legend: "Logo e colori societari" %>
<%= render "shared/billing_profile_fields", record: @club %> <%= render "shared/billing_profile_fields", record: @club %>
<%= submit_tag "Salva", class: "btn btn-primary" %> <%= submit_tag "Salva", class: "btn btn-primary" %>

View File

@@ -11,7 +11,7 @@
<%= label_tag "club[name]", "Nome società / club" %> <%= label_tag "club[name]", "Nome società / club" %>
<%= text_field_tag "club[name]", params.dig(:club, :name), required: true, placeholder: "es. Crazy Volley Rozzano" %> <%= text_field_tag "club[name]", params.dig(:club, :name), required: true, placeholder: "es. Crazy Volley Rozzano" %>
<%= label_tag "club[sport]", "Sport principale" %> <%= label_tag "club[sport]", "Sport principale" %>
<%= select_tag "club[sport]", options_for_select([["Pallavolo", "volleyball"], ["Calcio", "football"], ["Basket", "basketball"]], params.dig(:club, :sport) || "volleyball") %> <%= select_tag "club[sport]", sport_catalog_options(params.dig(:club, :sport) || "pallavolo") %>
<%= render "shared/branding_fields", record: Club.new(primary_color: "#e53935", secondary_color: "#ffffff"), legend: "Logo e colori societari" %> <%= render "shared/branding_fields", record: Club.new(primary_color: "#e53935", secondary_color: "#ffffff"), legend: "Logo e colori societari" %>
<%= render "shared/billing_profile_fields", record: Club.new(billing_country: "IT") %> <%= render "shared/billing_profile_fields", record: Club.new(billing_country: "IT") %>

View File

@@ -9,7 +9,7 @@
<%= label_tag "team[name]", "Nome squadra" %> <%= label_tag "team[name]", "Nome squadra" %>
<%= text_field_tag "team[name]", @team.name, required: true %> <%= text_field_tag "team[name]", @team.name, required: true %>
<%= label_tag "team[sport]", "Sport" %> <%= label_tag "team[sport]", "Sport" %>
<%= select_tag "team[sport]", options_for_select([["Pallavolo", "volleyball"], ["Calcio", "football"], ["Basket", "basketball"]], @team.sport) %> <%= select_tag "team[sport]", sport_catalog_options(@team.sport_key) %>
<%= label_tag "team[description]", "Descrizione squadra" %> <%= label_tag "team[description]", "Descrizione squadra" %>
<%= text_area_tag "team[description]", @team.description, rows: 5, placeholder: "Presentazione della squadra, palmarès, obiettivi della stagione…" %> <%= text_area_tag "team[description]", @team.description, rows: 5, placeholder: "Presentazione della squadra, palmarès, obiettivi della stagione…" %>

View File

@@ -9,7 +9,7 @@
<%= label_tag "team[name]", "Nome squadra" %> <%= label_tag "team[name]", "Nome squadra" %>
<%= text_field_tag "team[name]", nil, required: true, placeholder: "es. Under 13, Serie C" %> <%= text_field_tag "team[name]", nil, required: true, placeholder: "es. Under 13, Serie C" %>
<%= label_tag "team[sport]", "Sport" %> <%= label_tag "team[sport]", "Sport" %>
<%= select_tag "team[sport]", options_for_select([["Pallavolo", "volleyball"], ["Calcio", "football"], ["Basket", "basketball"]], @club.sport) %> <%= select_tag "team[sport]", sport_catalog_options(@club.sport) %>
<%= render "shared/branding_fields", record: Team.new(club: @club), show_inherit_hint: true, legend: "Override branding (opzionale)" %> <%= render "shared/branding_fields", record: Team.new(club: @club), show_inherit_hint: true, legend: "Override branding (opzionale)" %>
<%= submit_tag "Aggiungi squadra", class: "btn btn-primary" %> <%= submit_tag "Aggiungi squadra", class: "btn btn-primary" %>
<% end %> <% end %>

View File

@@ -43,6 +43,7 @@ Rails.application.routes.draw do
patch :pause patch :pause
patch :resume patch :resume
patch :score patch :score
post :score_action
get :events get :events
post :telemetry post :telemetry
post :pairing_token post :pairing_token

10
backend/db/schema.rb generated
View File

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2026_06_03_121000) do ActiveRecord::Schema[7.2].define(version: 2026_06_09_100000) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto" enable_extension "pgcrypto"
enable_extension "plpgsql" enable_extension "plpgsql"
@@ -147,7 +147,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_03_121000) do
t.string "opponent_name", null: false t.string "opponent_name", null: false
t.string "location" t.string "location"
t.datetime "scheduled_at" t.datetime "scheduled_at"
t.string "sport", default: "volleyball" t.string "sport_key", default: "pallavolo"
t.integer "sets_to_win", default: 3 t.integer "sets_to_win", default: 3
t.jsonb "roster_numbers", default: [] t.jsonb "roster_numbers", default: []
t.string "category" t.string "category"
@@ -155,6 +155,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_03_121000) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.jsonb "scoring_rules", default: {}, null: false t.jsonb "scoring_rules", default: {}, null: false
t.string "opponent_primary_color"
t.string "overlay_kind"
t.index ["team_id"], name: "index_matches_on_team_id" t.index ["team_id"], name: "index_matches_on_team_id"
end end
@@ -217,6 +219,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_03_121000) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.jsonb "set_partials", default: [], null: false t.jsonb "set_partials", default: [], null: false
t.string "board_type", default: "volley", null: false
t.jsonb "data", default: {}, null: false
t.index ["stream_session_id"], name: "index_score_states_on_stream_session_id", unique: true t.index ["stream_session_id"], name: "index_score_states_on_stream_session_id", unique: true
end end
@@ -319,7 +323,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_03_121000) do
create_table "teams", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| create_table "teams", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "sport", default: "volleyball" t.string "sport_key", default: "pallavolo"
t.string "logo_url" t.string "logo_url"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false

View File

@@ -8,7 +8,7 @@ RSpec.describe Sports::Catalog do
entry = described_class.find("pallavolo") entry = described_class.find("pallavolo")
expect(entry[:board]).to eq("volley") expect(entry[:board]).to eq("volley")
expect(entry[:overlay]).to eq("volley") expect(entry[:overlay]).to eq("volley")
expect(entry[:default_rules]["sets_to_win"]).to eq(3) expect(entry[:default_rules][:sets_to_win]).to eq(3)
end end
it "mappa legacy volleyball" do it "mappa legacy volleyball" do

View File

@@ -12,7 +12,7 @@ android {
applicationId = "com.matchlivetv.match_live_tv" applicationId = "com.matchlivetv.match_live_tv"
minSdk = 24 minSdk = 24
targetSdk = 35 targetSdk = 35
versionCode = 16 versionCode = 17
versionName = "2.0.0-native" versionName = "2.0.0-native"
val apiBaseUrl = project.findProperty("API_BASE_URL") as String? val apiBaseUrl = project.findProperty("API_BASE_URL") as String?

View File

@@ -195,6 +195,7 @@ data class SetPartialDto(
) )
data class ScoreStateDto( data class ScoreStateDto(
@Json(name = "board_type") val boardType: String? = null,
@Json(name = "home_sets") val homeSets: Int? = null, @Json(name = "home_sets") val homeSets: Int? = null,
@Json(name = "away_sets") val awaySets: Int? = null, @Json(name = "away_sets") val awaySets: Int? = null,
@Json(name = "home_points") val homePoints: 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 = "set_partials") val setPartials: List<SetPartialDto>? = null,
@Json(name = "timeout_home") val timeoutHome: Boolean? = null, @Json(name = "timeout_home") val timeoutHome: Boolean? = null,
@Json(name = "timeout_away") val timeoutAway: 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( fun toDomain() = com.matchlivetv.match_live_tv.domain.ScoreState(
homeSets = homeSets ?: 0, homeSets = homeSets ?: 0,
@@ -215,9 +219,18 @@ data class ScoreStateDto(
}, },
timeoutHome = timeoutHome ?: false, timeoutHome = timeoutHome ?: false,
timeoutAway = timeoutAway ?: 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( data class ScoreSyncRequest(
@Json(name = "home_sets") val homeSets: Int, @Json(name = "home_sets") val homeSets: Int,
@Json(name = "away_sets") val awaySets: Int, @Json(name = "away_sets") val awaySets: Int,

View File

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

View File

@@ -126,35 +126,7 @@ class SessionCableService(
return nested as? Map<String, Any?> ?: raw return nested as? Map<String, Any?> ?: raw
} }
private fun parseScore(data: Map<String, Any?>): ScoreState { private fun parseScore(data: Map<String, Any?>): ScoreState = ScoreState.fromCablePayload(data)
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
}
}
companion object { companion object {
private const val TAG = "SessionCableService" private const val TAG = "SessionCableService"

View File

@@ -1,6 +1,7 @@
package com.matchlivetv.match_live_tv.data.repository 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.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.ScoreSyncRequest
import com.matchlivetv.match_live_tv.data.api.SetPartialDto import com.matchlivetv.match_live_tv.data.api.SetPartialDto
import com.matchlivetv.match_live_tv.domain.ScoreState import com.matchlivetv.match_live_tv.domain.ScoreState
@@ -24,4 +25,9 @@ class ScoreRepository(
) )
return response.score?.toDomain() 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() 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() { fun closeSet() {
val current = _score.value val current = _score.value
val homeWon = current.homePoints > current.awayPoints val homeWon = current.homePoints > current.awayPoints

View File

@@ -15,23 +15,107 @@ data class ScoreState(
val setPartials: List<SetPartial> = emptyList(), val setPartials: List<SetPartial> = emptyList(),
val timeoutHome: Boolean = false, val timeoutHome: Boolean = false,
val timeoutAway: 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( fun cablePayload(): Map<String, Any?> {
"type" to "score_update", val payload = mutableMapOf<String, Any?>(
"home_sets" to homeSets, "type" to "score_update",
"away_sets" to awaySets, "board_type" to boardType,
"home_points" to homePoints, "home_sets" to homeSets,
"away_points" to awayPoints, "away_sets" to awaySets,
"current_set" to currentSet, "home_points" to homePoints,
"set_partials" to setPartials.map { "away_points" to awayPoints,
mapOf("set" to it.set, "home" to it.home, "away" to it.away) "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 = fun progressKey(): Long =
currentSet * 10_000_000_000L + currentSet * 10_000_000_000L +
homeSets * 1_000_000L + homeSets * 1_000_000L +
awaySets * 100_000L + awaySets * 100_000L +
homePoints * 1_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.Check
import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow 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.Share
import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.Visibility 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.DeviceHealthSnapshot
import com.matchlivetv.match_live_tv.core.ThermalLevel import com.matchlivetv.match_live_tv.core.ThermalLevel
import com.matchlivetv.match_live_tv.domain.ScoreState 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.components.MatchStatusBadge
import com.matchlivetv.match_live_tv.ui.theme.MatchColors import com.matchlivetv.match_live_tv.ui.theme.MatchColors
@@ -85,12 +88,19 @@ fun BroadcastControlsOverlay(
homeLogoUrl: String?, homeLogoUrl: String?,
awayLogoUrl: String?, awayLogoUrl: String?,
score: ScoreState, score: ScoreState,
boardType: String,
pointsTarget: Int, pointsTarget: Int,
onPointHome: () -> Unit, onPointHome: () -> Unit,
onPointAway: () -> Unit, onPointAway: () -> Unit,
onMinusHome: () -> Unit, onMinusHome: () -> Unit,
onMinusAway: () -> 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, onPauseOrResume: () -> Unit,
onTerminate: () -> Unit, onTerminate: () -> Unit,
onShareLive: () -> Unit, onShareLive: () -> Unit,
@@ -181,11 +191,28 @@ fun BroadcastControlsOverlay(
contentDescription = "Condividi link regia", contentDescription = "Condividi link regia",
onClick = onShareRegia, onClick = onShareRegia,
) )
SideIconButton( if (onCloseSet != null) {
icon = Icons.Default.Check, SideIconButton(
contentDescription = "Chiudi set", icon = Icons.Default.Check,
onClick = onCloseSet, 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( Column(
@@ -223,34 +250,17 @@ fun BroadcastControlsOverlay(
points = score.homePoints, points = score.homePoints,
onPlus = onPointHome, onPlus = onPointHome,
onMinus = onMinusHome, onMinus = onMinusHome,
onPlus2 = onPoint2Home,
onPlus3 = onPoint3Home,
showBasketButtons = boardType == "basket",
alignEnd = false, alignEnd = false,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
Column( ScoreCenterPanel(
Modifier score = score,
.padding(horizontal = 12.dp) boardType = boardType,
.padding(bottom = 8.dp), pointsTarget = pointsTarget,
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,
)
}
}
TeamScoreColumn( TeamScoreColumn(
teamLabel = "OSPITE", teamLabel = "OSPITE",
teamName = awayName, teamName = awayName,
@@ -259,6 +269,9 @@ fun BroadcastControlsOverlay(
points = score.awayPoints, points = score.awayPoints,
onPlus = onPointAway, onPlus = onPointAway,
onMinus = onMinusAway, onMinus = onMinusAway,
onPlus2 = onPoint2Away,
onPlus3 = onPoint3Away,
showBasketButtons = boardType == "basket",
alignEnd = true, alignEnd = true,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
@@ -365,6 +378,63 @@ private fun formatBitrateKbps(kbps: Long): String {
return "$kbps kbps" 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 @Composable
private fun TeamScoreColumn( private fun TeamScoreColumn(
teamLabel: String, teamLabel: String,
@@ -374,6 +444,9 @@ private fun TeamScoreColumn(
points: Int, points: Int,
onPlus: () -> Unit, onPlus: () -> Unit,
onMinus: () -> Unit, onMinus: () -> Unit,
onPlus2: (() -> Unit)? = null,
onPlus3: (() -> Unit)? = null,
showBasketButtons: Boolean = false,
alignEnd: Boolean, alignEnd: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -407,11 +480,19 @@ private fun TeamScoreColumn(
) { ) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
if (alignEnd) { 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( ScoreIconButton(
label = "+1", label = "+1",
tooltip = "Aggiungi punto $teamSide", tooltip = "Aggiungi punto $teamSide",
onClick = onPlus, onClick = onPlus,
primary = true, primary = !showBasketButtons,
) )
ScoreIconButton(label = "", tooltip = "Togli punto $teamSide", onClick = onMinus) ScoreIconButton(label = "", tooltip = "Togli punto $teamSide", onClick = onMinus)
} else { } else {
@@ -420,8 +501,16 @@ private fun TeamScoreColumn(
label = "+1", label = "+1",
tooltip = "Aggiungi punto $teamSide", tooltip = "Aggiungi punto $teamSide",
onClick = onPlus, 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.TIMER -> OverlayState(
overlayKind = overlayKind, overlayKind = overlayKind,
timerText = formatOverlayClock(score.homePoints, countUp = true), timerText = formatOverlayClock(
if (score.boardType == "timer") score.clockSecs else score.homePoints,
countUp = true,
),
watermarkVisible = true, watermarkVisible = true,
broadcastStatus = metrics.phase.toOverlayStatus(), broadcastStatus = metrics.phase.toOverlayStatus(),
) )
@@ -244,7 +247,11 @@ fun BroadcastScreen(
awayTeamName = currentMatch.opponentName, awayTeamName = currentMatch.opponentName,
homeScore = score.homePoints, homeScore = score.homePoints,
awayScore = score.awayPoints, 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, homeAccentColor = homeColor,
awayAccentColor = awayColor, 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) { LaunchedEffect(sessionId) {
while (true) { while (true) {
delay(4_000) delay(4_000)
@@ -346,6 +363,12 @@ fun BroadcastScreen(
val pointsTarget = scoringRules?.pointsTarget(score.currentSet) ?: 25 val pointsTarget = scoringRules?.pointsTarget(score.currentSet) ?: 25
val currentSession = session val currentSession = session
val currentMatch = match 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 shareUrl = currentSession?.watchShareUrl()
val shareSubject = currentMatch?.let { "${it.teamName} vs ${it.opponentName}" } ?: "Diretta Match Live TV" val shareSubject = currentMatch?.let { "${it.teamName} vs ${it.opponentName}" } ?: "Diretta Match Live TV"
@@ -419,29 +442,76 @@ fun BroadcastScreen(
homeLogoUrl = currentMatch.homeLogoUrl, homeLogoUrl = currentMatch.homeLogoUrl,
awayLogoUrl = currentMatch.opponentLogoUrl, awayLogoUrl = currentMatch.opponentLogoUrl,
score = score, score = score,
boardType = boardType,
pointsTarget = pointsTarget, pointsTarget = pointsTarget,
onPointHome = { onPointHome = {
scope.launch { scope.launch {
container.scoreController.incrementHome() if (usesActionScoring) {
scoreActions.afterPointChange(container.scoreController.score.value) { applyScoreAction("home_point")
stopStreamPermanently() } else {
container.scoreController.incrementHome()
scoreActions.afterPointChange(container.scoreController.score.value) {
stopStreamPermanently()
}
} }
} }
}, },
onPointAway = { onPointAway = {
scope.launch { scope.launch {
container.scoreController.incrementAway() if (usesActionScoring) {
scoreActions.afterPointChange(container.scoreController.score.value) { applyScoreAction("away_point")
stopStreamPermanently() } else {
container.scoreController.incrementAway()
scoreActions.afterPointChange(container.scoreController.score.value) {
stopStreamPermanently()
}
} }
} }
}, },
onMinusHome = { container.scoreController.decrementHome() }, onMinusHome = {
onMinusAway = { container.scoreController.decrementAway() }, if (usesActionScoring) applyScoreAction("home_undo") else container.scoreController.decrementHome()
onCloseSet = { },
scope.launch { onMinusAway = {
scoreActions.requestCloseSet { stopStreamPermanently() } 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 = { onPauseOrResume = {
scope.launch { scope.launch {

View File

@@ -98,10 +98,15 @@ class LiveScoreActions(
private val scoreController: ScoreController, private val scoreController: ScoreController,
private val dialogHost: LiveScoreDialogHost, private val dialogHost: LiveScoreDialogHost,
) { ) {
private val usesSetLogic: Boolean
get() = rules.boardType in setOf("volley", "racket")
suspend fun afterPointChange( suspend fun afterPointChange(
score: ScoreState, score: ScoreState,
onStopStream: suspend () -> Unit = {}, onStopStream: suspend () -> Unit = {},
) { ) {
if (!usesSetLogic) return
val winner = rules.setWinnerFromPoints( val winner = rules.setWinnerFromPoints(
homePoints = score.homePoints, homePoints = score.homePoints,
awayPoints = score.awayPoints, awayPoints = score.awayPoints,
@@ -123,6 +128,8 @@ class LiveScoreActions(
} }
suspend fun requestCloseSet(onStopStream: suspend () -> Unit) { suspend fun requestCloseSet(onStopStream: suspend () -> Unit) {
if (!usesSetLogic) return
val score = scoreController.score.value val score = scoreController.score.value
val winner = rules.setWinnerFromPoints( val winner = rules.setWinnerFromPoints(
homePoints = score.homePoints, 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_PER_SET = 25
private const val DEFAULT_POINTS_DECIDING_SET = 15 private const val DEFAULT_POINTS_DECIDING_SET = 15
private const val DEFAULT_MIN_POINT_LEAD = 2 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" private const val DEFAULT_OPPONENT_COLOR = "#1E3A8A"
/** True solo se la partita ha regole non standard (non i default FIPAV). */ /** True se la partita ha regole non standard rispetto ai default del board. */
private fun matchHasCustomScoring(match: Match): Boolean { private fun matchHasCustomScoring(match: Match, boardType: String): Boolean {
if (match.setsToWin != DEFAULT_SETS_TO_WIN) return true
val rules = match.scoringRules ?: return false val rules = match.scoringRules ?: return false
return rules.pointsPerSet != DEFAULT_POINTS_PER_SET || return when (boardType) {
rules.pointsDecidingSet != DEFAULT_POINTS_DECIDING_SET || "basket", "timed" -> {
rules.minPointLead != DEFAULT_MIN_POINT_LEAD 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 @Composable
@@ -102,7 +114,7 @@ fun StepMatchScreen(
) { uri -> opponentLogoUri = uri } ) { uri -> opponentLogoUri = uri }
val existingRules = match.scoringRules 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 setsToWin by remember { mutableIntStateOf(match.setsToWin.coerceIn(2, 3)) }
var pointsPerSet by remember { var pointsPerSet by remember {
mutableIntStateOf(existingRules?.pointsPerSet ?: DEFAULT_POINTS_PER_SET) mutableIntStateOf(existingRules?.pointsPerSet ?: DEFAULT_POINTS_PER_SET)
@@ -112,6 +124,15 @@ fun StepMatchScreen(
} }
var pointsPerSetText by remember { mutableStateOf(pointsPerSet.toString()) } var pointsPerSetText by remember { mutableStateOf(pointsPerSet.toString()) }
var pointsDecidingSetText by remember { mutableStateOf(pointsDecidingSet.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 saving by remember { mutableStateOf(false) }
var sports by remember { mutableStateOf<List<SportOption>>(emptyList()) } var sports by remember { mutableStateOf<List<SportOption>>(emptyList()) }
var selectedSportKey by remember { mutableStateOf(match.sportKey) } var selectedSportKey by remember { mutableStateOf(match.sportKey) }
@@ -127,6 +148,10 @@ fun StepMatchScreen(
val allowedOverlays = currentSport?.allowedOverlays.orEmpty() val allowedOverlays = currentSport?.allowedOverlays.orEmpty()
val boardType = currentSport?.board ?: homeTeam?.boardType ?: match.boardType val boardType = currentSport?.board ?: homeTeam?.boardType ?: match.boardType
LaunchedEffect(boardType, match.id) {
customRules = matchHasCustomScoring(match, boardType)
}
Column( Column(
Modifier Modifier
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
@@ -227,10 +252,15 @@ fun StepMatchScreen(
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text("Regole punteggio personalizzate") Text("Regole punteggio personalizzate")
Text( Text(
if (customRules) { when {
"Torneo non standard: imposta set e punteggi." !customRules && boardType in setOf("basket", "timed") ->
} else { "Regole standard dello sport selezionato."
"Standard FIPAV: 3 set per vincere, set a 25, tie-break a 15." 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, style = MaterialTheme.typography.bodyMedium,
color = MatchColors.TextSecondary, color = MatchColors.TextSecondary,
@@ -239,6 +269,51 @@ fun StepMatchScreen(
Switch(checked = customRules, onCheckedChange = { customRules = it }) 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")) { if (customRules && boardType in setOf("volley", "racket")) {
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Text("Set da vincere la partita", style = MaterialTheme.typography.bodyMedium) Text("Set da vincere la partita", style = MaterialTheme.typography.bodyMedium)
@@ -290,7 +365,7 @@ fun StepMatchScreen(
onError("Inserisci il nome avversario") onError("Inserisci il nome avversario")
return@MatchPrimaryButton return@MatchPrimaryButton
} }
if (customRules) { if (customRules && boardType in setOf("volley", "racket")) {
val perSet = pointsPerSetText.toIntOrNull() val perSet = pointsPerSetText.toIntOrNull()
val deciding = pointsDecidingSetText.toIntOrNull() val deciding = pointsDecidingSetText.toIntOrNull()
if (perSet == null || perSet < 1) { if (perSet == null || perSet < 1) {
@@ -304,17 +379,39 @@ fun StepMatchScreen(
pointsPerSet = perSet pointsPerSet = perSet
pointsDecidingSet = deciding 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 resolvedSets = if (customRules && boardType in setOf("volley", "racket")) {
val resolvedRules: ScoringRulesBody? = if (customRules && boardType in setOf("volley", "racket")) { setsToWin
ScoringRulesBody( } else {
DEFAULT_SETS_TO_WIN
}
val resolvedRules: ScoringRulesBody? = when {
customRules && boardType in setOf("volley", "racket") -> ScoringRulesBody(
setsToWin = resolvedSets, setsToWin = resolvedSets,
pointsPerSet = pointsPerSet, pointsPerSet = pointsPerSet,
pointsDecidingSet = pointsDecidingSet, pointsDecidingSet = pointsDecidingSet,
minPointLead = existingRules?.minPointLead ?: DEFAULT_MIN_POINT_LEAD, minPointLead = existingRules?.minPointLead ?: DEFAULT_MIN_POINT_LEAD,
) )
} else { customRules && boardType in setOf("basket", "timed") -> ScoringRulesBody(
null periods = periods,
periodDurationSecs = periodDurationMins * 60,
overtimeDurationSecs = overtimeDurationMins * 60,
)
else -> null
} }
val initialHomeColor = normalizeHexColor( val initialHomeColor = normalizeHexColor(