Fix sport squadra su partite basket e ruoli organico per board.

Le partite ereditano lo sport_key della squadra alla creazione invece del default pallavolo; l'organico mostra ruoli per board (basket, volley, timed) e il wizard Android usa lo sport del team per overlay e icona.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-09 22:32:36 +02:00
parent 01b7a3c96b
commit 34c97f4447
9 changed files with 136 additions and 49 deletions

View File

@@ -17,7 +17,7 @@ module Api
def create def create
attrs = match_params.to_h attrs = match_params.to_h
attrs["sport_key"] = @team.sport_key if attrs["sport_key"].blank? attrs["sport_key"] = @team.sport_key
normalize_scoring_rules!(attrs) normalize_scoring_rules!(attrs)
match = @team.matches.create!(attrs) match = @team.matches.create!(attrs)
attach_opponent_logo(match) attach_opponent_logo(match)
@@ -70,6 +70,7 @@ module Api
if p[:sport].present? && p[:sport_key].blank? if p[:sport].present? && p[:sport_key].blank?
p[:sport_key] = p.delete(:sport) p[:sport_key] = p.delete(:sport)
end end
p[:overlay_kind] = nil if p.key?(:overlay_kind) && p[:overlay_kind].blank?
p p
end end

View File

@@ -0,0 +1,25 @@
# frozen_string_literal: true
module Sports
class PlayerRoles
BY_BOARD = {
"volley" => %w[Schiacciatore Opposto Centrale Palleggiatore Libero Universale],
"basket" => ["Playmaker", "Guardia", "Ala", "Ala forte", "Centro"],
"racket" => %w[Giocatore],
"timed" => %w[Portiere Difensore Centrocampista Attaccante],
"generic" => %w[Giocatore],
"timer" => %w[Giocatore]
}.freeze
class << self
def for_team(team)
board = team.effective_board_type
BY_BOARD[board] || BY_BOARD["generic"]
end
def for_board(board)
BY_BOARD[board.to_s] || BY_BOARD["generic"]
end
end
end
end

View File

@@ -23,9 +23,10 @@ module RosterHelper
end end
end end
def roster_player_role_options(selected = nil) def roster_player_role_options(team, selected = nil)
roles = Sports::PlayerRoles.for_team(team)
options_for_select( options_for_select(
[["— Seleziona ruolo —", ""]] + TeamRosterMember::VOLLEYBALL_PLAYER_ROLES.map { |r| [r, r] }, [["— Seleziona ruolo —", ""]] + roles.map { |r| [r, r] },
selected selected
) )
end end

View File

@@ -129,7 +129,10 @@ class Match < ApplicationRecord
end end
def inherit_sport_from_team def inherit_sport_from_team
self.sport_key = team.sport_key if sport_key.blank? && team.present? return unless team.present?
# Il default DB (pallavolo) non deve prevalere sullo sport della squadra alla creazione.
self.sport_key = team.sport_key if new_record? || sport_key.blank?
end end
def sport_key_known def sport_key_known

View File

@@ -9,14 +9,7 @@ class TeamRosterMember < ApplicationRecord
"player" => "Giocatori" "player" => "Giocatori"
}.freeze }.freeze
VOLLEYBALL_PLAYER_ROLES = %w[ VOLLEYBALL_PLAYER_ROLES = Sports::PlayerRoles::BY_BOARD["volley"].freeze
Schiacciatore
Opposto
Centrale
Palleggiatore
Libero
Universale
].freeze
belongs_to :team belongs_to :team
@@ -74,9 +67,10 @@ class TeamRosterMember < ApplicationRecord
end end
def player_role_label_valid def player_role_label_valid
return if role_label.in?(VOLLEYBALL_PLAYER_ROLES) allowed = Sports::PlayerRoles.for_team(team)
return if role_label.in?(allowed)
errors.add(:role_label, "non è un ruolo pallavolo valido") errors.add(:role_label, "non è un ruolo valido per questo sport")
end end
def photo_file_type def photo_file_type

View File

@@ -3,7 +3,8 @@
<% form_url = member.persisted? ? public_team_roster_member_path(team, member) : public_team_roster_members_path(team) %> <% form_url = member.persisted? ? public_team_roster_member_path(team, member) : public_team_roster_members_path(team) %>
<% form_method = member.persisted? ? :patch : :post %> <% form_method = member.persisted? ? :patch : :post %>
<% is_player = member.category == "player" %> <% is_player = member.category == "player" %>
<% player_role = member.role_label if member.role_label.in?(TeamRosterMember::VOLLEYBALL_PLAYER_ROLES) %> <% player_roles = Sports::PlayerRoles.for_team(team) %>
<% player_role = member.role_label if member.role_label.in?(player_roles) %>
<%= form_with url: form_url, method: form_method, multipart: true, class: "roster-member-form card roster-sidebar__form", data: { roster_form: true } do %> <%= form_with url: form_url, method: form_method, multipart: true, class: "roster-member-form card roster-sidebar__form", data: { roster_form: true } do %>
<h3 class="roster-sidebar__title"><%= member.persisted? ? "Modifica scheda" : "Aggiungi persona" %></h3> <h3 class="roster-sidebar__title"><%= member.persisted? ? "Modifica scheda" : "Aggiungi persona" %></h3>
@@ -19,7 +20,7 @@
<div data-roster-role-fields> <div data-roster-role-fields>
<div data-roster-role-player <%= "hidden" unless is_player %>> <div data-roster-role-player <%= "hidden" unless is_player %>>
<%= label_tag "team_roster_member[role_label]", "Ruolo in campo" %> <%= label_tag "team_roster_member[role_label]", "Ruolo in campo" %>
<%= select_tag "team_roster_member[role_label]", roster_player_role_options(player_role), <%= select_tag "team_roster_member[role_label]", roster_player_role_options(team, player_role),
data: { roster_role_player: true }, disabled: !is_player %> data: { roster_role_player: true }, disabled: !is_player %>
</div> </div>
<div data-roster-role-other <%= "hidden" if is_player %>> <div data-roster-role-other <%= "hidden" if is_player %>>

View File

@@ -53,6 +53,15 @@ RSpec.describe Match, "scheduling scopes" do
expect(match.coach_hub_visible?).to be(false) expect(match.coach_hub_visible?).to be(false)
end end
it "eredita lo sport della squadra alla creazione (non il default pallavolo)" do
basket_team = club.teams.create!(name: "U13 Basket", sport_key: "basket")
match = basket_team.matches.create!(opponent_name: "Ospiti", sets_to_win: 3)
expect(match.sport_key).to eq("basket")
expect(match.effective_board_type).to eq("basket")
expect(match.effective_overlay_kind).to eq("basket")
end
it "nasconde partita con diretta terminata anche se resta una sessione idle vecchia" do it "nasconde partita con diretta terminata anche se resta una sessione idle vecchia" do
match = team.matches.create!(opponent_name: "Avversario", sets_to_win: 3) match = team.matches.create!(opponent_name: "Avversario", sets_to_win: 3)
StreamSession.create!( StreamSession.create!(

View File

@@ -50,6 +50,16 @@ private const val DEFAULT_PERIOD_DURATION_SECS = 600
private const val DEFAULT_OVERTIME_DURATION_SECS = 300 private const val DEFAULT_OVERTIME_DURATION_SECS = 300
private const val DEFAULT_OPPONENT_COLOR = "#1E3A8A" private const val DEFAULT_OPPONENT_COLOR = "#1E3A8A"
private fun overlayLabel(key: String): String =
OverlayKind.fromApi(key).name.lowercase().replaceFirstChar { it.uppercase() }
/** True se la partita ha un overlay diverso dal default dello sport. */
private fun matchHasCustomOverlay(match: Match, sportDefaultOverlay: String?): Boolean {
val default = sportDefaultOverlay ?: return false
val current = match.overlayKind ?: return false
return current != default
}
/** True se la partita ha regole non standard rispetto ai default del board. */ /** True se la partita ha regole non standard rispetto ai default del board. */
private fun matchHasCustomScoring(match: Match, boardType: String): Boolean { private fun matchHasCustomScoring(match: Match, boardType: String): Boolean {
val rules = match.scoringRules ?: return false val rules = match.scoringRules ?: return false
@@ -136,22 +146,41 @@ fun StepMatchScreen(
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) }
var selectedOverlay by remember { mutableStateOf(match.overlayKind ?: match.effectiveOverlayKind) } var customOverlay by remember { mutableStateOf(false) }
var selectedOverlay by remember { mutableStateOf(match.effectiveOverlayKind) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
runCatching { container.matchRepository.fetchSports() } runCatching { container.matchRepository.fetchSports() }
.onSuccess { sports = it } .onSuccess { sports = it }
} }
val currentSport = sports.find { it.key == selectedSportKey } val teamSportKey = homeTeam?.sportKey?.takeIf { it.isNotBlank() }
?: sports.find { it.key == homeTeam?.sportKey } val effectiveSportKey = teamSportKey ?: selectedSportKey
val currentSport = sports.find { it.key == effectiveSportKey }
val allowedOverlays = currentSport?.allowedOverlays.orEmpty() val allowedOverlays = currentSport?.allowedOverlays.orEmpty()
val defaultOverlay = currentSport?.overlay ?: match.effectiveOverlayKind
val boardType = currentSport?.board ?: homeTeam?.boardType ?: match.boardType val boardType = currentSport?.board ?: homeTeam?.boardType ?: match.boardType
LaunchedEffect(boardType, match.id) { LaunchedEffect(boardType, match.id) {
customRules = matchHasCustomScoring(match, boardType) customRules = matchHasCustomScoring(match, boardType)
} }
LaunchedEffect(currentSport?.overlay, match.id) {
customOverlay = matchHasCustomOverlay(match, currentSport?.overlay)
selectedOverlay = when {
customOverlay -> match.overlayKind ?: match.effectiveOverlayKind
else -> defaultOverlay
}
}
LaunchedEffect(customOverlay, defaultOverlay) {
if (!customOverlay) {
selectedOverlay = defaultOverlay
} else if (selectedOverlay !in allowedOverlays) {
selectedOverlay = defaultOverlay
}
}
Column( Column(
Modifier Modifier
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
@@ -214,31 +243,34 @@ fun StepMatchScreen(
) )
} }
Spacer(Modifier.height(16.dp))
WizardReadOnlyField(
label = "Sport",
value = currentSport?.label ?: match.sportLabel ?: selectedSportKey,
)
if (allowedOverlays.isNotEmpty()) { if (allowedOverlays.isNotEmpty()) {
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(20.dp))
Text("Overlay video", style = MaterialTheme.typography.bodyMedium) Row(
Spacer(Modifier.height(8.dp)) Modifier.fillMaxWidth(),
allowedOverlays.forEach { overlayKey -> horizontalArrangement = Arrangement.SpaceBetween,
val selected = selectedOverlay == overlayKey verticalAlignment = Alignment.CenterVertically,
val label = OverlayKind.fromApi(overlayKey).name.lowercase() ) {
.replaceFirstChar { it.uppercase() } Text("Overlay video personalizzato", modifier = Modifier.weight(1f))
if (selected) { Switch(checked = customOverlay, onCheckedChange = { customOverlay = it })
MatchPrimaryButton( }
label = label, if (customOverlay) {
onClick = { selectedOverlay = overlayKey }, Spacer(Modifier.height(12.dp))
modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp), allowedOverlays.forEach { overlayKey ->
) val selected = selectedOverlay == overlayKey
} else { val label = overlayLabel(overlayKey)
MatchSecondaryButton( if (selected) {
label = label, MatchPrimaryButton(
onClick = { selectedOverlay = overlayKey }, label = label,
modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp), onClick = { selectedOverlay = overlayKey },
) modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp),
)
} else {
MatchSecondaryButton(
label = label,
onClick = { selectedOverlay = overlayKey },
modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp),
)
}
} }
} }
} }
@@ -440,9 +472,11 @@ fun StepMatchScreen(
category = campionato.trim(), category = campionato.trim(),
opponentPrimaryColor = opponentPrimaryColor, opponentPrimaryColor = opponentPrimaryColor,
scoringRules = resolvedRules, scoringRules = resolvedRules,
sportKey = selectedSportKey, sportKey = effectiveSportKey,
overlayKind = selectedOverlay.takeIf { overlayKind = if (customOverlay && selectedOverlay != defaultOverlay) {
it != currentSport?.overlay selectedOverlay
} else {
""
}, },
opponentLogoUri = opponentLogoUri, opponentLogoUri = opponentLogoUri,
) )

View File

@@ -3,6 +3,7 @@ package com.matchlivetv.match_live_tv.ui.wizard
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -25,9 +26,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.matchlivetv.match_live_tv.data.AppContainer import com.matchlivetv.match_live_tv.data.AppContainer
import com.matchlivetv.match_live_tv.domain.Match import com.matchlivetv.match_live_tv.domain.Match
import com.matchlivetv.match_live_tv.domain.StreamSession import com.matchlivetv.match_live_tv.domain.StreamSession
import com.matchlivetv.match_live_tv.domain.Team
import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold
import com.matchlivetv.match_live_tv.ui.theme.MatchColors import com.matchlivetv.match_live_tv.ui.theme.MatchColors
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -46,6 +49,7 @@ fun WizardShellScreen(
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
var match by remember { mutableStateOf<Match?>(null) } var match by remember { mutableStateOf<Match?>(null) }
var homeTeam by remember { mutableStateOf<Team?>(null) }
val currentStep = step.coerceIn(1, 3) val currentStep = step.coerceIn(1, 3)
fun showError(message: String) { fun showError(message: String) {
@@ -55,9 +59,11 @@ fun WizardShellScreen(
LaunchedEffect(matchId) { LaunchedEffect(matchId) {
loading = true loading = true
runCatching { container.matchRepository.fetchMatch(matchId) } runCatching { container.matchRepository.fetchMatch(matchId) }
.onSuccess { .onSuccess { loaded ->
match = it match = loaded
container.wizardSession.match = it container.wizardSession.match = loaded
runCatching { container.matchRepository.fetchTeam(loaded.teamId) }
.onSuccess { homeTeam = it }
} }
loading = false loading = false
} }
@@ -71,6 +77,19 @@ fun WizardShellScreen(
Icon(Icons.Default.Close, contentDescription = "Chiudi", tint = Color.White) Icon(Icons.Default.Close, contentDescription = "Chiudi", tint = Color.White)
} }
}, },
actions = {
if (currentStep == 1) {
val badgeSportKey = homeTeam?.sportKey ?: match?.sportKey.orEmpty()
if (badgeSportKey.isNotBlank()) {
SportWizardBadge(
sportKey = badgeSportKey,
boardType = homeTeam?.boardType ?: match?.boardType ?: "volley",
sportLabel = match?.sportLabel,
modifier = Modifier.padding(end = 12.dp),
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = MatchColors.Background, containerColor = MatchColors.Background,
titleContentColor = Color.White, titleContentColor = Color.White,