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 %>
@@ -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,