diff --git a/backend/app/controllers/api/v1/matches_controller.rb b/backend/app/controllers/api/v1/matches_controller.rb
index 0de3457..30da598 100644
--- a/backend/app/controllers/api/v1/matches_controller.rb
+++ b/backend/app/controllers/api/v1/matches_controller.rb
@@ -16,7 +16,10 @@ module Api
end
def create
- match = @team.matches.create!(match_params)
+ attrs = match_params.to_h
+ attrs["sport_key"] = @team.sport_key if attrs["sport_key"].blank?
+ normalize_scoring_rules!(attrs)
+ match = @team.matches.create!(attrs)
attach_opponent_logo(match)
render json: match_json(match), status: :created
end
@@ -59,11 +62,15 @@ module Api
end
def match_params
- params.require(:match).permit(
- :opponent_name, :location, :scheduled_at, :sport, :sets_to_win,
- :category, :opponent_primary_color,
- scoring_rules: %i[points_per_set points_deciding_set min_point_lead]
+ p = params.require(:match).permit(
+ :opponent_name, :location, :scheduled_at, :sport, :sport_key, :sets_to_win,
+ :category, :opponent_primary_color, :overlay_kind,
+ scoring_rules: {}
)
+ if p[:sport].present? && p[:sport_key].blank?
+ p[:sport_key] = p.delete(:sport)
+ end
+ p
end
def normalize_scoring_rules!(attrs)
@@ -71,6 +78,9 @@ module Api
rules = attrs["scoring_rules"]
attrs["scoring_rules"] = {} if rules.blank?
+ if attrs["sets_to_win"].present? && attrs["scoring_rules"].is_a?(Hash)
+ attrs["scoring_rules"]["sets_to_win"] ||= attrs["sets_to_win"]
+ end
end
def normalize_opponent_color!(attrs)
@@ -97,9 +107,15 @@ module Api
opponent_name: match.opponent_name,
location: match.location,
scheduled_at: match.scheduled_at,
- sport: match.sport,
+ sport: match.sport_key,
+ sport_key: match.sport_key,
+ sport_label: match.sport_label,
+ board_type: match.effective_board_type,
+ overlay_kind: match.overlay_kind,
+ effective_overlay_kind: match.effective_overlay_kind,
sets_to_win: match.sets_to_win,
scoring_rules: match.scoring_rules.presence,
+ effective_scoring_rules: match.effective_scoring_rules,
category: match.category,
home_primary_color: team.effective_primary_color,
home_secondary_color: team.effective_secondary_color,
diff --git a/backend/app/controllers/api/v1/sports_controller.rb b/backend/app/controllers/api/v1/sports_controller.rb
new file mode 100644
index 0000000..2f9a57a
--- /dev/null
+++ b/backend/app/controllers/api/v1/sports_controller.rb
@@ -0,0 +1,9 @@
+module Api
+ module V1
+ class SportsController < BaseController
+ def index
+ render json: Sports::Catalog.as_api_list
+ end
+ end
+ end
+end
diff --git a/backend/app/controllers/api/v1/teams_controller.rb b/backend/app/controllers/api/v1/teams_controller.rb
index f3b8b4d..89d0773 100644
--- a/backend/app/controllers/api/v1/teams_controller.rb
+++ b/backend/app/controllers/api/v1/teams_controller.rb
@@ -21,10 +21,11 @@ module Api
}, status: :unprocessable_entity
end
- club = Club.create!(name: team_params[:name], sport: team_params[:sport] || "volleyball",
+ sport_key = Sports::Catalog.normalize_key(team_params[:sport_key] || team_params[:sport] || "pallavolo")
+ club = Club.create!(name: team_params[:name], sport: sport_key,
primary_color: "#e53935", secondary_color: "#ffffff")
ClubMembership.create!(user: current_user, club: club, role: "owner")
- team = club.teams.create!(name: team_params[:name], sport: club.sport)
+ team = club.teams.create!(name: team_params[:name], sport_key: sport_key)
Billing::AssignPlan.call(club: club, plan_slug: "free")
render json: team_json(team), status: :created
end
@@ -74,7 +75,11 @@ module Api
private
def team_params
- p = params.require(:team).permit(:name, :sport, :logo_url, :primary_color, :secondary_color)
+ p = params.require(:team).permit(:name, :sport, :sport_key, :logo_url, :primary_color, :secondary_color)
+ if p[:sport].present? && p[:sport_key].blank?
+ p[:sport_key] = p.delete(:sport)
+ end
+ p
if p[:primary_color].present?
p[:primary_color] = normalize_hex_color(p[:primary_color], p[:primary_color])
end
@@ -96,7 +101,10 @@ module Api
data = {
id: team.id,
name: team.name,
- sport: team.sport,
+ sport: team.sport_key,
+ sport_key: team.sport_key,
+ sport_label: team.sport_label,
+ board_type: team.effective_board_type,
logo_url: api_absolute_url(team.effective_logo_url),
primary_color: team.effective_primary_color,
secondary_color: team.effective_secondary_color,
diff --git a/backend/app/controllers/public/regia_controller.rb b/backend/app/controllers/public/regia_controller.rb
index 40bf960..f37b98a 100644
--- a/backend/app/controllers/public/regia_controller.rb
+++ b/backend/app/controllers/public/regia_controller.rb
@@ -1,6 +1,7 @@
module Public
class RegiaController < SiteBaseController
include Public::LiveHelper
+ include Public::RegiaHelper
include MediamtxPlayback
layout "regia"
@@ -13,9 +14,7 @@ module Public
def show
@match = @session.match
@team = @match.team
- @score = @session.score_state || @session.create_score_state!(
- home_sets: 0, away_sets: 0, home_points: 0, away_points: 0, current_set: 1, set_partials: []
- )
+ @score = @session.score_state || Scoring::Engine.ensure_score_for(@session)
@rules = Scoring::Rules.from_match(@match)
@stream_closed = @session.terminal?
@on_air = !@stream_closed && mediamtx_stream_playable?(@session)
diff --git a/backend/app/domain/sports/board_type.rb b/backend/app/domain/sports/board_type.rb
new file mode 100644
index 0000000..eca4ae1
--- /dev/null
+++ b/backend/app/domain/sports/board_type.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Sports
+ class BoardType
+ ALL = %w[volley basket timed racket timer generic].freeze
+
+ def self.valid?(value)
+ ALL.include?(value.to_s)
+ end
+ end
+end
diff --git a/backend/app/domain/sports/catalog.rb b/backend/app/domain/sports/catalog.rb
new file mode 100644
index 0000000..84a6024
--- /dev/null
+++ b/backend/app/domain/sports/catalog.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+module Sports
+ class Catalog
+ LEGACY_SPORT_MAP = {
+ "volleyball" => "pallavolo"
+ }.freeze
+
+ class << self
+ def all
+ @all ||= load_entries
+ end
+
+ def keys
+ all.keys
+ end
+
+ def find(key)
+ normalized = normalize_key(key)
+ entry = all[normalized]
+ raise KeyError, "Sport sconosciuto: #{key}" unless entry
+
+ entry.merge(key: normalized)
+ end
+
+ def find_optional(key)
+ normalized = normalize_key(key)
+ entry = all[normalized]
+ return nil unless entry
+
+ entry.merge(key: normalized)
+ end
+
+ def board_for(key)
+ find(key)[:board]
+ end
+
+ def overlay_for(key)
+ find(key)[:overlay]
+ end
+
+ def allowed_overlays_for(key)
+ find(key)[:allowed_overlays]
+ end
+
+ def default_rules_for(key)
+ deep_stringify_keys(find(key)[:default_rules] || {})
+ end
+
+ def normalize_key(key)
+ raw = key.to_s.strip
+ return "pallavolo" if raw.blank?
+
+ LEGACY_SPORT_MAP.fetch(raw, raw)
+ end
+
+ def as_api_list
+ all.map do |key, entry|
+ {
+ key: key,
+ label: entry[:label],
+ board: entry[:board],
+ overlay: entry[:overlay],
+ allowed_overlays: entry[:allowed_overlays],
+ default_rules: entry[:default_rules] || {}
+ }
+ end
+ end
+
+ private
+
+ def load_entries
+ path = Rails.root.join("config/sports.yml")
+ raw = YAML.safe_load(File.read(path), permitted_classes: [], aliases: true) || {}
+ raw.transform_keys(&:to_s).transform_values do |entry|
+ entry.deep_symbolize_keys
+ end.freeze
+ end
+
+ def deep_stringify_keys(value)
+ case value
+ when Hash
+ value.each_with_object({}) do |(k, v), h|
+ h[k.to_s] = deep_stringify_keys(v)
+ end
+ when Array
+ value.map { |v| deep_stringify_keys(v) }
+ else
+ value
+ end
+ end
+ end
+ end
+end
diff --git a/backend/app/domain/sports/overlay_kind.rb b/backend/app/domain/sports/overlay_kind.rb
new file mode 100644
index 0000000..8d6d07d
--- /dev/null
+++ b/backend/app/domain/sports/overlay_kind.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Sports
+ class OverlayKind
+ ALL = %w[none timer volley basket timed racket].freeze
+
+ def self.valid?(value)
+ ALL.include?(value.to_s)
+ end
+ end
+end
diff --git a/backend/app/domain/sports/rules_schema.rb b/backend/app/domain/sports/rules_schema.rb
new file mode 100644
index 0000000..498acbb
--- /dev/null
+++ b/backend/app/domain/sports/rules_schema.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module Sports
+ class RulesSchema
+ INTEGER_RULES = {
+ "volley" => %w[sets_to_win points_per_set points_deciding_set min_point_lead],
+ "racket" => %w[sets_to_win points_per_set points_deciding_set min_point_lead],
+ "basket" => %w[periods period_duration_secs overtime_duration_secs],
+ "timed" => %w[periods period_duration_secs overtime_duration_secs],
+ "timer" => [],
+ "generic" => []
+ }.freeze
+
+ def self.validate!(board_type, rules)
+ board = board_type.to_s
+ return if board == "generic" || board == "timer"
+
+ hash = rules.is_a?(Hash) ? rules : {}
+ INTEGER_RULES.fetch(board, []).each do |key|
+ val = hash[key] || hash[key.to_sym]
+ next if val.blank?
+
+ unless val.is_a?(Integer) || val.to_s.match?(/\A\d+\z/)
+ raise ArgumentError, "#{key} non valido"
+ end
+ int_val = val.to_i
+ raise ArgumentError, "#{key} deve essere positivo" if int_val < 1
+ end
+ end
+
+ def self.validate(board_type, rules)
+ validate!(board_type, rules)
+ true
+ rescue ArgumentError
+ false
+ end
+ end
+end
diff --git a/backend/app/helpers/public/live_helper.rb b/backend/app/helpers/public/live_helper.rb
index e5e50e5..e6e043e 100644
--- a/backend/app/helpers/public/live_helper.rb
+++ b/backend/app/helpers/public/live_helper.rb
@@ -1,13 +1,30 @@
module Public
module LiveHelper
- def live_score_sets_label(score_state)
+ def live_score_sets_label(score_state, match = nil)
return nil unless score_state
- "Set #{score_state.current_set} · Set vinti #{score_state.home_sets}-#{score_state.away_sets}"
+ board = match&.effective_board_type || score_state.effective_board_type
+ case board
+ when "basket", "timed"
+ live_score_period_label(match, score_state)
+ when "timer"
+ "Cronometro"
+ when "generic"
+ "Punteggio"
+ else
+ "Set #{score_state.current_set} · Set vinti #{score_state.home_sets}-#{score_state.away_sets}"
+ end
+ end
+
+ def live_score_period_label(match, score_state)
+ return "—" unless score_state
+
+ score_state.current_period_label
end
def live_score_partials_label(score_state)
return nil unless score_state
+ return nil unless score_state.effective_board_type.in?(%w[volley racket])
partials = Array(score_state.set_partials)
return nil if partials.empty?
@@ -58,7 +75,17 @@ module Public
def live_score_points_label(match, score_state)
return "—" unless score_state
- "#{match.team.name} #{score_state.home_points} - #{score_state.away_points} #{match.opponent_name}"
+ board = match.effective_board_type
+ home = case board
+ when "basket", "timed" then score_state.basket_home_score
+ else score_state.home_points
+ end
+ away = case board
+ when "basket", "timed" then score_state.basket_away_score
+ else score_state.away_points
+ end
+
+ "#{match.team.name} #{home} - #{away} #{match.opponent_name}"
end
private
diff --git a/backend/app/helpers/public/regia_helper.rb b/backend/app/helpers/public/regia_helper.rb
new file mode 100644
index 0000000..4657b96
--- /dev/null
+++ b/backend/app/helpers/public/regia_helper.rb
@@ -0,0 +1,32 @@
+module Public
+ module RegiaHelper
+ def regia_board_partial(match)
+ board = match.effective_board_type
+ partial = "public/regia/#{board}"
+ lookup_context.exists?(partial, [], true) ? partial : "public/regia/volley"
+ end
+
+ def regia_header_subtitle(match, score)
+ board = match.effective_board_type
+ case board
+ when "basket", "timed"
+ live_score_period_label(match, score)
+ when "timer"
+ "Cronometro"
+ when "generic"
+ "Punteggio"
+ else
+ "Set #{score.current_set}"
+ end
+ end
+
+ def format_clock_secs(secs, count_up: false)
+ total = secs.to_i
+ return count_up ? "0:00" : "—" if total <= 0 && !count_up
+
+ m = total / 60
+ s = total % 60
+ format("%d:%02d", m, s)
+ end
+ end
+end
diff --git a/backend/app/models/match.rb b/backend/app/models/match.rb
index 854482b..b089e08 100644
--- a/backend/app/models/match.rb
+++ b/backend/app/models/match.rb
@@ -9,13 +9,16 @@ class Match < ApplicationRecord
validates :opponent_name, presence: true
validates :sets_to_win, numericality: { greater_than: 0, less_than: 6 }
+ validates :sport_key, presence: true
validates :opponent_primary_color, format: { with: Brandable::HEX_COLOR }, allow_blank: true
- validate :scoring_rules_shape, if: -> { scoring_rules.present? }
+ validate :sport_key_known
+ validate :overlay_kind_allowed
+ validate :scoring_rules_shape
validate :opponent_logo_file_type, if: -> { opponent_logo_file.attached? }
- # category: campionato / descrizione torneo (facoltativo, es. "Serie C").
+ before_validation :normalize_sport_key
+ before_validation :inherit_sport_from_team, on: :create
- # Partite ancora da giocare in diretta (orario nel futuro).
scope :scheduled_for_live, -> {
where.not(scheduled_at: nil).where("scheduled_at >= ?", Time.zone.now)
}
@@ -78,8 +81,6 @@ class Match < ApplicationRecord
opponent_primary_color.presence || DEFAULT_OPPONENT_COLOR
end
- # Hub app coach: dirette da riprendere o partite programmate future.
- # Le bozze «Avversario» abbandonate (senza sessione) non compaiono.
def coach_hub_visible?
active = active_stream_session
return true if active&.resumable?
@@ -90,8 +91,72 @@ class Match < ApplicationRecord
scheduled_upcoming?
end
+ def effective_board_type
+ Sports::Catalog.board_for(sport_key)
+ end
+
+ def effective_overlay_kind
+ kind = overlay_kind.presence || Sports::Catalog.overlay_for(sport_key)
+ allowed = Sports::Catalog.allowed_overlays_for(sport_key)
+ allowed.include?(kind) ? kind : Sports::Catalog.overlay_for(sport_key)
+ end
+
+ def effective_scoring_rules
+ merged = Sports::Catalog.default_rules_for(sport_key).deep_merge(normalized_scoring_rules)
+ merged["sets_to_win"] = sets_to_win if merged["sets_to_win"].blank? && sets_to_win.present?
+ merged
+ end
+
+ def sport_label
+ Sports::Catalog.find_optional(sport_key)&.dig(:label) || sport_key.to_s.humanize
+ end
+
+ # Alias legacy API/spec (colonna rinominata in sport_key).
+ def sport
+ sport_key
+ end
+
+ def sport=(value)
+ self.sport_key = Sports::Catalog.normalize_key(value)
+ end
+
private
+ def normalize_sport_key
+ self.sport_key = Sports::Catalog.normalize_key(sport_key)
+ end
+
+ def inherit_sport_from_team
+ self.sport_key = team.sport_key if sport_key.blank? && team.present?
+ end
+
+ def sport_key_known
+ return if sport_key.blank?
+ return if Sports::Catalog.find_optional(sport_key)
+
+ errors.add(:sport_key, "non valido")
+ end
+
+ def overlay_kind_allowed
+ return if overlay_kind.blank?
+
+ unless Sports::OverlayKind.valid?(overlay_kind)
+ errors.add(:overlay_kind, "non valido")
+ return
+ end
+
+ allowed = Sports::Catalog.allowed_overlays_for(sport_key)
+ errors.add(:overlay_kind, "non consentito per questo sport") unless allowed.include?(overlay_kind)
+ end
+
+ def normalized_scoring_rules
+ return {} unless scoring_rules.is_a?(Hash)
+
+ scoring_rules.each_with_object({}) do |(k, v), h|
+ h[k.to_s] = v
+ end
+ end
+
def opponent_logo_file_type
return if opponent_logo_file.content_type.in?(%w[image/png image/jpeg image/webp])
@@ -99,17 +164,8 @@ class Match < ApplicationRecord
end
def scoring_rules_shape
- rules = scoring_rules.is_a?(Hash) ? scoring_rules : {}
- %w[points_per_set points_deciding_set min_point_lead].each do |key|
- val = rules[key] || rules[key.to_sym]
- next if val.blank?
-
- unless val.is_a?(Integer) || val.to_s.match?(/\A\d+\z/)
- errors.add(:scoring_rules, "#{key} non valido")
- next
- end
- int_val = val.to_i
- errors.add(:scoring_rules, "#{key} deve essere positivo") if int_val < 1
- end
+ Sports::RulesSchema.validate!(effective_board_type, normalized_scoring_rules)
+ rescue ArgumentError => e
+ errors.add(:scoring_rules, e.message)
end
end
diff --git a/backend/app/models/score_state.rb b/backend/app/models/score_state.rb
index 425a512..60adff0 100644
--- a/backend/app/models/score_state.rb
+++ b/backend/app/models/score_state.rb
@@ -1,9 +1,12 @@
class ScoreState < ApplicationRecord
belongs_to :stream_session
+ before_validation :sync_board_type_from_match, on: :create
+
def as_cable_payload
- {
+ payload = {
type: "score_update",
+ board_type: effective_board_type,
home_sets: home_sets,
away_sets: away_sets,
home_points: home_points,
@@ -11,7 +14,74 @@ class ScoreState < ApplicationRecord
current_set: current_set,
set_partials: set_partials || [],
timeout_home: timeout_home,
- timeout_away: timeout_away
+ timeout_away: timeout_away,
+ period: period,
+ data: data || {}
}
+
+ case effective_board_type
+ when "basket", "timed"
+ payload[:home_points] = basket_home_score
+ payload[:away_points] = basket_away_score
+ payload[:period] = current_period_label
+ payload[:clock_secs] = clock_secs
+ payload[:clock_running] = clock_running?
+ when "timer"
+ payload[:clock_secs] = clock_secs
+ payload[:clock_running] = clock_running?
+ end
+
+ payload
+ end
+
+ def effective_board_type
+ board_type.presence || stream_session&.match&.effective_board_type || "volley"
+ end
+
+ def basket_home_score
+ (data || {})["home_score"].to_i
+ end
+
+ def basket_away_score
+ (data || {})["away_score"].to_i
+ end
+
+ def clock_secs
+ (data || {})["clock_secs"].to_i
+ end
+
+ def clock_running?
+ !!(data || {})["clock_running"]
+ end
+
+ def current_period
+ (data || {})["period"].to_i.positive? ? (data || {})["period"].to_i : 1
+ end
+
+ def current_period_label
+ case effective_board_type
+ when "basket"
+ ot = (data || {})["overtime"]
+ return "OT#{current_period - rules_periods}" if ot
+
+ "Q#{current_period}"
+ when "timed"
+ ot = (data || {})["overtime"]
+ return "Suppl." if ot
+
+ "#{current_period}° tempo"
+ else
+ period.presence || "#{current_set}° set"
+ end
+ end
+
+ private
+
+ def sync_board_type_from_match
+ self.board_type = stream_session.match.effective_board_type if board_type.blank? && stream_session&.match
+ end
+
+ def rules_periods
+ stream_session&.match&.effective_scoring_rules&.dig("periods").to_i
end
end
diff --git a/backend/app/models/team.rb b/backend/app/models/team.rb
index 72a893d..335af05 100644
--- a/backend/app/models/team.rb
+++ b/backend/app/models/team.rb
@@ -12,8 +12,12 @@ class Team < ApplicationRecord
has_one_attached :photo_file
validates :name, presence: true
+ validates :sport_key, presence: true
+ validate :sport_key_known
validate :photo_file_type, if: -> { photo_file.attached? }
+ before_validation :normalize_sport_key
+
def subscription
club.subscription
end
@@ -43,8 +47,35 @@ class Team < ApplicationRecord
end
end
+ def sport_label
+ Sports::Catalog.find_optional(sport_key)&.dig(:label) || sport_key.to_s.humanize
+ end
+
+ def effective_board_type
+ Sports::Catalog.board_for(sport_key)
+ end
+
+ def sport
+ sport_key
+ end
+
+ def sport=(value)
+ self.sport_key = Sports::Catalog.normalize_key(value)
+ end
+
private
+ def normalize_sport_key
+ self.sport_key = Sports::Catalog.normalize_key(sport_key) if sport_key.present?
+ end
+
+ def sport_key_known
+ return if sport_key.blank?
+ return if Sports::Catalog.find_optional(sport_key)
+
+ errors.add(:sport_key, "non valido")
+ end
+
def photo_file_type
return if photo_file.content_type.in?(%w[image/png image/jpeg image/webp])
diff --git a/backend/app/services/scoring/apply_action.rb b/backend/app/services/scoring/apply_action.rb
index 74a8d21..fa0d5a3 100644
--- a/backend/app/services/scoring/apply_action.rb
+++ b/backend/app/services/scoring/apply_action.rb
@@ -6,105 +6,10 @@ module Scoring
@session = session
@match = session.match
@action = action.to_s
- @rules = Rules.from_match(@match)
end
def call
- score = @session.score_state || @session.create_score_state!(
- home_sets: 0, away_sets: 0, home_points: 0, away_points: 0, current_set: 1, set_partials: []
- )
-
- case @action
- when "home_point"
- apply_point(score, :home)
- when "away_point"
- apply_point(score, :away)
- when "home_undo"
- apply_undo(score, :home)
- when "away_undo"
- apply_undo(score, :away)
- when "close_set"
- apply_close_set(score)
- else
- raise ArgumentError, "Azione non valida"
- end
- end
-
- private
-
- def apply_point(score, side)
- if side == :home
- score.home_points += 1
- else
- score.away_points += 1
- end
- score.save!
- broadcast(score)
- winner = @rules.set_winner_from_points(
- home_points: score.home_points,
- away_points: score.away_points,
- current_set: score.current_set
- )
- Result.new(score: score, set_won: winner.present?, match_won: false, winner: winner)
- end
-
- def apply_undo(score, side)
- if side == :home
- score.home_points = [score.home_points - 1, 0].max
- else
- score.away_points = [score.away_points - 1, 0].max
- end
- score.save!
- broadcast(score)
- Result.new(score: score, set_won: false, match_won: false, winner: nil)
- end
-
- def apply_close_set(score)
- winner = @rules.set_winner_from_points(
- home_points: score.home_points,
- away_points: score.away_points,
- current_set: score.current_set
- )
- winner ||= score.home_points > score.away_points ? :home : :away if score.home_points != score.away_points
- return Result.new(score: score, set_won: false, match_won: false, winner: nil) unless winner
-
- close_set_for(score, winner)
- end
-
- def close_set_for(score, winner)
- partials = Array(score.set_partials)
- partials << {
- "set" => score.current_set,
- "home" => score.home_points,
- "away" => score.away_points
- }
-
- if winner == :home
- score.home_sets += 1
- else
- score.away_sets += 1
- end
-
- match_winner = @rules.match_winner(home_sets: score.home_sets, away_sets: score.away_sets)
-
- score.assign_attributes(
- set_partials: partials,
- home_points: 0,
- away_points: 0,
- current_set: score.current_set + 1
- )
- score.save!
- broadcast(score)
- Result.new(
- score: score,
- set_won: true,
- match_won: match_winner.present?,
- winner: match_winner || winner
- )
- end
-
- def broadcast(score)
- SessionChannel.broadcast_message(@session, score.as_cable_payload)
+ Engine.for(@match).apply(session: @session, action: @action)
end
end
end
diff --git a/backend/app/services/scoring/engine.rb b/backend/app/services/scoring/engine.rb
new file mode 100644
index 0000000..e333c6e
--- /dev/null
+++ b/backend/app/services/scoring/engine.rb
@@ -0,0 +1,26 @@
+module Scoring
+ class Engine
+ ENGINES = {
+ "volley" => Engines::VolleyEngine,
+ "racket" => Engines::RacketEngine,
+ "generic" => Engines::GenericEngine,
+ "basket" => Engines::BasketEngine,
+ "timed" => Engines::TimedEngine,
+ "timer" => Engines::TimerEngine
+ }.freeze
+
+ def self.for(match)
+ board = match.effective_board_type
+ klass = ENGINES.fetch(board) { Engines::GenericEngine }
+ klass.new(match: match)
+ end
+
+ def self.for_session(session)
+ for(session.match)
+ end
+
+ def self.ensure_score_for(session)
+ for(session.match).ensure_score(session)
+ end
+ end
+end
diff --git a/backend/app/services/scoring/engines/base_engine.rb b/backend/app/services/scoring/engines/base_engine.rb
new file mode 100644
index 0000000..ed2132b
--- /dev/null
+++ b/backend/app/services/scoring/engines/base_engine.rb
@@ -0,0 +1,45 @@
+module Scoring
+ module Engines
+ class BaseEngine
+ Result = Scoring::ApplyAction::Result
+
+ def initialize(match:)
+ @match = match
+ end
+
+ def apply(session:, action:)
+ raise NotImplementedError
+ end
+
+ def sync(session:, payload:)
+ raise NotImplementedError
+ end
+
+ def ensure_score(session)
+ board = @match.effective_board_type
+ score = session.score_state
+ return score if score
+
+ attrs = {
+ board_type: board,
+ home_sets: 0,
+ away_sets: 0,
+ home_points: 0,
+ away_points: 0,
+ current_set: 1,
+ set_partials: [],
+ data: default_data
+ }
+ session.create_score_state!(attrs)
+ end
+
+ def default_data
+ {}
+ end
+
+ def broadcast(session, score)
+ SessionChannel.broadcast_message(session, score.as_cable_payload)
+ end
+ end
+ end
+end
diff --git a/backend/app/services/scoring/engines/basket_engine.rb b/backend/app/services/scoring/engines/basket_engine.rb
new file mode 100644
index 0000000..610c604
--- /dev/null
+++ b/backend/app/services/scoring/engines/basket_engine.rb
@@ -0,0 +1,32 @@
+module Scoring
+ module Engines
+ class BasketEngine < BaseEngine
+ include PeriodEngineMixin
+
+ def apply(session:, action:)
+ score = ensure_score(session)
+
+ case action.to_s
+ when "home_point" then adjust_score(score, :home, 1)
+ when "away_point" then adjust_score(score, :away, 1)
+ when "home_undo" then adjust_score(score, :home, -1)
+ when "away_undo" then adjust_score(score, :away, -1)
+ when "home_point_2" then adjust_score(score, :home, 2)
+ when "away_point_2" then adjust_score(score, :away, 2)
+ when "home_point_3" then adjust_score(score, :home, 3)
+ when "away_point_3" then adjust_score(score, :away, 3)
+ when "advance_period" then advance_period(score)
+ when "clock_toggle" then toggle_clock(score)
+ when "clock_reset" then reset_clock(score)
+ when "clock_tick" then tick_clock(score)
+ else
+ raise ArgumentError, "Azione non valida"
+ end
+ end
+
+ def sync(session:, payload:)
+ sync_period(session: session, payload: payload.stringify_keys)
+ end
+ end
+ end
+end
diff --git a/backend/app/services/scoring/engines/generic_engine.rb b/backend/app/services/scoring/engines/generic_engine.rb
new file mode 100644
index 0000000..6953f37
--- /dev/null
+++ b/backend/app/services/scoring/engines/generic_engine.rb
@@ -0,0 +1,42 @@
+module Scoring
+ module Engines
+ class GenericEngine < BaseEngine
+ def apply(session:, action:)
+ score = ensure_score(session)
+
+ case action.to_s
+ when "home_point" then adjust(score, :home, 1)
+ when "away_point" then adjust(score, :away, 1)
+ when "home_undo" then adjust(score, :home, -1)
+ when "away_undo" then adjust(score, :away, -1)
+ else
+ raise ArgumentError, "Azione non valida"
+ end
+ end
+
+ def sync(session:, payload:)
+ score = ensure_score(session)
+ attrs = {}
+ %w[home_points away_points].each do |key|
+ attrs[key] = payload[key] if payload.key?(key)
+ end
+ score.update!(attrs)
+ broadcast(session, score)
+ score
+ end
+
+ private
+
+ def adjust(score, side, delta)
+ if side == :home
+ score.home_points = [score.home_points + delta, 0].max
+ else
+ score.away_points = [score.away_points + delta, 0].max
+ end
+ score.save!
+ broadcast(score.stream_session, score)
+ Result.new(score: score, set_won: false, match_won: false, winner: nil)
+ end
+ end
+ end
+end
diff --git a/backend/app/services/scoring/engines/period_engine_mixin.rb b/backend/app/services/scoring/engines/period_engine_mixin.rb
new file mode 100644
index 0000000..b5005e1
--- /dev/null
+++ b/backend/app/services/scoring/engines/period_engine_mixin.rb
@@ -0,0 +1,117 @@
+module Scoring
+ module Engines
+ module PeriodEngineMixin
+ private
+
+ def period_rules
+ @period_rules ||= Rules.from_match(@match)
+ end
+
+ def period_data(score)
+ score.data.is_a?(Hash) ? score.data.deep_dup : {}
+ end
+
+ def default_data
+ rules = period_rules
+ {
+ "period" => 1,
+ "clock_secs" => rules.period_duration_secs,
+ "clock_running" => false,
+ "home_score" => 0,
+ "away_score" => 0,
+ "overtime" => false
+ }
+ end
+
+ def ensure_period_data(score)
+ data = period_data(score)
+ default_data.each { |k, v| data[k] = v unless data.key?(k) }
+ data
+ end
+
+ def save_period_score!(score, data)
+ score.assign_attributes(
+ data: data,
+ home_points: data["home_score"].to_i,
+ away_points: data["away_score"].to_i,
+ period: period_label(data)
+ )
+ score.save!
+ broadcast(score.stream_session, score)
+ end
+
+ def period_label(data)
+ period = data["period"].to_i
+ return "OT" if data["overtime"]
+
+ board = @match.effective_board_type
+ board == "basket" ? "Q#{period}" : "#{period}° tempo"
+ end
+
+ def adjust_score(score, side, delta)
+ data = ensure_period_data(score)
+ key = side == :home ? "home_score" : "away_score"
+ data[key] = [data[key].to_i + delta, 0].max
+ save_period_score!(score, data)
+ Result.new(score: score, set_won: false, match_won: false, winner: nil)
+ end
+
+ def advance_period(score)
+ data = ensure_period_data(score)
+ rules = period_rules
+ max_periods = rules.periods
+
+ if data["overtime"]
+ data["period"] = data["period"].to_i + 1
+ data["clock_secs"] = rules.overtime_duration_secs
+ elsif data["period"].to_i >= max_periods
+ data["overtime"] = true
+ data["period"] = max_periods + 1
+ data["clock_secs"] = rules.overtime_duration_secs
+ else
+ data["period"] = data["period"].to_i + 1
+ data["clock_secs"] = rules.period_duration_secs
+ end
+ data["clock_running"] = false
+ save_period_score!(score, data)
+ Result.new(score: score, set_won: false, match_won: false, winner: nil)
+ end
+
+ def toggle_clock(score)
+ data = ensure_period_data(score)
+ data["clock_running"] = !data["clock_running"]
+ save_period_score!(score, data)
+ Result.new(score: score, set_won: false, match_won: false, winner: nil)
+ end
+
+ def reset_clock(score)
+ data = ensure_period_data(score)
+ rules = period_rules
+ duration = data["overtime"] ? rules.overtime_duration_secs : rules.period_duration_secs
+ data["clock_secs"] = duration
+ data["clock_running"] = false
+ save_period_score!(score, data)
+ Result.new(score: score, set_won: false, match_won: false, winner: nil)
+ end
+
+ def tick_clock(score, secs: 1)
+ data = ensure_period_data(score)
+ return Result.new(score: score, set_won: false, match_won: false, winner: nil) unless data["clock_running"]
+
+ data["clock_secs"] = [data["clock_secs"].to_i - secs, 0].max
+ save_period_score!(score, data)
+ Result.new(score: score, set_won: false, match_won: false, winner: nil)
+ end
+
+ def sync_period(session:, payload:)
+ score = ensure_score(session)
+ data = ensure_period_data(score)
+ %w[period clock_secs clock_running home_score away_score overtime].each do |key|
+ data[key] = payload[key] if payload.key?(key)
+ end
+ save_period_score!(score, data)
+ score
+ end
+ end
+ end
+end
diff --git a/backend/app/services/scoring/engines/racket_engine.rb b/backend/app/services/scoring/engines/racket_engine.rb
new file mode 100644
index 0000000..330259a
--- /dev/null
+++ b/backend/app/services/scoring/engines/racket_engine.rb
@@ -0,0 +1,7 @@
+module Scoring
+ module Engines
+ # Fase 1: stessa logica del volley (set + punti).
+ class RacketEngine < VolleyEngine
+ end
+ end
+end
diff --git a/backend/app/services/scoring/engines/timed_engine.rb b/backend/app/services/scoring/engines/timed_engine.rb
new file mode 100644
index 0000000..357c7da
--- /dev/null
+++ b/backend/app/services/scoring/engines/timed_engine.rb
@@ -0,0 +1,28 @@
+module Scoring
+ module Engines
+ class TimedEngine < BaseEngine
+ include PeriodEngineMixin
+
+ def apply(session:, action:)
+ score = ensure_score(session)
+
+ case action.to_s
+ when "home_point" then adjust_score(score, :home, 1)
+ when "away_point" then adjust_score(score, :away, 1)
+ when "home_undo" then adjust_score(score, :home, -1)
+ when "away_undo" then adjust_score(score, :away, -1)
+ when "advance_period" then advance_period(score)
+ when "clock_toggle" then toggle_clock(score)
+ when "clock_reset" then reset_clock(score)
+ when "clock_tick" then tick_clock(score)
+ else
+ raise ArgumentError, "Azione non valida"
+ end
+ end
+
+ def sync(session:, payload:)
+ sync_period(session: session, payload: payload.stringify_keys)
+ end
+ end
+ end
+end
diff --git a/backend/app/services/scoring/engines/timer_engine.rb b/backend/app/services/scoring/engines/timer_engine.rb
new file mode 100644
index 0000000..20e8ea5
--- /dev/null
+++ b/backend/app/services/scoring/engines/timer_engine.rb
@@ -0,0 +1,49 @@
+module Scoring
+ module Engines
+ class TimerEngine < BaseEngine
+ def apply(session:, action:)
+ score = ensure_score(session)
+ data = timer_data(score)
+
+ case action.to_s
+ when "clock_toggle"
+ data["clock_running"] = !data["clock_running"]
+ when "clock_reset"
+ data["clock_secs"] = 0
+ data["clock_running"] = false
+ when "clock_tick"
+ data["clock_secs"] = data["clock_secs"].to_i + 1 if data["clock_running"]
+ else
+ raise ArgumentError, "Azione non valida"
+ end
+
+ score.update!(data: data)
+ broadcast(session, score)
+ Result.new(score: score, set_won: false, match_won: false, winner: nil)
+ end
+
+ def sync(session:, payload:)
+ score = ensure_score(session)
+ data = timer_data(score)
+ %w[clock_secs clock_running].each do |key|
+ data[key] = payload[key] if payload.key?(key)
+ end
+ score.update!(data: data)
+ broadcast(session, score)
+ score
+ end
+
+ private
+
+ def default_data
+ { "clock_secs" => 0, "clock_running" => false }
+ end
+
+ def timer_data(score)
+ base = default_data
+ stored = score.data.is_a?(Hash) ? score.data : {}
+ base.merge(stored)
+ end
+ end
+ end
+end
diff --git a/backend/app/services/scoring/engines/volley_engine.rb b/backend/app/services/scoring/engines/volley_engine.rb
new file mode 100644
index 0000000..64c8573
--- /dev/null
+++ b/backend/app/services/scoring/engines/volley_engine.rb
@@ -0,0 +1,105 @@
+module Scoring
+ module Engines
+ class VolleyEngine < BaseEngine
+ def apply(session:, action:)
+ @rules = Rules.from_match(@match)
+ score = ensure_score(session)
+
+ case action.to_s
+ when "home_point" then apply_point(score, :home)
+ when "away_point" then apply_point(score, :away)
+ when "home_undo" then apply_undo(score, :home)
+ when "away_undo" then apply_undo(score, :away)
+ when "close_set" then apply_close_set(score)
+ else
+ raise ArgumentError, "Azione non valida"
+ end
+ end
+
+ def sync(session:, payload:)
+ score = ensure_score(session)
+ attrs = {}
+ %w[home_sets away_sets home_points away_points current_set].each do |key|
+ attrs[key] = payload[key] if payload.key?(key)
+ end
+ attrs[:set_partials] = payload["set_partials"] if payload.key?("set_partials")
+ score.update!(attrs)
+ broadcast(session, score)
+ score
+ end
+
+ private
+
+ def apply_point(score, side)
+ if side == :home
+ score.home_points += 1
+ else
+ score.away_points += 1
+ end
+ score.save!
+ broadcast(score.stream_session, score)
+ winner = @rules.set_winner_from_points(
+ home_points: score.home_points,
+ away_points: score.away_points,
+ current_set: score.current_set
+ )
+ Result.new(score: score, set_won: winner.present?, match_won: false, winner: winner)
+ end
+
+ def apply_undo(score, side)
+ if side == :home
+ score.home_points = [score.home_points - 1, 0].max
+ else
+ score.away_points = [score.away_points - 1, 0].max
+ end
+ score.save!
+ broadcast(score.stream_session, score)
+ Result.new(score: score, set_won: false, match_won: false, winner: nil)
+ end
+
+ def apply_close_set(score)
+ winner = @rules.set_winner_from_points(
+ home_points: score.home_points,
+ away_points: score.away_points,
+ current_set: score.current_set
+ )
+ winner ||= score.home_points > score.away_points ? :home : :away if score.home_points != score.away_points
+ return Result.new(score: score, set_won: false, match_won: false, winner: nil) unless winner
+
+ close_set_for(score, winner)
+ end
+
+ def close_set_for(score, winner)
+ partials = Array(score.set_partials)
+ partials << {
+ "set" => score.current_set,
+ "home" => score.home_points,
+ "away" => score.away_points
+ }
+
+ if winner == :home
+ score.home_sets += 1
+ else
+ score.away_sets += 1
+ end
+
+ match_winner = @rules.match_winner(home_sets: score.home_sets, away_sets: score.away_sets)
+
+ score.assign_attributes(
+ set_partials: partials,
+ home_points: 0,
+ away_points: 0,
+ current_set: score.current_set + 1
+ )
+ score.save!
+ broadcast(score.stream_session, score)
+ Result.new(
+ score: score,
+ set_won: true,
+ match_won: match_winner.present?,
+ winner: match_winner || winner
+ )
+ end
+ end
+ end
+end
diff --git a/backend/app/services/scoring/rules.rb b/backend/app/services/scoring/rules.rb
index bebaeda..054c502 100644
--- a/backend/app/services/scoring/rules.rb
+++ b/backend/app/services/scoring/rules.rb
@@ -3,13 +3,31 @@ module Scoring
Side = Struct.new(:home, :away, keyword_init: true)
def self.from_match(match)
- overrides = match.scoring_rules.is_a?(Hash) ? match.scoring_rules : {}
- new(
- sets_to_win: match.sets_to_win,
- points_per_set: overrides["points_per_set"] || overrides[:points_per_set] || 25,
- points_deciding_set: overrides["points_deciding_set"] || overrides[:points_deciding_set] || 15,
- min_point_lead: overrides["min_point_lead"] || overrides[:min_point_lead] || 2
- )
+ rules = match.effective_scoring_rules
+ board = match.effective_board_type
+
+ case board
+ when "volley", "racket"
+ new(
+ sets_to_win: rules["sets_to_win"].to_i,
+ points_per_set: rules["points_per_set"].to_i,
+ points_deciding_set: rules["points_deciding_set"].to_i,
+ min_point_lead: rules["min_point_lead"].to_i
+ )
+ when "basket", "timed"
+ PeriodRules.new(
+ periods: rules["periods"].to_i,
+ period_duration_secs: rules["period_duration_secs"].to_i,
+ overtime_duration_secs: rules["overtime_duration_secs"].to_i
+ )
+ else
+ new(
+ sets_to_win: rules["sets_to_win"].to_i.positive? ? rules["sets_to_win"].to_i : 3,
+ points_per_set: rules["points_per_set"].to_i.positive? ? rules["points_per_set"].to_i : 25,
+ points_deciding_set: rules["points_deciding_set"].to_i.positive? ? rules["points_deciding_set"].to_i : 15,
+ min_point_lead: rules["min_point_lead"].to_i.positive? ? rules["min_point_lead"].to_i : 2
+ )
+ end
end
def initialize(sets_to_win:, points_per_set: 25, points_deciding_set: 15, min_point_lead: 2)
@@ -42,5 +60,15 @@ module Scoring
nil
end
+
+ class PeriodRules
+ attr_reader :periods, :period_duration_secs, :overtime_duration_secs
+
+ def initialize(periods:, period_duration_secs:, overtime_duration_secs: 300)
+ @periods = periods
+ @period_duration_secs = period_duration_secs
+ @overtime_duration_secs = overtime_duration_secs
+ end
+ end
end
end
diff --git a/backend/app/services/scoring/sync_state.rb b/backend/app/services/scoring/sync_state.rb
index 7a90799..3e77cfa 100644
--- a/backend/app/services/scoring/sync_state.rb
+++ b/backend/app/services/scoring/sync_state.rb
@@ -1,27 +1,13 @@
module Scoring
# Sincronizza lo stato completo del tabellone (app mobile / Action Cable).
class SyncState
- SCORE_KEYS = %w[home_sets away_sets home_points away_points current_set].freeze
-
def initialize(session:, payload:)
@session = session
@payload = payload.stringify_keys
end
def call
- score = @session.score_state || @session.create_score_state!(
- home_sets: 0, away_sets: 0, home_points: 0, away_points: 0, current_set: 1, set_partials: []
- )
-
- attrs = {}
- SCORE_KEYS.each do |key|
- attrs[key] = @payload[key] if @payload.key?(key)
- end
- attrs[:set_partials] = @payload["set_partials"] if @payload.key?("set_partials")
-
- score.update!(attrs)
- SessionChannel.broadcast_message(@session, score.as_cable_payload)
- score
+ Engine.for(@session.match).sync(session: @session, payload: @payload)
end
end
end
diff --git a/backend/app/views/public/regia/_basket.html.erb b/backend/app/views/public/regia/_basket.html.erb
new file mode 100644
index 0000000..eeb6f72
--- /dev/null
+++ b/backend/app/views/public/regia/_basket.html.erb
@@ -0,0 +1,30 @@
+<% data = @score.data || {} %>
+
<%= live_score_period_label(@match, @score) %>
+<%= live_score_points_label(@match, @score) %>
+
+Cronometro: <%= format_clock_secs(data["clock_secs"]) %>
+
+
+
+
<%= @team.name %>
+
<%= data["home_score"] || @score.home_points %>
+
+
+
+
+
+
<%= format_clock_secs(data["clock_secs"]) %>
+
+
<%= @match.opponent_name %>
+
<%= data["away_score"] || @score.away_points %>
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/app/views/public/regia/_generic.html.erb b/backend/app/views/public/regia/_generic.html.erb
new file mode 100644
index 0000000..f05a6cb
--- /dev/null
+++ b/backend/app/views/public/regia/_generic.html.erb
@@ -0,0 +1,19 @@
+Punteggio libero
+<%= live_score_points_label(@match, @score) %>
+
+
+
+
+
<%= @team.name %>
+
<%= @score.home_points %>
+
+
+
+
—
+
+
<%= @match.opponent_name %>
+
<%= @score.away_points %>
+
+
+
+
diff --git a/backend/app/views/public/regia/_racket.html.erb b/backend/app/views/public/regia/_racket.html.erb
new file mode 100644
index 0000000..4b1a065
--- /dev/null
+++ b/backend/app/views/public/regia/_racket.html.erb
@@ -0,0 +1 @@
+<%= render "public/regia/volley" %>
diff --git a/backend/app/views/public/regia/_timed.html.erb b/backend/app/views/public/regia/_timed.html.erb
new file mode 100644
index 0000000..6fde355
--- /dev/null
+++ b/backend/app/views/public/regia/_timed.html.erb
@@ -0,0 +1,26 @@
+<% data = @score.data || {} %>
+<%= live_score_period_label(@match, @score) %>
+<%= live_score_points_label(@match, @score) %>
+
+Cronometro: <%= format_clock_secs(data["clock_secs"]) %>
+
+
+
+
<%= @team.name %>
+
<%= data["home_score"] || @score.home_points %>
+
+
+
+
<%= format_clock_secs(data["clock_secs"]) %>
+
+
<%= @match.opponent_name %>
+
<%= data["away_score"] || @score.away_points %>
+
+
+
+
+
+
+
+
+
diff --git a/backend/app/views/public/regia/_timer.html.erb b/backend/app/views/public/regia/_timer.html.erb
new file mode 100644
index 0000000..6fe52af
--- /dev/null
+++ b/backend/app/views/public/regia/_timer.html.erb
@@ -0,0 +1,10 @@
+<% data = @score.data || {} %>
+Cronometro
+
+
+<%= format_clock_secs(data["clock_secs"], count_up: true) %>
+
+
+
+
+
diff --git a/backend/app/views/public/regia/_volley.html.erb b/backend/app/views/public/regia/_volley.html.erb
new file mode 100644
index 0000000..ee4c053
--- /dev/null
+++ b/backend/app/views/public/regia/_volley.html.erb
@@ -0,0 +1,24 @@
+<%= live_score_sets_label(@score) || "—" %>
+<%= live_score_points_label(@match, @score) %>
+>
+ Parziali: <%= live_score_partials_label(@score) %>
+
+
+
+
+
<%= @team.name %>
+
<%= @score.home_points %>
+
+
+
+
+ <%= Scoring::Rules.from_match(@match).points_target(@score.current_set) %> pt
+
+
+
<%= @match.opponent_name %>
+
<%= @score.away_points %>
+
+
+
+
+
diff --git a/backend/app/views/public/regia/show.html.erb b/backend/app/views/public/regia/show.html.erb
index 42ef9a3..8e3dd0d 100644
--- a/backend/app/views/public/regia/show.html.erb
+++ b/backend/app/views/public/regia/show.html.erb
@@ -9,6 +9,7 @@
- <%= live_score_sets_label(@score) || "—" %>
- <%= live_score_points_label(@match, @score) %>
- >
- Parziali: <%= live_score_partials_label(@score) %>
-
-
-
-
-
<%= @team.name %>
-
<%= @score.home_points %>
-
-
-
-
- <%= Scoring::Rules.from_match(@match).points_target(@score.current_set) %> pt
-
-
-
<%= @match.opponent_name %>
-
<%= @score.away_points %>
-
-
-
-
-
+ <%= render regia_board_partial(@match) %>
<% unless @stream_closed %>
@@ -96,4 +74,4 @@
-
+
diff --git a/backend/config/application.rb b/backend/config/application.rb
index 8387b2a..41700da 100644
--- a/backend/config/application.rb
+++ b/backend/config/application.rb
@@ -22,6 +22,8 @@ module App
class Application < Rails::Application
config.load_defaults 7.2
config.autoload_lib(ignore: %w[assets tasks])
+ config.autoload_paths << Rails.root.join("app/domain")
+ config.eager_load_paths << Rails.root.join("app/domain")
config.api_only = false
config.active_job.queue_adapter = :sidekiq
config.time_zone = "Europe/Rome"
diff --git a/backend/config/routes.rb b/backend/config/routes.rb
index 7f4e58d..7f949dd 100644
--- a/backend/config/routes.rb
+++ b/backend/config/routes.rb
@@ -10,6 +10,7 @@ Rails.application.routes.draw do
post "auth/logout", to: "auth#logout"
post "auth/refresh", to: "auth#refresh"
get "auth/me", to: "auth#me"
+ get "sports", to: "sports#index"
get "invitations/:token", to: "invitations#show"
post "invitations/:token/accept", to: "invitations#accept"
diff --git a/backend/config/sports.yml b/backend/config/sports.yml
new file mode 100644
index 0000000..b934261
--- /dev/null
+++ b/backend/config/sports.yml
@@ -0,0 +1,183 @@
+# Catalogo sport Match Live TV — board e overlay impliciti, regole standard.
+# board: volley | basket | timed | racket | timer | generic
+# overlay: none | timer | volley | basket | timed | racket
+
+pallavolo:
+ label: Pallavolo
+ board: volley
+ overlay: volley
+ allowed_overlays: [volley, timer, none]
+ default_rules:
+ sets_to_win: 3
+ points_per_set: 25
+ points_deciding_set: 15
+ min_point_lead: 2
+
+beach_volley:
+ label: Beach Volley
+ board: volley
+ overlay: volley
+ allowed_overlays: [volley, timer, none]
+ default_rules:
+ sets_to_win: 2
+ points_per_set: 21
+ points_deciding_set: 15
+ min_point_lead: 2
+
+sitting_volley:
+ label: Sitting Volley
+ board: volley
+ overlay: volley
+ allowed_overlays: [volley, timer, none]
+ default_rules:
+ sets_to_win: 3
+ points_per_set: 25
+ points_deciding_set: 15
+ min_point_lead: 2
+
+basket:
+ label: Basket
+ board: basket
+ overlay: basket
+ allowed_overlays: [basket, timer, none]
+ default_rules:
+ periods: 4
+ period_duration_secs: 600
+ overtime_duration_secs: 300
+
+calcio_a_5:
+ label: Calcio a 5
+ board: timed
+ overlay: timed
+ allowed_overlays: [timed, timer, none]
+ default_rules:
+ periods: 2
+ period_duration_secs: 1200
+ overtime_duration_secs: 300
+
+pallamano:
+ label: Pallamano
+ board: timed
+ overlay: timed
+ allowed_overlays: [timed, timer, none]
+ default_rules:
+ periods: 2
+ period_duration_secs: 1800
+ overtime_duration_secs: 300
+
+hockey_indoor:
+ label: Hockey Indoor
+ board: timed
+ overlay: timed
+ allowed_overlays: [timed, timer, none]
+ default_rules:
+ periods: 2
+ period_duration_secs: 1200
+ overtime_duration_secs: 300
+
+floorball:
+ label: Floorball
+ board: timed
+ overlay: timed
+ allowed_overlays: [timed, timer, none]
+ default_rules:
+ periods: 3
+ period_duration_secs: 1200
+ overtime_duration_secs: 300
+
+tennis:
+ label: Tennis
+ board: racket
+ overlay: racket
+ allowed_overlays: [racket, timer, none]
+ default_rules:
+ sets_to_win: 2
+ points_per_set: 6
+ points_deciding_set: 7
+ min_point_lead: 2
+
+tennis_tavolo:
+ label: Tennis Tavolo
+ board: racket
+ overlay: racket
+ allowed_overlays: [racket, timer, none]
+ default_rules:
+ sets_to_win: 3
+ points_per_set: 11
+ points_deciding_set: 11
+ min_point_lead: 2
+
+badminton:
+ label: Badminton
+ board: racket
+ overlay: racket
+ allowed_overlays: [racket, timer, none]
+ default_rules:
+ sets_to_win: 2
+ points_per_set: 21
+ points_deciding_set: 21
+ min_point_lead: 2
+
+padel:
+ label: Padel
+ board: racket
+ overlay: racket
+ allowed_overlays: [racket, timer, none]
+ default_rules:
+ sets_to_win: 2
+ points_per_set: 6
+ points_deciding_set: 7
+ min_point_lead: 2
+
+pickleball:
+ label: Pickleball
+ board: racket
+ overlay: racket
+ allowed_overlays: [racket, timer, none]
+ default_rules:
+ sets_to_win: 2
+ points_per_set: 11
+ points_deciding_set: 11
+ min_point_lead: 2
+
+ginnastica_artistica:
+ label: Ginnastica Artistica
+ board: timer
+ overlay: timer
+ allowed_overlays: [timer, none]
+ default_rules: {}
+
+ginnastica_ritmica:
+ label: Ginnastica Ritmica
+ board: timer
+ overlay: timer
+ allowed_overlays: [timer, none]
+ default_rules: {}
+
+danza_sportiva:
+ label: Danza Sportiva
+ board: timer
+ overlay: timer
+ allowed_overlays: [timer, none]
+ default_rules: {}
+
+cheerleading:
+ label: Cheerleading
+ board: timer
+ overlay: timer
+ allowed_overlays: [timer, none]
+ default_rules: {}
+
+pattinaggio:
+ label: Pattinaggio
+ board: timer
+ overlay: timer
+ allowed_overlays: [timer, none]
+ default_rules: {}
+
+altro:
+ label: Altro
+ board: generic
+ overlay: none
+ allowed_overlays: [none, timer]
+ default_rules: {}
diff --git a/backend/db/migrate/20260609100000_multi_sport.rb b/backend/db/migrate/20260609100000_multi_sport.rb
new file mode 100644
index 0000000..e641abb
--- /dev/null
+++ b/backend/db/migrate/20260609100000_multi_sport.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+class MultiSport < ActiveRecord::Migration[7.2]
+ def up
+ rename_column :teams, :sport, :sport_key
+ rename_column :matches, :sport, :sport_key
+
+ change_column_default :teams, :sport_key, from: "volleyball", to: "pallavolo"
+ change_column_default :matches, :sport_key, from: "volleyball", to: "pallavolo"
+
+ execute <<~SQL.squish
+ UPDATE teams SET sport_key = 'pallavolo' WHERE sport_key = 'volleyball' OR sport_key IS NULL OR sport_key = '';
+ UPDATE matches SET sport_key = 'pallavolo' WHERE sport_key = 'volleyball' OR sport_key IS NULL OR sport_key = '';
+ SQL
+
+ add_column :matches, :overlay_kind, :string
+ add_column :score_states, :board_type, :string, null: false, default: "volley"
+ add_column :score_states, :data, :jsonb, null: false, default: {}
+
+ execute <<~SQL.squish
+ UPDATE matches
+ SET scoring_rules = scoring_rules || jsonb_build_object('sets_to_win', sets_to_win)
+ WHERE sets_to_win IS NOT NULL
+ AND NOT (scoring_rules ? 'sets_to_win');
+ SQL
+
+ execute <<~SQL.squish
+ UPDATE score_states ss
+ SET board_type = 'volley'
+ FROM stream_sessions s
+ JOIN matches m ON m.id = s.match_id
+ WHERE ss.stream_session_id = s.id;
+ SQL
+ end
+
+ def down
+ execute <<~SQL.squish
+ UPDATE teams SET sport_key = 'volleyball' WHERE sport_key = 'pallavolo';
+ UPDATE matches SET sport_key = 'volleyball' WHERE sport_key = 'pallavolo';
+ SQL
+
+ change_column_default :teams, :sport_key, from: "pallavolo", to: "volleyball"
+ change_column_default :matches, :sport_key, from: "pallavolo", to: "volleyball"
+
+ rename_column :teams, :sport_key, :sport
+ rename_column :matches, :sport_key, :sport
+
+ remove_column :matches, :overlay_kind
+ remove_column :score_states, :board_type
+ remove_column :score_states, :data
+ end
+end
diff --git a/backend/public/regia.css b/backend/public/regia.css
index 03fa9a5..e07fdc7 100644
--- a/backend/public/regia.css
+++ b/backend/public/regia.css
@@ -223,3 +223,23 @@
.regia-modal__card h2 { margin: 0 0 8px; font-size: 1.1rem; }
.regia-modal__card p { margin: 0 0 16px; color: var(--regia-muted); font-size: 0.9rem; }
.regia-modal__actions { display: flex; flex-direction: column; gap: 8px; }
+
+.regia-center-hint {
+ text-align: center;
+ color: var(--regia-muted);
+ font-size: 0.72rem;
+ padding-top: 24px;
+}
+
+.regia-clock-line {
+ margin: 0 0 12px;
+ color: var(--regia-muted);
+ font-size: 0.9rem;
+}
+
+.regia-clock-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 12px;
+}
diff --git a/backend/public/regia.js b/backend/public/regia.js
index e8aba6a..d8e3d37 100644
--- a/backend/public/regia.js
+++ b/backend/public/regia.js
@@ -4,6 +4,7 @@
const cfg = {
token: root.dataset.token,
+ board: root.dataset.board || "volley",
statusUrl: root.dataset.statusUrl,
scoreUrl: root.dataset.scoreUrl,
pauseUrl: root.dataset.pauseUrl,
@@ -88,26 +89,52 @@
.join(" · ");
}
+ function formatClock(secs, countUp) {
+ const total = parseInt(secs, 10) || 0;
+ if (total <= 0 && !countUp) return "0:00";
+ const m = Math.floor(total / 60);
+ const s = total % 60;
+ return `${m}:${String(s).padStart(2, "0")}`;
+ }
+
function applyScorePayload(data) {
const s = data.score;
if (!s) return;
- const homePts = s.home_points ?? s.homePoints;
- const awayPts = s.away_points ?? s.awayPoints;
+ const board = s.board_type || s.boardType || cfg.board;
+ const homePts = s.home_points ?? s.homePoints ?? 0;
+ const awayPts = s.away_points ?? s.awayPoints ?? 0;
const homeSets = s.home_sets ?? s.homeSets;
const awaySets = s.away_sets ?? s.awaySets;
const currentSet = s.current_set ?? s.currentSet;
-
+ const period = s.period;
+ const clockSecs = s.clock_secs ?? s.clockSecs ?? s.data?.clock_secs;
const awayEl = document.getElementById("away-points");
- els.homePoints.textContent = homePts;
- if (awayEl) awayEl.textContent = awayPts;
- els.setsLine.textContent = `Set ${currentSet} · Set vinti ${homeSets}-${awaySets}`;
- els.scoreLine.textContent = `${cfg.homeName} ${homePts} - ${awayPts} ${cfg.awayName}`;
+ const centerHint = document.getElementById("center-hint");
+ const clockLine = document.getElementById("clock-line");
- const partialsText = formatPartials(s.set_partials || s.setPartials);
- if (partialsText) {
- els.partialsLine.textContent = `Parziali: ${partialsText}`;
- els.partialsLine.hidden = false;
+ if (els.homePoints) els.homePoints.textContent = homePts;
+ if (awayEl) awayEl.textContent = awayPts;
+
+ if (board === "basket" || board === "timed") {
+ if (els.setsLine) els.setsLine.textContent = period || "—";
+ if (els.scoreLine) els.scoreLine.textContent = `${cfg.homeName} ${homePts} - ${awayPts} ${cfg.awayName}`;
+ if (centerHint) centerHint.textContent = formatClock(clockSecs);
+ if (clockLine) clockLine.textContent = `Cronometro: ${formatClock(clockSecs)}`;
+ } else if (board === "timer") {
+ if (els.setsLine) els.setsLine.textContent = "Cronometro";
+ if (clockLine) clockLine.textContent = formatClock(clockSecs, true);
+ } else if (board === "generic") {
+ if (els.setsLine) els.setsLine.textContent = "Punteggio libero";
+ if (els.scoreLine) els.scoreLine.textContent = `${cfg.homeName} ${homePts} - ${awayPts} ${cfg.awayName}`;
+ } else {
+ if (els.setsLine) els.setsLine.textContent = `Set ${currentSet} · Set vinti ${homeSets}-${awaySets}`;
+ if (els.scoreLine) els.scoreLine.textContent = `${cfg.homeName} ${homePts} - ${awayPts} ${cfg.awayName}`;
+ const partialsText = formatPartials(s.set_partials || s.setPartials);
+ if (els.partialsLine && partialsText) {
+ els.partialsLine.textContent = `Parziali: ${partialsText}`;
+ els.partialsLine.hidden = false;
+ }
}
}
@@ -157,10 +184,13 @@
applyScorePayload(data);
setBadge(data);
- if (data.set_won && action !== "close_set") {
- const winner = data.winner === "home" ? cfg.homeName : cfg.awayName;
- openModal("Set vinto", `${winner} ha vinto il set. Chiudere il set?`, () => postScore("close_set"));
- } else if (data.match_won) {
+ if (cfg.board === "volley" || cfg.board === "racket") {
+ if (data.set_won && action !== "close_set") {
+ const winner = data.winner === "home" ? cfg.homeName : cfg.awayName;
+ openModal("Set vinto", `${winner} ha vinto il set. Chiudere il set?`, () => postScore("close_set"));
+ }
+ }
+ if (data.match_won) {
const winner = data.winner === "home" ? cfg.homeName : cfg.awayName;
openModal("Partita vinta", `${winner} ha vinto la partita. Chiudere la diretta?`, () => stopStream());
}
diff --git a/backend/spec/domain/sports/catalog_spec.rb b/backend/spec/domain/sports/catalog_spec.rb
new file mode 100644
index 0000000..348000d
--- /dev/null
+++ b/backend/spec/domain/sports/catalog_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe Sports::Catalog do
+ describe ".find" do
+ it "restituisce pallavolo con board volley" do
+ entry = described_class.find("pallavolo")
+ expect(entry[:board]).to eq("volley")
+ expect(entry[:overlay]).to eq("volley")
+ expect(entry[:default_rules]["sets_to_win"]).to eq(3)
+ end
+
+ it "mappa legacy volleyball" do
+ expect(described_class.normalize_key("volleyball")).to eq("pallavolo")
+ end
+
+ it "altro usa board generic" do
+ entry = described_class.find("altro")
+ expect(entry[:board]).to eq("generic")
+ expect(entry[:overlay]).to eq("none")
+ end
+ end
+
+ describe ".as_api_list" do
+ it "include tutti gli sport configurati" do
+ keys = described_class.as_api_list.map { |e| e[:key] }
+ expect(keys).to include("pallavolo", "basket", "calcio_a_5", "tennis", "ginnastica_artistica", "altro")
+ end
+ end
+end
diff --git a/backend/spec/requests/api/v1/sports_spec.rb b/backend/spec/requests/api/v1/sports_spec.rb
new file mode 100644
index 0000000..1597d14
--- /dev/null
+++ b/backend/spec/requests/api/v1/sports_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe "Api::V1::Sports", type: :request do
+ let!(:user) { User.create!(email: "s@example.com", password: "secret123", name: "U", role: "coach") }
+
+ it "GET /api/v1/sports restituisce il catalogo" do
+ post "/api/v1/auth/login", params: { email: user.email, password: "secret123" }
+ token = response.parsed_body["access_token"]
+ get "/api/v1/sports", headers: { "Authorization" => "Bearer #{token}" }
+ expect(response).to have_http_status(:ok)
+ body = JSON.parse(response.body)
+ expect(body).to be_an(Array)
+ pallavolo = body.find { |s| s["key"] == "pallavolo" }
+ expect(pallavolo["board"]).to eq("volley")
+ expect(pallavolo["allowed_overlays"]).to include("volley", "timer", "none")
+ end
+end
diff --git a/backend/spec/services/scoring/generic_engine_spec.rb b/backend/spec/services/scoring/generic_engine_spec.rb
new file mode 100644
index 0000000..f535049
--- /dev/null
+++ b/backend/spec/services/scoring/generic_engine_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe Scoring::Engines::GenericEngine do
+ let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
+ let!(:team) { club.teams.create!(name: "T", sport: "altro") }
+ let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "altro") }
+ let!(:user) { User.create!(email: "g@example.com", password: "secret123", name: "Coach") }
+ let!(:session) { StreamSession.create!(match: match, user: user, status: "live", platform: "matchlivetv") }
+
+ it "incrementa solo il contatore senza chiudere set" do
+ engine = described_class.new(match: match)
+ result = engine.apply(session: session, action: "home_point")
+ expect(result.score.home_points).to eq(1)
+ expect(result.set_won).to be(false)
+ end
+end
diff --git a/docs/MULTI_SPORT.md b/docs/MULTI_SPORT.md
new file mode 100644
index 0000000..3d84a98
--- /dev/null
+++ b/docs/MULTI_SPORT.md
@@ -0,0 +1,49 @@
+# Multi-sport — Match Live TV
+
+## Concetti
+
+| Concetto | Descrizione |
+|----------|-------------|
+| `sport_key` | Disciplina (pallavolo, basket, tennis…) — sulla squadra e snapshot sulla partita |
+| `board_type` | Macro-tabellone **implicito** dal catalogo (`volley`, `basket`, `timed`, `racket`, `timer`, `generic`) |
+| `overlay_kind` | Layout video sulla partita (`none`, `timer`, `volley`, `basket`, `timed`, `racket`) |
+| `scoring_rules` | Regole effettive della partita (override sui default del catalogo) |
+
+## Catalogo
+
+Definito in [`backend/config/sports.yml`](../backend/config/sports.yml), caricato da `Sports::Catalog`.
+
+- Nessuna tabella `sports` in DB
+- API: `GET /api/v1/sports`
+
+## Scoring
+
+`Scoring::Engine.for(match)` seleziona l'engine:
+
+| Board | Engine | Azioni principali |
+|-------|--------|-------------------|
+| volley | VolleyEngine | +1, −1, chiudi set |
+| racket | RacketEngine | come volley (fase 1) |
+| generic | GenericEngine | +1, −1 (nessuna chiusura set) |
+| basket | BasketEngine | +1/+2/+3, quarti, cronometro |
+| timed | TimedEngine | +1, −1, tempi, cronometro |
+| timer | TimerEngine | solo cronometro |
+
+`score_states.data` (JSONB) per basket/timed/timer.
+
+## Overlay
+
+Scelto per partita (`overlay_kind`), limitato da `allowed_overlays` dello sport.
+
+Rendering Android: `OverlayKind` in `OverlayState` → `ScoreboardElement`, `CompactScoreboardElement`, `TimerElement`.
+
+## Migrazione
+
+- `teams.sport` → `sport_key` (`volleyball` → `pallavolo`)
+- `matches.sport` → `sport_key`
+- `matches.overlay_kind` (nullable)
+- `score_states.board_type`, `score_states.data`
+
+## Regia web
+
+Partial per board in `app/views/public/regia/_*.html.erb`, `data-board` su `#regia-app`.
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt
index 5753145..00a4d4c 100644
--- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt
@@ -3,6 +3,7 @@ package com.matchlivetv.match_live_tv.data.api
import com.matchlivetv.match_live_tv.domain.AuthTokens
import com.matchlivetv.match_live_tv.domain.Match
import com.matchlivetv.match_live_tv.domain.ScoringRules
+import com.matchlivetv.match_live_tv.domain.SportOption
import com.matchlivetv.match_live_tv.domain.StreamSession
import com.matchlivetv.match_live_tv.domain.Team
import com.matchlivetv.match_live_tv.domain.User
@@ -30,10 +31,31 @@ data class UserDto(
fun toDomain() = User(id, email, name, role ?: "coach")
}
+data class SportDto(
+ val key: String,
+ val label: String,
+ val board: String,
+ val overlay: String,
+ @Json(name = "allowed_overlays") val allowedOverlays: List = emptyList(),
+ @Json(name = "default_rules") val defaultRules: Map = emptyMap(),
+) {
+ fun toDomain() = SportOption(
+ key = key,
+ label = label,
+ board = board,
+ overlay = overlay,
+ allowedOverlays = allowedOverlays,
+ defaultRules = defaultRules,
+ )
+}
+
data class TeamDto(
val id: String,
val name: String,
val sport: String? = null,
+ @Json(name = "sport_key") val sportKey: String? = null,
+ @Json(name = "board_type") val boardType: String? = null,
+ @Json(name = "sport_label") val sportLabel: String? = null,
@Json(name = "can_stream") val canStream: Boolean? = true,
@Json(name = "club_name") val clubName: String? = null,
@Json(name = "youtube_enabled") val youtubeEnabled: Boolean? = false,
@@ -54,7 +76,9 @@ data class TeamDto(
fun toDomain() = Team(
id = id,
name = name,
- sport = sport ?: "volleyball",
+ sport = sportKey ?: sport ?: "pallavolo",
+ sportKey = sportKey ?: sport ?: "pallavolo",
+ boardType = boardType ?: "volley",
canStream = canStream ?: true,
clubName = clubName,
logoUrl = logoUrl,
@@ -82,6 +106,12 @@ data class MatchDto(
val location: String? = null,
@Json(name = "scheduled_at") val scheduledAt: String? = null,
@Json(name = "sets_to_win") val setsToWin: Int? = null,
+ @Json(name = "sport_key") val sportKey: String? = null,
+ val sport: String? = null,
+ @Json(name = "sport_label") val sportLabel: String? = null,
+ @Json(name = "board_type") val boardType: String? = null,
+ @Json(name = "overlay_kind") val overlayKind: String? = null,
+ @Json(name = "effective_overlay_kind") val effectiveOverlayKind: String? = null,
/** Campionato / descrizione torneo (facoltativo). */
val category: String? = null,
@Json(name = "scoring_rules") val scoringRules: ScoringRulesBody? = null,
@@ -103,6 +133,11 @@ data class MatchDto(
location = location,
scheduledAt = scheduledAt,
setsToWin = setsToWin ?: 3,
+ sportKey = sportKey ?: sport ?: "pallavolo",
+ sportLabel = sportLabel,
+ boardType = boardType ?: "volley",
+ overlayKind = overlayKind,
+ effectiveOverlayKind = effectiveOverlayKind ?: overlayKind ?: "volley",
category = category,
scoringRules = scoringRules?.toDomain(),
activeSessionId = activeSessionId,
@@ -212,34 +247,51 @@ data class UpdateMatchBody(
data class UpdateMatchRequest(val match: UpdateMatchBody)
data class ScoringRulesBody(
- @Json(name = "points_per_set") val pointsPerSet: Int,
- @Json(name = "points_deciding_set") val pointsDecidingSet: Int,
- @Json(name = "min_point_lead") val minPointLead: Int,
+ @Json(name = "sets_to_win") val setsToWin: Int? = null,
+ @Json(name = "points_per_set") val pointsPerSet: Int? = null,
+ @Json(name = "points_deciding_set") val pointsDecidingSet: Int? = null,
+ @Json(name = "min_point_lead") val minPointLead: Int? = null,
+ val periods: Int? = null,
+ @Json(name = "period_duration_secs") val periodDurationSecs: Int? = null,
+ @Json(name = "overtime_duration_secs") val overtimeDurationSecs: Int? = null,
) {
fun toDomain() = ScoringRules(
- pointsPerSet = pointsPerSet,
- pointsDecidingSet = pointsDecidingSet,
- minPointLead = minPointLead,
+ pointsPerSet = pointsPerSet ?: 25,
+ pointsDecidingSet = pointsDecidingSet ?: 15,
+ minPointLead = minPointLead ?: 2,
+ periods = periods ?: 4,
+ periodDurationSecs = periodDurationSecs ?: 600,
+ overtimeDurationSecs = overtimeDurationSecs ?: 300,
)
}
-fun ScoringRules.toBody() = ScoringRulesBody(
+fun ScoringRules.toBody(setsToWin: Int? = null) = ScoringRulesBody(
+ setsToWin = setsToWin,
pointsPerSet = pointsPerSet,
pointsDecidingSet = pointsDecidingSet,
minPointLead = minPointLead,
+ periods = periods,
+ periodDurationSecs = periodDurationSecs,
+ overtimeDurationSecs = overtimeDurationSecs,
)
-fun ScoringRulesBody.toMap(): Map = mapOf(
- "points_per_set" to pointsPerSet,
- "points_deciding_set" to pointsDecidingSet,
- "min_point_lead" to minPointLead,
-)
+fun ScoringRulesBody.toMap(): Map = buildMap {
+ setsToWin?.let { put("sets_to_win", it) }
+ pointsPerSet?.let { put("points_per_set", it) }
+ pointsDecidingSet?.let { put("points_deciding_set", it) }
+ minPointLead?.let { put("min_point_lead", it) }
+ periods?.let { put("periods", it) }
+ periodDurationSecs?.let { put("period_duration_secs", it) }
+ overtimeDurationSecs?.let { put("overtime_duration_secs", it) }
+}
data class UpdateMatchBodyFull(
@Json(name = "opponent_name") val opponentName: String,
val location: String? = null,
@Json(name = "scheduled_at") val scheduledAt: String? = null,
@Json(name = "sets_to_win") val setsToWin: Int,
+ @Json(name = "sport_key") val sportKey: String? = null,
+ @Json(name = "overlay_kind") val overlayKind: String? = null,
val category: String? = null,
@Json(name = "opponent_primary_color") val opponentPrimaryColor: String? = null,
@Json(name = "scoring_rules") val scoringRules: Map? = null,
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt
index dfaee82..8c32c16 100644
--- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt
@@ -23,6 +23,9 @@ interface MatchLiveApi {
@POST("auth/logout")
suspend fun logout()
+ @GET("sports")
+ suspend fun sports(): List
+
@GET("teams")
suspend fun teams(): List
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchRepository.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchRepository.kt
index 030305c..c03c891 100644
--- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchRepository.kt
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchRepository.kt
@@ -15,6 +15,7 @@ import com.matchlivetv.match_live_tv.data.api.UpdateMatchRequestFull
import com.matchlivetv.match_live_tv.data.api.UpdateTeamBody
import com.matchlivetv.match_live_tv.data.api.UpdateTeamRequest
import com.matchlivetv.match_live_tv.domain.Match
+import com.matchlivetv.match_live_tv.domain.SportOption
import com.matchlivetv.match_live_tv.domain.Team
import com.matchlivetv.match_live_tv.domain.coachHubVisible
import kotlinx.coroutines.flow.first
@@ -25,6 +26,9 @@ class MatchRepository(
private val tokenStore: TokenStore,
private val appContext: Context,
) {
+ suspend fun fetchSports(): List =
+ api.sports().map { it.toDomain() }
+
suspend fun fetchTeams(): List =
api.teams().map { it.toDomain() }
@@ -115,6 +119,8 @@ class MatchRepository(
category: String?,
opponentPrimaryColor: String?,
scoringRules: ScoringRulesBody?,
+ sportKey: String? = null,
+ overlayKind: String? = null,
opponentLogoUri: Uri? = null,
): Match {
val body = UpdateMatchBodyFull(
@@ -122,9 +128,11 @@ class MatchRepository(
location = location?.takeIf { it.isNotBlank() },
scheduledAt = scheduledAt,
setsToWin = setsToWin,
+ sportKey = sportKey,
+ overlayKind = overlayKind,
category = category?.takeIf { it.isNotBlank() },
opponentPrimaryColor = opponentPrimaryColor,
- scoringRules = scoringRules?.toMap() ?: emptyMap(),
+ scoringRules = scoringRules?.toMap().takeIf { !it.isNullOrEmpty() },
)
if (opponentLogoUri != null) {
return api.updateMatchMultipart(
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/MatchScoringRules.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/MatchScoringRules.kt
index bdab095..d41c7db 100644
--- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/MatchScoringRules.kt
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/MatchScoringRules.kt
@@ -10,6 +10,10 @@ data class MatchScoringRules(
val pointsPerSet: Int = 25,
val pointsDecidingSet: Int = 15,
val minPointLead: Int = 2,
+ val periods: Int = 4,
+ val periodDurationSecs: Int = 600,
+ val overtimeDurationSecs: Int = 300,
+ val boardType: String = "volley",
) {
val maxSets: Int get() = (setsToWin * 2) - 1
@@ -35,6 +39,18 @@ data class MatchScoringRules(
}
companion object {
- fun fromMatch(match: Match) = MatchScoringRules(setsToWin = match.setsToWin)
+ fun fromMatch(match: Match): MatchScoringRules {
+ val rules = match.scoringRules
+ return MatchScoringRules(
+ setsToWin = match.setsToWin,
+ pointsPerSet = rules?.pointsPerSet ?: 25,
+ pointsDecidingSet = rules?.pointsDecidingSet ?: 15,
+ minPointLead = rules?.minPointLead ?: 2,
+ periods = rules?.periods ?: 4,
+ periodDurationSecs = rules?.periodDurationSecs ?: 600,
+ overtimeDurationSecs = rules?.overtimeDurationSecs ?: 300,
+ boardType = match.boardType,
+ )
+ }
}
}
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt
index 81ddcda..ace84c1 100644
--- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt
@@ -12,10 +12,21 @@ data class AuthTokens(
val refreshToken: String,
)
+data class SportOption(
+ val key: String,
+ val label: String,
+ val board: String,
+ val overlay: String,
+ val allowedOverlays: List,
+ val defaultRules: Map = emptyMap(),
+)
+
data class Team(
val id: String,
val name: String,
val sport: String,
+ val sportKey: String = sport,
+ val boardType: String = "volley",
val clubName: String? = null,
val logoUrl: String? = null,
val primaryColor: String? = null,
@@ -59,6 +70,9 @@ data class ScoringRules(
val pointsPerSet: Int = 25,
val pointsDecidingSet: Int = 15,
val minPointLead: Int = 2,
+ val periods: Int = 4,
+ val periodDurationSecs: Int = 600,
+ val overtimeDurationSecs: Int = 300,
)
data class Match(
@@ -68,6 +82,11 @@ data class Match(
val opponentName: String,
val location: String? = null,
val scheduledAt: String? = null,
+ val sportKey: String = "pallavolo",
+ val sportLabel: String? = null,
+ val boardType: String = "volley",
+ val overlayKind: String? = null,
+ val effectiveOverlayKind: String = "volley",
val setsToWin: Int = 3,
/** Campionato / descrizione torneo (facoltativo). */
val category: String? = null,
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/CompactScoreboardElement.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/CompactScoreboardElement.kt
new file mode 100644
index 0000000..4cd8089
--- /dev/null
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/CompactScoreboardElement.kt
@@ -0,0 +1,61 @@
+package com.matchlivetv.match_live_tv.streaming.overlay
+
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.RectF
+import android.graphics.Typeface
+import android.text.TextPaint
+import kotlin.math.roundToInt
+
+/** Tabellone compatto per basket, timed e sport a punteggio semplice. */
+class CompactScoreboardElement : OverlayElement {
+
+ private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = Color.argb(210, 12, 12, 12)
+ }
+ private val labelPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = Color.WHITE
+ typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
+ }
+ private val metaPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = Color.parseColor("#AAAAAA")
+ typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL)
+ }
+ private val scorePaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = Color.WHITE
+ typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
+ textAlign = Paint.Align.CENTER
+ }
+
+ override fun draw(canvas: Canvas, state: OverlayState, layout: OverlayLayout) {
+ val compact = state.compactScoreboard ?: return
+ val kind = state.overlayKind
+ if (kind !in setOf(OverlayKind.BASKET, OverlayKind.TIMED, OverlayKind.RACKET, OverlayKind.VOLLEY)) return
+
+ val w = (layout.canvasWidth * 0.42f).roundToInt().coerceAtLeast(220)
+ val h = (layout.canvasHeight * 0.11f).roundToInt().coerceAtLeast(72)
+ val left = layout.marginPx.toFloat()
+ val top = layout.marginPx.toFloat()
+ val rect = RectF(left, top, left + w, top + h)
+ canvas.drawRoundRect(rect, 12f, 12f, backgroundPaint)
+
+ labelPaint.textSize = (h * 0.18f).coerceAtLeast(14f)
+ scorePaint.textSize = (h * 0.34f).coerceAtLeast(22f)
+ metaPaint.textSize = (h * 0.16f).coerceAtLeast(12f)
+
+ val midY = rect.centerY()
+ canvas.drawText(compact.homeTeamName, rect.left + 16f, midY - 6f, labelPaint)
+ canvas.drawText(compact.awayTeamName, rect.left + 16f, midY + labelPaint.textSize + 4f, labelPaint)
+ canvas.drawText(
+ "${compact.homeScore} - ${compact.awayScore}",
+ rect.right - 56f,
+ midY + scorePaint.textSize * 0.35f,
+ scorePaint,
+ )
+ val meta = listOfNotNull(compact.periodLabel, compact.clockText).joinToString(" · ")
+ if (meta.isNotBlank()) {
+ canvas.drawText(meta, rect.left + 16f, rect.bottom - 10f, metaPaint)
+ }
+ }
+}
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayCanvasRenderer.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayCanvasRenderer.kt
index ae79f7d..fa4d41e 100644
--- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayCanvasRenderer.kt
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayCanvasRenderer.kt
@@ -15,6 +15,7 @@ class OverlayCanvasRenderer(context: Context) {
private val elements: List = listOf(
WatermarkElement(context),
ScoreboardElement(),
+ CompactScoreboardElement(),
TimerElement(),
SponsorElement(),
)
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayMappings.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayMappings.kt
index 9032ff6..4962b69 100644
--- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayMappings.kt
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayMappings.kt
@@ -42,6 +42,13 @@ fun ScoreState.toScoreboardState(
)
}
+fun formatOverlayClock(secs: Int, countUp: Boolean = false): String {
+ if (secs <= 0 && !countUp) return "0:00"
+ val m = secs / 60
+ val s = secs % 60
+ return "%d:%02d".format(m, s)
+}
+
fun BroadcastPhase.toOverlayStatus(): BroadcastOverlayStatus = when (this) {
BroadcastPhase.LIVE -> BroadcastOverlayStatus.LIVE
BroadcastPhase.PAUSED -> BroadcastOverlayStatus.PAUSED
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayState.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayState.kt
index 3bdeb28..c7c1bbe 100644
--- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayState.kt
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/OverlayState.kt
@@ -2,7 +2,7 @@ package com.matchlivetv.match_live_tv.streaming.overlay
/**
* Stato grafico dell'overlay video. Indipendente dalla logica sportiva:
- * il tabellone è descritto da [ScoreboardState], non da regole di gioco.
+ * il tabellone è descritto da [ScoreboardState] / [CompactScoreboardState].
*/
data class ScoreboardSetColumn(
val setNumber: Int,
@@ -21,6 +21,38 @@ data class ScoreboardState(
val awayLogoUrl: String? = null,
)
+data class CompactScoreboardState(
+ val homeTeamName: String,
+ val awayTeamName: String,
+ val homeScore: Int,
+ val awayScore: Int,
+ val periodLabel: String? = null,
+ val clockText: String? = null,
+ val homeAccentColor: Int = 0xFFFF2D2D.toInt(),
+ val awayAccentColor: Int = 0xFF1E3A8A.toInt(),
+)
+
+enum class OverlayKind {
+ NONE,
+ TIMER,
+ VOLLEY,
+ BASKET,
+ TIMED,
+ RACKET,
+ ;
+
+ companion object {
+ fun fromApi(value: String?): OverlayKind = when (value?.lowercase()) {
+ "none" -> NONE
+ "timer" -> TIMER
+ "basket" -> BASKET
+ "timed" -> TIMED
+ "racket" -> RACKET
+ else -> VOLLEY
+ }
+ }
+}
+
enum class BroadcastOverlayStatus {
PREVIEW,
CONNECTING,
@@ -29,18 +61,22 @@ enum class BroadcastOverlayStatus {
}
data class OverlayState(
+ val overlayKind: OverlayKind = OverlayKind.VOLLEY,
val scoreboard: ScoreboardState? = null,
+ val compactScoreboard: CompactScoreboardState? = null,
val watermarkVisible: Boolean = true,
val broadcastStatus: BroadcastOverlayStatus = BroadcastOverlayStatus.LIVE,
val timerText: String? = null,
val sponsorText: String? = null,
) {
fun fingerprint(): Int {
- var result = watermarkVisible.hashCode()
+ var result = overlayKind.hashCode()
+ result = 31 * result + watermarkVisible.hashCode()
result = 31 * result + broadcastStatus.hashCode()
result = 31 * result + (timerText?.hashCode() ?: 0)
result = 31 * result + (sponsorText?.hashCode() ?: 0)
result = 31 * result + (scoreboard?.fingerprint() ?: 0)
+ result = 31 * result + (compactScoreboard?.fingerprint() ?: 0)
return result
}
}
@@ -63,4 +99,14 @@ private fun ScoreboardState.fingerprint(): Int {
return result
}
+private fun CompactScoreboardState.fingerprint(): Int {
+ var result = homeTeamName.hashCode()
+ result = 31 * result + awayTeamName.hashCode()
+ result = 31 * result + homeScore
+ result = 31 * result + awayScore
+ result = 31 * result + (periodLabel?.hashCode() ?: 0)
+ result = 31 * result + (clockText?.hashCode() ?: 0)
+ return result
+}
+
private fun logoLoadedKey(url: String?): Int = if (OverlayLogoCache.get(url) != null) 1 else 0
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/ScoreboardElement.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/ScoreboardElement.kt
index 20d78f8..9ce7a32 100644
--- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/ScoreboardElement.kt
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/ScoreboardElement.kt
@@ -62,6 +62,7 @@ class ScoreboardElement : OverlayElement {
private val liveRect = RectF()
override fun draw(canvas: Canvas, state: OverlayState, layout: OverlayLayout) {
+ if (state.overlayKind !in setOf(OverlayKind.VOLLEY, OverlayKind.RACKET)) return
val board = state.scoreboard ?: return
if (board.columns.isEmpty()) return
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/TimerElement.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/TimerElement.kt
index 865c7e2..294cc28 100644
--- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/TimerElement.kt
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/TimerElement.kt
@@ -14,6 +14,7 @@ class TimerElement : OverlayElement {
}
override fun draw(canvas: Canvas, state: OverlayState, layout: OverlayLayout) {
+ if (state.overlayKind != OverlayKind.TIMER && state.timerText.isNullOrBlank()) return
val text = state.timerText?.takeIf { it.isNotBlank() } ?: return
paint.textSize = layout.canvasHeight * 0.05f
val x = layout.marginPx.toFloat()
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/WatermarkElement.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/WatermarkElement.kt
index 6bb2843..ab91228 100644
--- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/WatermarkElement.kt
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/overlay/WatermarkElement.kt
@@ -16,6 +16,7 @@ class WatermarkElement(context: Context) : OverlayElement {
}
override fun draw(canvas: Canvas, state: OverlayState, layout: OverlayLayout) {
+ if (state.overlayKind == OverlayKind.NONE) return
if (!state.watermarkVisible) return
val targetWidth = (layout.canvasWidth * LOGO_WIDTH_RATIO).roundToInt().coerceIn(28, 72)
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt
index 572ba7a..a724165 100644
--- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt
@@ -39,8 +39,11 @@ import com.matchlivetv.match_live_tv.domain.StreamSession
import com.matchlivetv.match_live_tv.streaming.BroadcastConfig
import com.matchlivetv.match_live_tv.streaming.BroadcastPhase
import com.matchlivetv.match_live_tv.streaming.LivePreviewView
+import com.matchlivetv.match_live_tv.streaming.overlay.CompactScoreboardState
+import com.matchlivetv.match_live_tv.streaming.overlay.OverlayKind
import com.matchlivetv.match_live_tv.streaming.overlay.OverlayLogoCache
import com.matchlivetv.match_live_tv.streaming.overlay.OverlayState
+import com.matchlivetv.match_live_tv.streaming.overlay.formatOverlayClock
import com.matchlivetv.match_live_tv.streaming.overlay.toOverlayStatus
import com.matchlivetv.match_live_tv.streaming.overlay.toScoreboardState
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
@@ -219,23 +222,50 @@ fun BroadcastScreen(
val currentMatch = match ?: return@LaunchedEffect
val homeLogoUrl = resolveMediaUrl(currentMatch.homeLogoUrl)
val awayLogoUrl = resolveMediaUrl(currentMatch.opponentLogoUrl)
- container.broadcastCoordinator.updateOverlay(
- OverlayState(
+ val overlayKind = OverlayKind.fromApi(currentMatch.effectiveOverlayKind)
+ val homeColor = parseColorHex(currentMatch.homePrimaryColor, 0xFFFF2D2D.toInt())
+ val awayColor = parseColorHex(currentMatch.opponentPrimaryColor, 0xFF1E3A8A.toInt())
+ val overlayState = when (overlayKind) {
+ OverlayKind.NONE -> OverlayState(
+ overlayKind = overlayKind,
+ watermarkVisible = false,
+ broadcastStatus = metrics.phase.toOverlayStatus(),
+ )
+ OverlayKind.TIMER -> OverlayState(
+ overlayKind = overlayKind,
+ timerText = formatOverlayClock(score.homePoints, countUp = true),
+ watermarkVisible = true,
+ broadcastStatus = metrics.phase.toOverlayStatus(),
+ )
+ OverlayKind.BASKET, OverlayKind.TIMED -> OverlayState(
+ overlayKind = overlayKind,
+ compactScoreboard = CompactScoreboardState(
+ homeTeamName = currentMatch.teamName,
+ awayTeamName = currentMatch.opponentName,
+ homeScore = score.homePoints,
+ awayScore = score.awayPoints,
+ periodLabel = "${score.currentSet}°",
+ homeAccentColor = homeColor,
+ awayAccentColor = awayColor,
+ ),
+ watermarkVisible = true,
+ broadcastStatus = metrics.phase.toOverlayStatus(),
+ )
+ else -> OverlayState(
+ overlayKind = overlayKind,
scoreboard = score.toScoreboardState(
homeTeamName = currentMatch.teamName,
awayTeamName = currentMatch.opponentName,
- homeAccentColor = parseColorHex(currentMatch.homePrimaryColor, 0xFFFF2D2D.toInt()),
- awayAccentColor = parseColorHex(
- currentMatch.opponentPrimaryColor,
- 0xFF1E3A8A.toInt(),
- ),
+ homeAccentColor = homeColor,
+ awayAccentColor = awayColor,
homeLogoUrl = homeLogoUrl,
awayLogoUrl = awayLogoUrl,
),
watermarkVisible = true,
broadcastStatus = metrics.phase.toOverlayStatus(),
- ),
- )
+ )
+ }
+ container.broadcastCoordinator.updateOverlay(overlayState)
}
LaunchedEffect(Unit) {
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepMatchScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepMatchScreen.kt
index cb95bba..478c990 100644
--- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepMatchScreen.kt
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepMatchScreen.kt
@@ -31,7 +31,9 @@ import com.matchlivetv.match_live_tv.core.normalizeHexColor
import com.matchlivetv.match_live_tv.data.AppContainer
import com.matchlivetv.match_live_tv.data.api.ScoringRulesBody
import com.matchlivetv.match_live_tv.domain.Match
+import com.matchlivetv.match_live_tv.domain.SportOption
import com.matchlivetv.match_live_tv.domain.Team
+import com.matchlivetv.match_live_tv.streaming.overlay.OverlayKind
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton
import com.matchlivetv.match_live_tv.ui.matches.MatchOutlinedField
@@ -111,6 +113,19 @@ fun StepMatchScreen(
var pointsPerSetText by remember { mutableStateOf(pointsPerSet.toString()) }
var pointsDecidingSetText by remember { mutableStateOf(pointsDecidingSet.toString()) }
var saving by remember { mutableStateOf(false) }
+ var sports by remember { mutableStateOf>(emptyList()) }
+ var selectedSportKey by remember { mutableStateOf(match.sportKey) }
+ var selectedOverlay by remember { mutableStateOf(match.overlayKind ?: match.effectiveOverlayKind) }
+
+ LaunchedEffect(Unit) {
+ runCatching { container.matchRepository.fetchSports() }
+ .onSuccess { sports = it }
+ }
+
+ val currentSport = sports.find { it.key == selectedSportKey }
+ ?: sports.find { it.key == homeTeam?.sportKey }
+ val allowedOverlays = currentSport?.allowedOverlays.orEmpty()
+ val boardType = currentSport?.board ?: homeTeam?.boardType ?: match.boardType
Column(
Modifier
@@ -174,6 +189,35 @@ fun StepMatchScreen(
)
}
+ Spacer(Modifier.height(16.dp))
+ WizardReadOnlyField(
+ label = "Sport",
+ value = currentSport?.label ?: match.sportLabel ?: selectedSportKey,
+ )
+ if (allowedOverlays.isNotEmpty()) {
+ Spacer(Modifier.height(12.dp))
+ Text("Overlay video", style = MaterialTheme.typography.bodyMedium)
+ Spacer(Modifier.height(8.dp))
+ allowedOverlays.forEach { overlayKey ->
+ val selected = selectedOverlay == overlayKey
+ val label = OverlayKind.fromApi(overlayKey).name.lowercase()
+ .replaceFirstChar { it.uppercase() }
+ if (selected) {
+ MatchPrimaryButton(
+ label = label,
+ onClick = { selectedOverlay = overlayKey },
+ modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp),
+ )
+ } else {
+ MatchSecondaryButton(
+ label = label,
+ onClick = { selectedOverlay = overlayKey },
+ modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp),
+ )
+ }
+ }
+ }
+
Spacer(Modifier.height(20.dp))
Row(
Modifier.fillMaxWidth(),
@@ -195,7 +239,7 @@ fun StepMatchScreen(
Switch(checked = customRules, onCheckedChange = { customRules = it })
}
- if (customRules) {
+ if (customRules && boardType in setOf("volley", "racket")) {
Spacer(Modifier.height(16.dp))
Text("Set da vincere la partita", style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(8.dp))
@@ -262,8 +306,9 @@ fun StepMatchScreen(
}
val resolvedSets = if (customRules) setsToWin else DEFAULT_SETS_TO_WIN
- val resolvedRules: ScoringRulesBody? = if (customRules) {
+ val resolvedRules: ScoringRulesBody? = if (customRules && boardType in setOf("volley", "racket")) {
ScoringRulesBody(
+ setsToWin = resolvedSets,
pointsPerSet = pointsPerSet,
pointsDecidingSet = pointsDecidingSet,
minPointLead = existingRules?.minPointLead ?: DEFAULT_MIN_POINT_LEAD,
@@ -298,6 +343,10 @@ fun StepMatchScreen(
category = campionato.trim(),
opponentPrimaryColor = opponentPrimaryColor,
scoringRules = resolvedRules,
+ sportKey = selectedSportKey,
+ overlayKind = selectedOverlay.takeIf {
+ it != currentSport?.overlay
+ },
opponentLogoUri = opponentLogoUri,
)
}.onSuccess { updated ->