From 34c97f44478bc931ad11e0cf22a7df4a21f8efdc Mon Sep 17 00:00:00 2001 From: Emiliano Frascaro Date: Tue, 9 Jun 2026 22:32:36 +0200 Subject: [PATCH] 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 --- .../controllers/api/v1/matches_controller.rb | 3 +- backend/app/domain/sports/player_roles.rb | 25 +++++ backend/app/helpers/roster_helper.rb | 5 +- backend/app/models/match.rb | 5 +- backend/app/models/team_roster_member.rb | 14 +-- .../views/shared/_roster_member_form.html.erb | 5 +- backend/spec/models/match_scheduling_spec.rb | 9 ++ .../ui/wizard/StepMatchScreen.kt | 94 +++++++++++++------ .../ui/wizard/WizardShellScreen.kt | 25 ++++- 9 files changed, 136 insertions(+), 49 deletions(-) create mode 100644 backend/app/domain/sports/player_roles.rb diff --git a/backend/app/controllers/api/v1/matches_controller.rb b/backend/app/controllers/api/v1/matches_controller.rb index 7779bd1..250ab2c 100644 --- a/backend/app/controllers/api/v1/matches_controller.rb +++ b/backend/app/controllers/api/v1/matches_controller.rb @@ -17,7 +17,7 @@ module Api def create 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) match = @team.matches.create!(attrs) attach_opponent_logo(match) @@ -70,6 +70,7 @@ module Api if p[:sport].present? && p[:sport_key].blank? p[:sport_key] = p.delete(:sport) end + p[:overlay_kind] = nil if p.key?(:overlay_kind) && p[:overlay_kind].blank? p end diff --git a/backend/app/domain/sports/player_roles.rb b/backend/app/domain/sports/player_roles.rb new file mode 100644 index 0000000..c241603 --- /dev/null +++ b/backend/app/domain/sports/player_roles.rb @@ -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 diff --git a/backend/app/helpers/roster_helper.rb b/backend/app/helpers/roster_helper.rb index 804b4b2..f9fbe8e 100644 --- a/backend/app/helpers/roster_helper.rb +++ b/backend/app/helpers/roster_helper.rb @@ -23,9 +23,10 @@ module RosterHelper 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( - [["— Seleziona ruolo —", ""]] + TeamRosterMember::VOLLEYBALL_PLAYER_ROLES.map { |r| [r, r] }, + [["— Seleziona ruolo —", ""]] + roles.map { |r| [r, r] }, selected ) end diff --git a/backend/app/models/match.rb b/backend/app/models/match.rb index da5abfe..ea7c112 100644 --- a/backend/app/models/match.rb +++ b/backend/app/models/match.rb @@ -129,7 +129,10 @@ class Match < ApplicationRecord end 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 def sport_key_known diff --git a/backend/app/models/team_roster_member.rb b/backend/app/models/team_roster_member.rb index 3b358ca..f506510 100644 --- a/backend/app/models/team_roster_member.rb +++ b/backend/app/models/team_roster_member.rb @@ -9,14 +9,7 @@ class TeamRosterMember < ApplicationRecord "player" => "Giocatori" }.freeze - VOLLEYBALL_PLAYER_ROLES = %w[ - Schiacciatore - Opposto - Centrale - Palleggiatore - Libero - Universale - ].freeze + VOLLEYBALL_PLAYER_ROLES = Sports::PlayerRoles::BY_BOARD["volley"].freeze belongs_to :team @@ -74,9 +67,10 @@ class TeamRosterMember < ApplicationRecord end 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 def photo_file_type diff --git a/backend/app/views/shared/_roster_member_form.html.erb b/backend/app/views/shared/_roster_member_form.html.erb index 767e544..be272d0 100644 --- a/backend/app/views/shared/_roster_member_form.html.erb +++ b/backend/app/views/shared/_roster_member_form.html.erb @@ -3,7 +3,8 @@ <% form_url = member.persisted? ? public_team_roster_member_path(team, member) : public_team_roster_members_path(team) %> <% form_method = member.persisted? ? :patch : :post %> <% 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 %>

<%= member.persisted? ? "Modifica scheda" : "Aggiungi persona" %>

@@ -19,7 +20,7 @@
> <%= 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 %>
> diff --git a/backend/spec/models/match_scheduling_spec.rb b/backend/spec/models/match_scheduling_spec.rb index df8c502..fef3f2a 100644 --- a/backend/spec/models/match_scheduling_spec.rb +++ b/backend/spec/models/match_scheduling_spec.rb @@ -53,6 +53,15 @@ RSpec.describe Match, "scheduling scopes" do expect(match.coach_hub_visible?).to be(false) 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 match = team.matches.create!(opponent_name: "Avversario", sets_to_win: 3) StreamSession.create!( 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 6b22161..a1dabee 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 @@ -50,6 +50,16 @@ private const val DEFAULT_PERIOD_DURATION_SECS = 600 private const val DEFAULT_OVERTIME_DURATION_SECS = 300 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. */ private fun matchHasCustomScoring(match: Match, boardType: String): Boolean { val rules = match.scoringRules ?: return false @@ -136,22 +146,41 @@ fun StepMatchScreen( 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) } + var customOverlay by remember { mutableStateOf(false) } + var selectedOverlay by remember { mutableStateOf(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 teamSportKey = homeTeam?.sportKey?.takeIf { it.isNotBlank() } + val effectiveSportKey = teamSportKey ?: selectedSportKey + val currentSport = sports.find { it.key == effectiveSportKey } val allowedOverlays = currentSport?.allowedOverlays.orEmpty() + val defaultOverlay = currentSport?.overlay ?: match.effectiveOverlayKind val boardType = currentSport?.board ?: homeTeam?.boardType ?: match.boardType LaunchedEffect(boardType, match.id) { 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( Modifier .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()) { - 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(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Overlay video personalizzato", modifier = Modifier.weight(1f)) + Switch(checked = customOverlay, onCheckedChange = { customOverlay = it }) + } + if (customOverlay) { + Spacer(Modifier.height(12.dp)) + allowedOverlays.forEach { overlayKey -> + val selected = selectedOverlay == overlayKey + val label = overlayLabel(overlayKey) + 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), + ) + } } } } @@ -440,9 +472,11 @@ fun StepMatchScreen( category = campionato.trim(), opponentPrimaryColor = opponentPrimaryColor, scoringRules = resolvedRules, - sportKey = selectedSportKey, - overlayKind = selectedOverlay.takeIf { - it != currentSport?.overlay + sportKey = effectiveSportKey, + overlayKind = if (customOverlay && selectedOverlay != defaultOverlay) { + selectedOverlay + } else { + "" }, opponentLogoUri = opponentLogoUri, ) diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/WizardShellScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/WizardShellScreen.kt index f1f1c93..ca644e6 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/WizardShellScreen.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/WizardShellScreen.kt @@ -3,6 +3,7 @@ package com.matchlivetv.match_live_tv.ui.wizard import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material3.CircularProgressIndicator @@ -25,9 +26,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.domain.Match 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.theme.MatchColors import kotlinx.coroutines.launch @@ -46,6 +49,7 @@ fun WizardShellScreen( val snackbarHostState = remember { SnackbarHostState() } var loading by remember { mutableStateOf(true) } var match by remember { mutableStateOf(null) } + var homeTeam by remember { mutableStateOf(null) } val currentStep = step.coerceIn(1, 3) fun showError(message: String) { @@ -55,9 +59,11 @@ fun WizardShellScreen( LaunchedEffect(matchId) { loading = true runCatching { container.matchRepository.fetchMatch(matchId) } - .onSuccess { - match = it - container.wizardSession.match = it + .onSuccess { loaded -> + match = loaded + container.wizardSession.match = loaded + runCatching { container.matchRepository.fetchTeam(loaded.teamId) } + .onSuccess { homeTeam = it } } loading = false } @@ -71,6 +77,19 @@ fun WizardShellScreen( 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( containerColor = MatchColors.Background, titleContentColor = Color.White,