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:
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -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}")
|
||||
|
||||
+10
-2
@@ -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
|
||||
|
||||
|
||||
+105
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user