Aggiunge copertina sponsor custom solo Premium Full, con override in wizard e slate sui nodi di streaming.

Permette upload da portale e app con ereditarietà partita→squadra→società, generazione MP4 via ffmpeg e distribuzione su home lab e nodi CPX per pause e assenza segnale.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-19 23:47:17 +02:00
co-authored by Cursor
parent a51d5e8da5
commit 41d4235258
62 changed files with 1487 additions and 20 deletions
@@ -85,6 +85,7 @@ data class TeamDto(
@Json(name = "phone_download_enabled") val phoneDownloadEnabled: Boolean? = false,
@Json(name = "billing_url") val billingUrl: String? = null,
@Json(name = "staff_manage_url") val staffManageUrl: String? = null,
@Json(name = "custom_cover_enabled") val customCoverEnabled: Boolean? = false,
@Json(name = "logo_url") val logoUrl: String? = null,
@Json(name = "primary_color") val primaryColor: String? = null,
@Json(name = "secondary_color") val secondaryColor: String? = null,
@@ -111,6 +112,7 @@ data class TeamDto(
phoneDownloadEnabled = phoneDownloadEnabled ?: false,
billingUrl = billingUrl,
staffManageUrl = staffManageUrl,
customCoverEnabled = customCoverEnabled ?: false,
)
}
@@ -140,6 +142,9 @@ data class MatchDto(
@Json(name = "home_logo_url") val homeLogoUrl: String? = null,
@Json(name = "opponent_primary_color") val opponentPrimaryColor: String? = null,
@Json(name = "opponent_logo_url") val opponentLogoUrl: String? = null,
@Json(name = "effective_cover_url") val effectiveCoverUrl: String? = null,
@Json(name = "cover_source") val coverSource: String? = null,
@Json(name = "custom_cover_enabled") val customCoverEnabled: Boolean? = null,
) {
fun toDomain() = Match(
id = id,
@@ -165,6 +170,9 @@ data class MatchDto(
homeLogoUrl = homeLogoUrl,
opponentPrimaryColor = opponentPrimaryColor,
opponentLogoUrl = opponentLogoUrl,
effectiveCoverUrl = effectiveCoverUrl,
coverSource = coverSource ?: "default",
customCoverEnabled = customCoverEnabled ?: false,
)
}
@@ -91,6 +91,8 @@ interface MatchLiveApi {
@Part("match[scoring_rules][points_deciding_set]") pointsDecidingSet: okhttp3.RequestBody?,
@Part("match[scoring_rules][min_point_lead]") minPointLead: okhttp3.RequestBody?,
@Part opponentLogoFile: MultipartBody.Part?,
@Part coverImage: MultipartBody.Part?,
@Part("remove_cover") removeCover: okhttp3.RequestBody?,
): MatchDto
@DELETE("matches/{matchId}")
@@ -122,6 +122,8 @@ class MatchRepository(
sportKey: String? = null,
overlayKind: String? = null,
opponentLogoUri: Uri? = null,
coverImageUri: Uri? = null,
removeCover: Boolean = false,
): Match {
val body = UpdateMatchBodyFull(
opponentName = opponentName,
@@ -134,7 +136,7 @@ class MatchRepository(
opponentPrimaryColor = opponentPrimaryColor,
scoringRules = scoringRules?.toMap().takeIf { !it.isNullOrEmpty() },
)
if (opponentLogoUri != null) {
if (opponentLogoUri != null || coverImageUri != null || removeCover) {
return api.updateMatchMultipart(
matchId = matchId,
opponentName = MultipartBodies.textPart(opponentName),
@@ -146,7 +148,13 @@ class MatchRepository(
pointsPerSet = scoringRules?.pointsPerSet?.let { MultipartBodies.textPart(it.toString()) },
pointsDecidingSet = scoringRules?.pointsDecidingSet?.let { MultipartBodies.textPart(it.toString()) },
minPointLead = scoringRules?.minPointLead?.let { MultipartBodies.textPart(it.toString()) },
opponentLogoFile = MultipartBodies.logoPart(appContext, opponentLogoUri, "opponent_logo_file"),
opponentLogoFile = opponentLogoUri?.let {
MultipartBodies.logoPart(appContext, it, "opponent_logo_file")
},
coverImage = coverImageUri?.let {
MultipartBodies.logoPart(appContext, it, "cover_image")
},
removeCover = if (removeCover) MultipartBodies.textPart("1") else null,
).toDomain()
}
return api.updateMatch(
@@ -43,6 +43,7 @@ data class Team(
val phoneDownloadEnabled: Boolean = false,
val billingUrl: String? = null,
val staffManageUrl: String? = null,
val customCoverEnabled: Boolean = false,
) {
val canUseYoutube: Boolean get() = youtubeEnabled
val isYoutubeReady: Boolean get() = youtubeSelectable
@@ -100,6 +101,9 @@ data class Match(
val homeLogoUrl: String? = null,
val opponentPrimaryColor: String? = null,
val opponentLogoUrl: String? = null,
val effectiveCoverUrl: String? = null,
val coverSource: String = "default",
val customCoverEnabled: Boolean = false,
) {
val hasActiveSession: Boolean get() = activeSessionId != null
@@ -0,0 +1,105 @@
package com.matchlivetv.match_live_tv.ui.wizard
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.matchlivetv.match_live_tv.R
import com.matchlivetv.match_live_tv.core.resolveMediaUrl
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.theme.MatchColors
@Composable
fun coverSourceLabel(source: String): String = when (source) {
"match" -> stringResource(R.string.wizard_match_cover_source_match)
"team" -> stringResource(R.string.wizard_match_cover_source_team)
"club" -> stringResource(R.string.wizard_match_cover_source_club)
else -> stringResource(R.string.wizard_match_cover_source_default)
}
@Composable
fun MatchCoverSection(
effectiveCoverUrl: String?,
coverSource: String,
customCoverEnabled: Boolean,
localCoverUri: Uri?,
hasMatchOverride: Boolean,
onPickCover: () -> Unit,
onClearOverride: () -> Unit,
modifier: Modifier = Modifier,
) {
val previewModel = localCoverUri ?: resolveMediaUrl(effectiveCoverUrl)
Column(modifier.fillMaxWidth()) {
Text(
stringResource(R.string.wizard_match_cover_title),
style = MaterialTheme.typography.labelLarge,
color = MatchColors.TextSecondary,
)
Text(
stringResource(R.string.wizard_match_cover_source_label, coverSourceLabel(coverSource)),
style = MaterialTheme.typography.bodyMedium,
color = MatchColors.TextSecondary,
modifier = Modifier.padding(top = 4.dp),
)
Box(
Modifier
.fillMaxWidth()
.padding(top = 12.dp)
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(12.dp))
.background(MatchColors.Surface),
) {
if (previewModel != null) {
AsyncImage(
model = previewModel,
contentDescription = stringResource(R.string.wizard_match_cover_preview_cd),
modifier = Modifier.fillMaxWidth(),
contentScale = ContentScale.Crop,
)
}
}
Text(
stringResource(R.string.wizard_match_cover_hint),
style = MaterialTheme.typography.bodyMedium,
color = MatchColors.TextSecondary,
modifier = Modifier.padding(top = 8.dp),
)
if (customCoverEnabled) {
MatchPrimaryButton(
label = stringResource(R.string.wizard_match_cover_change),
onClick = onPickCover,
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
)
if (hasMatchOverride || localCoverUri != null) {
MatchSecondaryButton(
label = stringResource(R.string.wizard_match_cover_reset),
onClick = onClearOverride,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
}
} else {
Text(
stringResource(R.string.wizard_match_cover_premium_required),
style = MaterialTheme.typography.bodyMedium,
color = MatchColors.TextSecondary,
modifier = Modifier.padding(top = 12.dp),
)
}
}
}
@@ -105,6 +105,11 @@ fun StepMatchScreen(
}
var homeLogoUri by remember { mutableStateOf<Uri?>(null) }
var opponentLogoUri by remember { mutableStateOf<Uri?>(null) }
var coverImageUri by remember { mutableStateOf<Uri?>(null) }
var removeCover by remember { mutableStateOf(false) }
var effectiveCoverUrl by remember { mutableStateOf(match.effectiveCoverUrl) }
var coverSource by remember { mutableStateOf(match.coverSource) }
var customCoverEnabled by remember { mutableStateOf(match.customCoverEnabled) }
LaunchedEffect(match.teamId) {
runCatching { container.matchRepository.fetchTeam(match.teamId) }
@@ -113,10 +118,20 @@ fun StepMatchScreen(
if (team != null) {
homePrimaryColor = normalizeHexColor(team.primaryColor ?: match.homePrimaryColor)
homeLogoUrl = team.logoUrl ?: match.homeLogoUrl
if (!match.customCoverEnabled) {
customCoverEnabled = team.customCoverEnabled
}
}
}
}
val pickCover = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia(),
) { uri ->
coverImageUri = uri
removeCover = false
}
val pickHomeLogo = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia(),
) { uri -> homeLogoUri = uri }
@@ -230,6 +245,24 @@ fun StepMatchScreen(
onClearLogo = { opponentLogoUri = null },
)
Spacer(Modifier.height(16.dp))
MatchCoverSection(
effectiveCoverUrl = effectiveCoverUrl,
coverSource = when {
coverImageUri != null -> "match"
else -> coverSource
},
customCoverEnabled = customCoverEnabled,
localCoverUri = coverImageUri,
hasMatchOverride = match.coverSource == "match",
onPickCover = {
pickCover.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
},
onClearOverride = {
coverImageUri = null
removeCover = true
},
)
Spacer(Modifier.height(16.dp))
MatchOutlinedField(
value = location,
onValueChange = { location = it },
@@ -500,9 +533,13 @@ fun StepMatchScreen(
""
},
opponentLogoUri = opponentLogoUri,
coverImageUri = coverImageUri,
removeCover = removeCover,
)
}.onSuccess { updated ->
container.wizardSession.match = updated
effectiveCoverUrl = updated.effectiveCoverUrl
coverSource = updated.coverSource
onNext()
}.onFailure {
onError(it.message ?: saveGenericError)
@@ -64,6 +64,17 @@
<string name="wizard_match_location_label">Ort</string>
<string name="wizard_match_category_label">Liga (optional)</string>
<string name="wizard_match_category_hint">Z. B. Serie C, Sommerturnier — wird in Beschreibung und Overlay verwendet.</string>
<string name="wizard_match_cover_title">Pausenbild</string>
<string name="wizard_match_cover_source_label">Quelle: %1$s</string>
<string name="wizard_match_cover_source_default">Match Live TV</string>
<string name="wizard_match_cover_source_club">Verein</string>
<string name="wizard_match_cover_source_team">Team</string>
<string name="wizard_match_cover_source_match">Dieses Spiel</string>
<string name="wizard_match_cover_hint">Wird bei Pause oder ohne Signal angezeigt, bis der Stream weitergeht.</string>
<string name="wizard_match_cover_change">Bild für dieses Spiel ändern</string>
<string name="wizard_match_cover_reset">Standardbild verwenden</string>
<string name="wizard_match_cover_premium_required">Individuelles Sponsor-Bild mit Premium Full</string>
<string name="wizard_match_cover_preview_cd">Bildvorschau</string>
<string name="wizard_match_scheduled_label">Geplant für</string>
<string name="wizard_match_custom_overlay_label">Individuelles Video-Overlay</string>
<string name="wizard_match_custom_rules_label">Individuelle Punkteregeln</string>
@@ -64,6 +64,17 @@
<string name="wizard_match_location_label">Location</string>
<string name="wizard_match_category_label">Category (optional)</string>
<string name="wizard_match_category_hint">E.g. Serie C, summer tournament — we\'ll use it in the description and overlay.</string>
<string name="wizard_match_cover_title">Pause cover</string>
<string name="wizard_match_cover_source_label">Source: %1$s</string>
<string name="wizard_match_cover_source_default">Match Live TV</string>
<string name="wizard_match_cover_source_club">Club</string>
<string name="wizard_match_cover_source_team">Team</string>
<string name="wizard_match_cover_source_match">This match</string>
<string name="wizard_match_cover_hint">Shown while paused or offline until the stream resumes.</string>
<string name="wizard_match_cover_change">Change cover for this match</string>
<string name="wizard_match_cover_reset">Use default cover</string>
<string name="wizard_match_cover_premium_required">Custom sponsor cover requires Premium Full</string>
<string name="wizard_match_cover_preview_cd">Cover preview</string>
<string name="wizard_match_scheduled_label">Scheduled for</string>
<string name="wizard_match_custom_overlay_label">Custom video overlay</string>
<string name="wizard_match_custom_rules_label">Custom scoring rules</string>
@@ -64,6 +64,17 @@
<string name="wizard_match_location_label">Lugar</string>
<string name="wizard_match_category_label">Categoría (opcional)</string>
<string name="wizard_match_category_hint">Ej. Serie C, torneo de verano — lo usaremos en la descripción y el overlay.</string>
<string name="wizard_match_cover_title">Imagen de pausa</string>
<string name="wizard_match_cover_source_label">Origen: %1$s</string>
<string name="wizard_match_cover_source_default">Match Live TV</string>
<string name="wizard_match_cover_source_club">Club</string>
<string name="wizard_match_cover_source_team">Equipo</string>
<string name="wizard_match_cover_source_match">Este partido</string>
<string name="wizard_match_cover_hint">Se muestra en pausa o sin señal hasta que retome la transmisión.</string>
<string name="wizard_match_cover_change">Cambiar imagen para este partido</string>
<string name="wizard_match_cover_reset">Usar imagen predeterminada</string>
<string name="wizard_match_cover_premium_required">Imagen de patrocinio personalizada con Premium Full</string>
<string name="wizard_match_cover_preview_cd">Vista previa de la imagen</string>
<string name="wizard_match_scheduled_label">Programado para</string>
<string name="wizard_match_custom_overlay_label">Overlay de vídeo personalizado</string>
<string name="wizard_match_custom_rules_label">Reglas de puntuación personalizadas</string>
@@ -64,6 +64,17 @@
<string name="wizard_match_location_label">Lieu</string>
<string name="wizard_match_category_label">Championnat (facultatif)</string>
<string name="wizard_match_category_hint">Ex. Serie C, tournoi d\'été — utilisé dans la description et l\'overlay.</string>
<string name="wizard_match_cover_title">Image de pause</string>
<string name="wizard_match_cover_source_label">Source : %1$s</string>
<string name="wizard_match_cover_source_default">Match Live TV</string>
<string name="wizard_match_cover_source_club">Club</string>
<string name="wizard_match_cover_source_team">Équipe</string>
<string name="wizard_match_cover_source_match">Ce match</string>
<string name="wizard_match_cover_hint">Affichée en pause ou sans signal jusqu\'à la reprise du direct.</string>
<string name="wizard_match_cover_change">Changer l\'image pour ce match</string>
<string name="wizard_match_cover_reset">Utiliser l\'image par défaut</string>
<string name="wizard_match_cover_premium_required">Image sponsor personnalisée avec Premium Full</string>
<string name="wizard_match_cover_preview_cd">Aperçu de l\'image</string>
<string name="wizard_match_scheduled_label">Programmé pour</string>
<string name="wizard_match_custom_overlay_label">Overlay vidéo personnalisé</string>
<string name="wizard_match_custom_rules_label">Règles de score personnalisées</string>
@@ -65,6 +65,17 @@
<string name="wizard_match_location_label">Luogo</string>
<string name="wizard_match_category_label">Campionato (facoltativo)</string>
<string name="wizard_match_category_hint">Es. Serie C, torneo estivo — lo useremo in descrizione e overlay.</string>
<string name="wizard_match_cover_title">Copertina in pausa</string>
<string name="wizard_match_cover_source_label">Origine: %1$s</string>
<string name="wizard_match_cover_source_default">Match Live TV</string>
<string name="wizard_match_cover_source_club">Società</string>
<string name="wizard_match_cover_source_team">Squadra</string>
<string name="wizard_match_cover_source_match">Questa partita</string>
<string name="wizard_match_cover_hint">Mostrata in pausa o senza segnale finché non riparte la diretta.</string>
<string name="wizard_match_cover_change">Cambia copertina per questa partita</string>
<string name="wizard_match_cover_reset">Usa quella predefinita</string>
<string name="wizard_match_cover_premium_required">Copertina sponsor personalizzata con Premium Full</string>
<string name="wizard_match_cover_preview_cd">Anteprima copertina</string>
<string name="wizard_match_scheduled_label">Programmata per</string>
<string name="wizard_match_custom_overlay_label">Overlay video personalizzato</string>
<string name="wizard_match_custom_rules_label">Regole punteggio personalizzate</string>
@@ -57,6 +57,7 @@
713E0129656649FC8630FDC6 /* LivePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46CE696CAB994E2C8E399776 /* LivePreviewView.swift */; };
7575030B9AEB4DEBAD986A23 /* MatchColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2996990D08341ABA52BE287 /* MatchColors.swift */; };
777B73E69F8A4DC6A8E7C1DF /* StepMatchScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB037B1369B454D84F6850D /* StepMatchScreen.swift */; };
A1B2C3D4E5F60718293A4B5D /* MatchCoverSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C3D4E5F60718293A4B5D6E /* MatchCoverSection.swift */; };
8723E1F8443A46B3AF49E8A3 /* ScoreState.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA08E23008964D02AB59377E /* ScoreState.swift */; };
8C8FEABCD83C422BA5A5DEA3 /* MatchLiveTv/Resources/de.lproj in Resources */ = {isa = PBXBuildFile; fileRef = EEFE07D4B6FD44199A5B9C25 /* MatchLiveTv/Resources/de.lproj */; };
9391C9EA0B834DC8956483DF /* ActionCableClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98526C26B2584F55AADF0FFF /* ActionCableClient.swift */; };
@@ -194,6 +195,7 @@
E860A0E35362427DA84F8972 /* BroadcastControlsOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastControlsOverlay.swift; path = MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift; sourceTree = "<group>"; };
EA08E23008964D02AB59377E /* ScoreState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreState.swift; path = MatchLiveTv/Domain/ScoreState.swift; sourceTree = "<group>"; };
EAB037B1369B454D84F6850D /* StepMatchScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StepMatchScreen.swift; path = MatchLiveTv/UI/Wizard/StepMatchScreen.swift; sourceTree = "<group>"; };
B2C3D4E5F60718293A4B5D6E /* MatchCoverSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchCoverSection.swift; path = MatchLiveTv/UI/Wizard/MatchCoverSection.swift; sourceTree = "<group>"; };
EE0A49E8D59947448DAA7851 /* AuthRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AuthRepository.swift; path = MatchLiveTv/Data/Repository/AuthRepository.swift; sourceTree = "<group>"; };
EEFE07D4B6FD44199A5B9C25 /* MatchLiveTv/Resources/de.lproj */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MatchLiveTv/Resources/de.lproj; sourceTree = "<group>"; };
F438E7E164F44C7B856C4808 /* LiveBroadcastCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LiveBroadcastCoordinator.swift; path = MatchLiveTv/Streaming/LiveBroadcastCoordinator.swift; sourceTree = "<group>"; };
@@ -324,6 +326,7 @@
2B5C00419D09442FA8ED60F3 /* ShareSheet.swift */,
D2996990D08341ABA52BE287 /* MatchColors.swift */,
EAB037B1369B454D84F6850D /* StepMatchScreen.swift */,
B2C3D4E5F60718293A4B5D6E /* MatchCoverSection.swift */,
1079231B9A4E446AA9AD6C0A /* StepNetworkTestScreen.swift */,
7EC0179FE6BC4323B1F9E1D3 /* StepTransmissionScreen.swift */,
6FBBE0F1399840FFA0E7EF2E /* TeamBrandingEditor.swift */,
@@ -502,6 +505,7 @@
1A82C6F343F54284AFA8FECE /* ShareSheet.swift in Sources */,
7575030B9AEB4DEBAD986A23 /* MatchColors.swift in Sources */,
777B73E69F8A4DC6A8E7C1DF /* StepMatchScreen.swift in Sources */,
A1B2C3D4E5F60718293A4B5D /* MatchCoverSection.swift in Sources */,
F67C0C88237F41DE8EDF6CB8 /* StepNetworkTestScreen.swift in Sources */,
2B8E5D9945364094B74165DE /* StepTransmissionScreen.swift in Sources */,
45776A6A76D04EF5A0C0D187 /* TeamBrandingEditor.swift in Sources */,
@@ -323,6 +323,16 @@ enum L10n {
"wizard.error.youtube.unavailable": "YouTube non disponibile per questa squadra",
"wizard.match.away.team.label": "Squadra avversaria",
"wizard.match.category.hint": "Es. Serie C, torneo estivo — lo useremo in descrizione e overlay.",
"wizard.match.cover.title": "Copertina in pausa",
"wizard.match.cover.source.label": "Origine: %1$@",
"wizard.match.cover.source.default": "Match Live TV",
"wizard.match.cover.source.club": "Società",
"wizard.match.cover.source.team": "Squadra",
"wizard.match.cover.source.match": "Questa partita",
"wizard.match.cover.hint": "Mostrata in pausa o senza segnale finché non riparte la diretta.",
"wizard.match.cover.change": "Cambia copertina per questa partita",
"wizard.match.cover.reset": "Usa quella predefinita",
"wizard.match.cover.premium.required": "Copertina sponsor personalizzata con Premium Full",
"wizard.match.category.label": "Campionato (facoltativo)",
"wizard.match.custom.overlay.label": "Overlay video personalizzato",
"wizard.match.custom.rules.label": "Regole punteggio personalizzate",
@@ -628,6 +638,16 @@ enum L10n {
"wizard.error.youtube.unavailable": "YouTube not available for this team",
"wizard.match.away.team.label": "Away team",
"wizard.match.category.hint": "E.g. Serie C, summer tournament — we'll use it in the description and overlay.",
"wizard.match.cover.title": "Pause cover",
"wizard.match.cover.source.label": "Source: %1$@",
"wizard.match.cover.source.default": "Match Live TV",
"wizard.match.cover.source.club": "Club",
"wizard.match.cover.source.team": "Team",
"wizard.match.cover.source.match": "This match",
"wizard.match.cover.hint": "Shown while paused or offline until the stream resumes.",
"wizard.match.cover.change": "Change cover for this match",
"wizard.match.cover.reset": "Use default cover",
"wizard.match.cover.premium.required": "Custom sponsor cover requires Premium Full",
"wizard.match.category.label": "Category (optional)",
"wizard.match.custom.overlay.label": "Custom video overlay",
"wizard.match.custom.rules.label": "Custom scoring rules",
@@ -933,6 +953,16 @@ enum L10n {
"wizard.error.youtube.unavailable": "YouTube non disponible pour cette équipe",
"wizard.match.away.team.label": "Équipe adverse",
"wizard.match.category.hint": "Ex. Serie C, tournoi d'été — utilisé dans la description et l'overlay.",
"wizard.match.cover.title": "Image de pause",
"wizard.match.cover.source.label": "Source : %1$@",
"wizard.match.cover.source.default": "Match Live TV",
"wizard.match.cover.source.club": "Club",
"wizard.match.cover.source.team": "Équipe",
"wizard.match.cover.source.match": "Ce match",
"wizard.match.cover.hint": "Affichée en pause ou sans signal jusqu'à la reprise du direct.",
"wizard.match.cover.change": "Changer l'image pour ce match",
"wizard.match.cover.reset": "Utiliser l'image par défaut",
"wizard.match.cover.premium.required": "Image sponsor personnalisée avec Premium Full",
"wizard.match.category.label": "Championnat (facultatif)",
"wizard.match.custom.overlay.label": "Overlay vidéo personnalisé",
"wizard.match.custom.rules.label": "Règles de score personnalisées",
@@ -1238,6 +1268,16 @@ enum L10n {
"wizard.error.youtube.unavailable": "YouTube für dieses Team nicht verfügbar",
"wizard.match.away.team.label": "Gastteam",
"wizard.match.category.hint": "Z. B. Serie C, Sommerturnier — wird in Beschreibung und Overlay verwendet.",
"wizard.match.cover.title": "Pausenbild",
"wizard.match.cover.source.label": "Quelle: %1$@",
"wizard.match.cover.source.default": "Match Live TV",
"wizard.match.cover.source.club": "Verein",
"wizard.match.cover.source.team": "Team",
"wizard.match.cover.source.match": "Dieses Spiel",
"wizard.match.cover.hint": "Wird bei Pause oder ohne Signal angezeigt, bis der Stream weitergeht.",
"wizard.match.cover.change": "Bild für dieses Spiel ändern",
"wizard.match.cover.reset": "Standardbild verwenden",
"wizard.match.cover.premium.required": "Individuelles Sponsor-Bild mit Premium Full",
"wizard.match.category.label": "Liga (optional)",
"wizard.match.custom.overlay.label": "Individuelles Video-Overlay",
"wizard.match.custom.rules.label": "Individuelle Punkteregeln",
@@ -1543,6 +1583,16 @@ enum L10n {
"wizard.error.youtube.unavailable": "YouTube no disponible para este equipo",
"wizard.match.away.team.label": "Equipo visitante",
"wizard.match.category.hint": "Ej. Serie C, torneo de verano — lo usaremos en la descripción y el overlay.",
"wizard.match.cover.title": "Imagen de pausa",
"wizard.match.cover.source.label": "Origen: %1$@",
"wizard.match.cover.source.default": "Match Live TV",
"wizard.match.cover.source.club": "Club",
"wizard.match.cover.source.team": "Equipo",
"wizard.match.cover.source.match": "Este partido",
"wizard.match.cover.hint": "Se muestra en pausa o sin señal hasta que retome la transmisión.",
"wizard.match.cover.change": "Cambiar imagen para este partido",
"wizard.match.cover.reset": "Usar imagen predeterminada",
"wizard.match.cover.premium.required": "Imagen de patrocinio personalizada con Premium Full",
"wizard.match.category.label": "Categoría (opcional)",
"wizard.match.custom.overlay.label": "Overlay de vídeo personalizado",
"wizard.match.custom.rules.label": "Reglas de puntuación personalizadas",
+10 -2
View File
@@ -101,6 +101,7 @@ struct TeamDto: Decodable {
let phoneDownloadEnabled: Bool?
let billingUrl: String?
let staffManageUrl: String?
let customCoverEnabled: Bool?
let logoUrl: String?
let primaryColor: String?
let secondaryColor: String?
@@ -127,7 +128,8 @@ struct TeamDto: Decodable {
recordingsEnabled: recordingsEnabled ?? false,
phoneDownloadEnabled: phoneDownloadEnabled ?? false,
billingUrl: billingUrl,
staffManageUrl: staffManageUrl
staffManageUrl: staffManageUrl,
customCoverEnabled: customCoverEnabled ?? false
)
}
}
@@ -157,6 +159,9 @@ struct MatchDto: Decodable {
let homeLogoUrl: String?
let opponentPrimaryColor: String?
let opponentLogoUrl: String?
let effectiveCoverUrl: String?
let coverSource: String?
let customCoverEnabled: Bool?
func toDomain() -> Match {
Match(
@@ -182,7 +187,10 @@ struct MatchDto: Decodable {
homeSecondaryColor: homeSecondaryColor,
homeLogoUrl: homeLogoUrl,
opponentPrimaryColor: opponentPrimaryColor,
opponentLogoUrl: opponentLogoUrl
opponentLogoUrl: opponentLogoUrl,
effectiveCoverUrl: effectiveCoverUrl,
coverSource: coverSource ?? "default",
customCoverEnabled: customCoverEnabled ?? false
)
}
}
@@ -101,7 +101,9 @@ final class MatchRepository {
scoringRules: ScoringRules?,
sportKey: String? = nil,
overlayKind: String? = nil,
opponentLogoImage: UIImage? = nil
opponentLogoImage: UIImage? = nil,
coverImage: UIImage? = nil,
removeCover: Bool = false
) async throws -> Match {
let rulesMap = scoringRules?.toMap(setsToWin: setsToWin)
let body = UpdateMatchBodyFull(
@@ -115,7 +117,8 @@ final class MatchRepository {
opponentPrimaryColor: opponentPrimaryColor,
scoringRules: rulesMap
)
if let opponentLogoImage, let data = opponentLogoImage.pngData() {
let needsMultipart = opponentLogoImage != nil || coverImage != nil || removeCover
if needsMultipart {
var fields: [MultipartField] = [
.text("match[opponent_name]", opponentName),
.text("match[sets_to_win]", "\(setsToWin)"),
@@ -129,7 +132,15 @@ final class MatchRepository {
fields.append(.text("match[scoring_rules][points_deciding_set]", "\(rules.pointsDecidingSet)"))
fields.append(.text("match[scoring_rules][min_point_lead]", "\(rules.minPointLead)"))
}
fields.append(.file("opponent_logo_file", data, filename: "opponent.png", mime: "image/png"))
if let opponentLogoImage, let data = opponentLogoImage.pngData() {
fields.append(.file("opponent_logo_file", data, filename: "opponent.png", mime: "image/png"))
}
if let coverImage, let data = coverImage.pngData() {
fields.append(.file("cover_image", data, filename: "cover.png", mime: "image/png"))
}
if removeCover {
fields.append(.text("remove_cover", "1"))
}
return try await api.updateMatchMultipart(id: matchId, fields: fields).toDomain()
}
return try await api.updateMatch(id: matchId, body: UpdateMatchRequestFull(match: body)).toDomain()
@@ -44,6 +44,7 @@ struct Team: Identifiable, Equatable, Sendable {
let phoneDownloadEnabled: Bool
let billingUrl: String?
let staffManageUrl: String?
let customCoverEnabled: Bool
var canUseYoutube: Bool { youtubeEnabled }
var isYoutubeReady: Bool { youtubeSelectable }
@@ -95,6 +96,9 @@ struct Match: Identifiable, Equatable, Sendable {
let homeLogoUrl: String?
let opponentPrimaryColor: String?
let opponentLogoUrl: String?
let effectiveCoverUrl: String?
let coverSource: String
let customCoverEnabled: Bool
var hasActiveSession: Bool { activeSessionId != nil }
@@ -0,0 +1,97 @@
import PhotosUI
import SwiftUI
struct MatchCoverSection: View {
let effectiveCoverUrl: String?
let coverSource: String
let customCoverEnabled: Bool
@Binding var localCoverImage: UIImage?
let hasMatchOverride: Bool
let onClearOverride: () -> Void
@State private var coverPickerItem: PhotosPickerItem?
private var sourceLabel: String {
switch coverSource {
case "match": return L10n.t("wizard.match.cover.source.match")
case "team": return L10n.t("wizard.match.cover.source.team")
case "club": return L10n.t("wizard.match.cover.source.club")
default: return L10n.t("wizard.match.cover.source.default")
}
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text(L10n.t("wizard.match.cover.title"))
.font(MatchTypography.labelLarge)
.foregroundStyle(MatchColors.textSecondary)
Text(L10n.t("wizard.match.cover.source.label", sourceLabel))
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
.padding(.top, 4)
Group {
if let localCoverImage {
Image(uiImage: localCoverImage)
.resizable()
.scaledToFill()
} else if let urlString = MediaUrl.resolve(effectiveCoverUrl), let url = URL(string: urlString) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFill()
default:
Color.clear
}
}
} else {
Color.clear
}
}
.frame(maxWidth: .infinity)
.aspectRatio(16 / 9, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 12))
.background(MatchColors.surface)
.padding(.top, 12)
Text(L10n.t("wizard.match.cover.hint"))
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
.padding(.top, 8)
if customCoverEnabled {
PhotosPicker(selection: $coverPickerItem, matching: .images) {
Text(L10n.t("wizard.match.cover.change"))
.font(MatchTypography.labelLarge)
.frame(maxWidth: .infinity)
.frame(height: 52)
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline, lineWidth: 1))
}
.buttonStyle(.plain)
.padding(.top, 12)
if hasMatchOverride || localCoverImage != nil {
MatchSecondaryButton(
label: L10n.t("wizard.match.cover.reset"),
action: onClearOverride
)
.padding(.top, 8)
}
} else {
Text(L10n.t("wizard.match.cover.premium.required"))
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
.padding(.top, 12)
}
}
.onChange(of: coverPickerItem) { item in
guard let item else { return }
Task {
if let data = try? await item.loadTransferable(type: Data.self),
let image = UIImage(data: data) {
await MainActor.run { localCoverImage = image }
}
}
}
}
}
@@ -53,6 +53,11 @@ struct StepMatchScreen: View {
@State private var opponentPrimaryColor: String
@State private var homeLogoImage: UIImage?
@State private var opponentLogoImage: UIImage?
@State private var coverImage: UIImage?
@State private var removeCover = false
@State private var effectiveCoverUrl: String?
@State private var coverSource: String
@State private var customCoverEnabled: Bool
@State private var saving = false
@State private var sports: [SportOption] = []
@State private var customRules = false
@@ -84,6 +89,9 @@ struct StepMatchScreen: View {
_opponentLogoUrl = State(initialValue: match.opponentLogoUrl)
_homePrimaryColor = State(initialValue: ColorHex.normalizeHexColor(match.homePrimaryColor))
_opponentPrimaryColor = State(initialValue: ColorHex.normalizeHexColor(match.opponentPrimaryColor, fallback: WizardDefaults.opponentColor))
_effectiveCoverUrl = State(initialValue: match.effectiveCoverUrl)
_coverSource = State(initialValue: match.coverSource)
_customCoverEnabled = State(initialValue: match.customCoverEnabled)
_setsToWin = State(initialValue: min(max(match.setsToWin, 2), 3))
_pointsPerSet = State(initialValue: rules?.pointsPerSet ?? WizardDefaults.pointsPerSet)
_pointsDecidingSet = State(initialValue: rules?.pointsDecidingSet ?? WizardDefaults.pointsDecidingSet)
@@ -150,6 +158,19 @@ struct StepMatchScreen: View {
)
.padding(.bottom, 16)
MatchCoverSection(
effectiveCoverUrl: effectiveCoverUrl,
coverSource: coverImage != nil ? "match" : coverSource,
customCoverEnabled: customCoverEnabled,
localCoverImage: $coverImage,
hasMatchOverride: match.coverSource == "match",
onClearOverride: {
coverImage = nil
removeCover = true
}
)
.padding(.bottom, 16)
WizardOutlinedField(label: L10n.t("wizard.match.location.label"), text: $location)
.padding(.bottom, 12)
@@ -213,9 +234,12 @@ struct StepMatchScreen: View {
.task(id: match.id) {
if let cached = container.wizardSession.team {
applyHomeTeam(cached)
} else if let team = await container.matchRepository.fetchTeam(teamId: match.teamId) {
} else if let team = await container.matchRepository.fetchTeam(teamId: match.teamId) {
container.wizardSession.team = team
applyHomeTeam(team)
if !match.customCoverEnabled {
customCoverEnabled = team.customCoverEnabled
}
}
if let loaded = try? await container.matchRepository.fetchSports() {
sports = loaded
@@ -228,6 +252,9 @@ struct StepMatchScreen: View {
selectedOverlay = defaultOverlay
}
}
.onChange(of: coverImage) { image in
if image != nil { removeCover = false }
}
.onChange(of: customOverlay) { enabled in
if !enabled {
selectedOverlay = defaultOverlay
@@ -414,9 +441,13 @@ struct StepMatchScreen: View {
scoringRules: resolvedRules,
sportKey: effectiveSportKey,
overlayKind: overlayKind,
opponentLogoImage: opponentLogoImage
opponentLogoImage: opponentLogoImage,
coverImage: coverImage,
removeCover: removeCover
)
container.wizardSession.match = updated
effectiveCoverUrl = updated.effectiveCoverUrl
coverSource = updated.coverSource
onNext()
} catch {
if let message = UserFacingError.message(for: error) {
@@ -26,7 +26,10 @@ final class MatchScoringRulesTests: XCTestCase {
homeSecondaryColor: nil,
homeLogoUrl: nil,
opponentPrimaryColor: "#1E3A8A",
opponentLogoUrl: nil
opponentLogoUrl: nil,
effectiveCoverUrl: nil,
coverSource: "default",
customCoverEnabled: false
)
}
@@ -87,7 +90,10 @@ final class MatchScoringRulesTests: XCTestCase {
homeSecondaryColor: nil,
homeLogoUrl: nil,
opponentPrimaryColor: nil,
opponentLogoUrl: nil
opponentLogoUrl: nil,
effectiveCoverUrl: nil,
coverSource: "default",
customCoverEnabled: false
)
XCTAssertFalse(MatchScoringContext(match: basket).usesSetLogic)
}