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:
@@ -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
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" %>
|
||||||
|
|||||||
@@ -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") %>
|
||||||
|
|
||||||
|
|||||||
@@ -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…" %>
|
||||||
|
|||||||
@@ -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 %>
|
||||||
|
|||||||
@@ -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
10
backend/db/schema.rb
generated
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -15,9 +15,17 @@ 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?> {
|
||||||
|
val payload = mutableMapOf<String, Any?>(
|
||||||
"type" to "score_update",
|
"type" to "score_update",
|
||||||
|
"board_type" to boardType,
|
||||||
"home_sets" to homeSets,
|
"home_sets" to homeSets,
|
||||||
"away_sets" to awaySets,
|
"away_sets" to awaySets,
|
||||||
"home_points" to homePoints,
|
"home_points" to homePoints,
|
||||||
@@ -26,12 +34,88 @@ data class ScoreState(
|
|||||||
"set_partials" to setPartials.map {
|
"set_partials" to setPartials.map {
|
||||||
mapOf("set" to it.set, "home" to it.home, "away" to it.away)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,12 +191,29 @@ fun BroadcastControlsOverlay(
|
|||||||
contentDescription = "Condividi link regia",
|
contentDescription = "Condividi link regia",
|
||||||
onClick = onShareRegia,
|
onClick = onShareRegia,
|
||||||
)
|
)
|
||||||
|
if (onCloseSet != null) {
|
||||||
SideIconButton(
|
SideIconButton(
|
||||||
icon = Icons.Default.Check,
|
icon = Icons.Default.Check,
|
||||||
contentDescription = "Chiudi set",
|
contentDescription = "Chiudi set",
|
||||||
onClick = onCloseSet,
|
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(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
if (usesActionScoring) {
|
||||||
|
applyScoreAction("home_point")
|
||||||
|
} else {
|
||||||
container.scoreController.incrementHome()
|
container.scoreController.incrementHome()
|
||||||
scoreActions.afterPointChange(container.scoreController.score.value) {
|
scoreActions.afterPointChange(container.scoreController.score.value) {
|
||||||
stopStreamPermanently()
|
stopStreamPermanently()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onPointAway = {
|
onPointAway = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
if (usesActionScoring) {
|
||||||
|
applyScoreAction("away_point")
|
||||||
|
} else {
|
||||||
container.scoreController.incrementAway()
|
container.scoreController.incrementAway()
|
||||||
scoreActions.afterPointChange(container.scoreController.score.value) {
|
scoreActions.afterPointChange(container.scoreController.score.value) {
|
||||||
stopStreamPermanently()
|
stopStreamPermanently()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onMinusHome = { container.scoreController.decrementHome() },
|
onMinusHome = {
|
||||||
onMinusAway = { container.scoreController.decrementAway() },
|
if (usesActionScoring) applyScoreAction("home_undo") else container.scoreController.decrementHome()
|
||||||
onCloseSet = {
|
},
|
||||||
|
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 {
|
scope.launch {
|
||||||
scoreActions.requestCloseSet { stopStreamPermanently() }
|
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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
"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.pointsDecidingSet != DEFAULT_POINTS_DECIDING_SET ||
|
||||||
rules.minPointLead != DEFAULT_MIN_POINT_LEAD
|
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,9 +252,14 @@ fun StepMatchScreen(
|
|||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text("Regole punteggio personalizzate")
|
Text("Regole punteggio personalizzate")
|
||||||
Text(
|
Text(
|
||||||
if (customRules) {
|
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."
|
"Torneo non standard: imposta set e punteggi."
|
||||||
} else {
|
else ->
|
||||||
"Standard FIPAV: 3 set per vincere, set a 25, tie-break a 15."
|
"Standard FIPAV: 3 set per vincere, set a 25, tie-break a 15."
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user