Allinea hub partite app nativa e API match al wizard semplificato.
Filtra le partite visibili lato client/server, corregge il parsing date Rails su Android, semplifica lo step Partita del wizard e restringe l'API ai campi effettivamente usati (campionato, set e scoring_rules). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
package com.matchlivetv.match_live_tv.core
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.DateTimeParseException
|
||||
|
||||
/** Parsing date ISO da Rails/API (`2026-06-06T20:00:00.000+02:00`, ecc.). */
|
||||
fun parseApiInstant(raw: String?): Instant? {
|
||||
val value = raw?.trim().orEmpty()
|
||||
if (value.isEmpty()) return null
|
||||
|
||||
parseInstantOrNull(value)?.let { return it }
|
||||
|
||||
// Fallback: normalizza spazi e riprova.
|
||||
val normalized = value.replace(' ', 'T')
|
||||
if (normalized != value) {
|
||||
parseInstantOrNull(normalized)?.let { return it }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun parseInstantOrNull(value: String): Instant? {
|
||||
try {
|
||||
return Instant.parse(value)
|
||||
} catch (_: DateTimeParseException) {
|
||||
// Instant.parse fallisce su alcuni dispositivi con offset + frazioni di secondo.
|
||||
}
|
||||
|
||||
try {
|
||||
return OffsetDateTime.parse(value, DateTimeFormatter.ISO_OFFSET_DATE_TIME).toInstant()
|
||||
} catch (_: DateTimeParseException) {
|
||||
// continua
|
||||
}
|
||||
|
||||
try {
|
||||
return ZonedDateTime.parse(value, DateTimeFormatter.ISO_ZONED_DATE_TIME).toInstant()
|
||||
} catch (_: DateTimeParseException) {
|
||||
// continua
|
||||
}
|
||||
|
||||
return runCatching {
|
||||
LocalDateTime.parse(value, DateTimeFormatter.ISO_LOCAL_DATE_TIME)
|
||||
.atZone(ZoneId.of("Europe/Rome"))
|
||||
.toInstant()
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun Instant.isScheduledFuture(now: Instant = Instant.now()): Boolean = isAfter(now)
|
||||
@@ -3,6 +3,7 @@ package com.matchlivetv.match_live_tv.data.api
|
||||
import com.matchlivetv.match_live_tv.domain.AuthTokens
|
||||
import com.matchlivetv.match_live_tv.domain.Match
|
||||
import com.matchlivetv.match_live_tv.domain.Recording
|
||||
import com.matchlivetv.match_live_tv.domain.ScoringRules
|
||||
import com.matchlivetv.match_live_tv.domain.StreamSession
|
||||
import com.matchlivetv.match_live_tv.domain.Team
|
||||
import com.matchlivetv.match_live_tv.domain.User
|
||||
@@ -74,11 +75,13 @@ data class MatchDto(
|
||||
val location: String? = null,
|
||||
@Json(name = "scheduled_at") val scheduledAt: String? = null,
|
||||
@Json(name = "sets_to_win") val setsToWin: Int? = null,
|
||||
/** Campionato / descrizione torneo (facoltativo). */
|
||||
val category: String? = null,
|
||||
val phase: String? = null,
|
||||
@Json(name = "roster_numbers") val rosterNumbers: List<Int>? = null,
|
||||
@Json(name = "scoring_rules") val scoringRules: ScoringRulesBody? = null,
|
||||
@Json(name = "active_session_id") val activeSessionId: String? = null,
|
||||
@Json(name = "active_session_status") val activeSessionStatus: String? = null,
|
||||
@Json(name = "stream_completed") val streamCompleted: Boolean? = null,
|
||||
@Json(name = "coach_hub_visible") val coachHubVisible: Boolean? = null,
|
||||
) {
|
||||
fun toDomain() = Match(
|
||||
id = id,
|
||||
@@ -89,10 +92,11 @@ data class MatchDto(
|
||||
scheduledAt = scheduledAt,
|
||||
setsToWin = setsToWin ?: 3,
|
||||
category = category,
|
||||
phase = phase,
|
||||
rosterNumbers = rosterNumbers.orEmpty(),
|
||||
scoringRules = scoringRules?.toDomain(),
|
||||
activeSessionId = activeSessionId,
|
||||
activeSessionStatus = activeSessionStatus,
|
||||
streamCompleted = streamCompleted ?: false,
|
||||
coachHubVisible = coachHubVisible,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -186,8 +190,6 @@ data class UpdateMatchBody(
|
||||
@Json(name = "scheduled_at") val scheduledAt: String? = null,
|
||||
@Json(name = "sets_to_win") val setsToWin: Int? = null,
|
||||
val category: String? = null,
|
||||
val phase: String? = null,
|
||||
@Json(name = "roster_numbers") val rosterNumbers: List<Int>? = null,
|
||||
)
|
||||
|
||||
data class UpdateMatchRequest(val match: UpdateMatchBody)
|
||||
@@ -196,6 +198,18 @@ data class ScoringRulesBody(
|
||||
@Json(name = "points_per_set") val pointsPerSet: Int,
|
||||
@Json(name = "points_deciding_set") val pointsDecidingSet: Int,
|
||||
@Json(name = "min_point_lead") val minPointLead: Int,
|
||||
) {
|
||||
fun toDomain() = ScoringRules(
|
||||
pointsPerSet = pointsPerSet,
|
||||
pointsDecidingSet = pointsDecidingSet,
|
||||
minPointLead = minPointLead,
|
||||
)
|
||||
}
|
||||
|
||||
fun ScoringRules.toBody() = ScoringRulesBody(
|
||||
pointsPerSet = pointsPerSet,
|
||||
pointsDecidingSet = pointsDecidingSet,
|
||||
minPointLead = minPointLead,
|
||||
)
|
||||
|
||||
data class UpdateMatchBodyFull(
|
||||
@@ -204,8 +218,6 @@ data class UpdateMatchBodyFull(
|
||||
@Json(name = "scheduled_at") val scheduledAt: String? = null,
|
||||
@Json(name = "sets_to_win") val setsToWin: Int,
|
||||
val category: String? = null,
|
||||
val phase: String? = null,
|
||||
@Json(name = "roster_numbers") val rosterNumbers: List<Int>? = null,
|
||||
@Json(name = "scoring_rules") val scoringRules: ScoringRulesBody? = null,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.matchlivetv.match_live_tv.data.repository
|
||||
|
||||
import com.matchlivetv.match_live_tv.core.TokenStore
|
||||
import com.matchlivetv.match_live_tv.core.parseApiInstant
|
||||
import com.matchlivetv.match_live_tv.data.api.CreateMatchBody
|
||||
import com.matchlivetv.match_live_tv.data.api.CreateMatchRequest
|
||||
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
|
||||
@@ -10,6 +11,7 @@ import com.matchlivetv.match_live_tv.data.api.UpdateMatchRequestFull
|
||||
import com.matchlivetv.match_live_tv.domain.Match
|
||||
import com.matchlivetv.match_live_tv.domain.Recording
|
||||
import com.matchlivetv.match_live_tv.domain.Team
|
||||
import com.matchlivetv.match_live_tv.domain.coachHubVisible
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.time.Instant
|
||||
|
||||
@@ -37,6 +39,7 @@ class MatchRepository(
|
||||
suspend fun fetchMatchesForTeam(teamId: String): List<Match> =
|
||||
api.matches(teamId)
|
||||
.map { it.toDomain() }
|
||||
.coachHubVisible()
|
||||
.sortedWith(matchComparator)
|
||||
|
||||
suspend fun fetchMatches(): List<Match> {
|
||||
@@ -81,8 +84,6 @@ class MatchRepository(
|
||||
scheduledAt: String?,
|
||||
setsToWin: Int,
|
||||
category: String?,
|
||||
phase: String?,
|
||||
rosterNumbers: List<Int>,
|
||||
scoringRules: ScoringRulesBody?,
|
||||
): Match = api.updateMatch(
|
||||
matchId,
|
||||
@@ -93,8 +94,6 @@ class MatchRepository(
|
||||
scheduledAt = scheduledAt,
|
||||
setsToWin = setsToWin,
|
||||
category = category?.takeIf { it.isNotBlank() },
|
||||
phase = phase?.takeIf { it.isNotBlank() },
|
||||
rosterNumbers = rosterNumbers.takeIf { it.isNotEmpty() },
|
||||
scoringRules = scoringRules,
|
||||
),
|
||||
),
|
||||
@@ -111,7 +110,7 @@ class MatchRepository(
|
||||
|
||||
companion object {
|
||||
private val matchComparator = compareBy<Match, Instant?>(nullsLast()) { match ->
|
||||
match.scheduledAt?.let { runCatching { Instant.parse(it) }.getOrNull() }
|
||||
parseApiInstant(match.scheduledAt)
|
||||
}.thenByDescending { it.id }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.matchlivetv.match_live_tv.domain
|
||||
|
||||
import com.matchlivetv.match_live_tv.core.isScheduledFuture
|
||||
import com.matchlivetv.match_live_tv.core.parseApiInstant
|
||||
import java.time.Instant
|
||||
|
||||
/** Partite visibili nell'hub coach: dirette attive, wizard in corso, programmate future. */
|
||||
fun Match.isCoachHubVisible(now: Instant = Instant.now()): Boolean {
|
||||
coachHubVisible?.let { return it }
|
||||
|
||||
if (hasActiveSession) {
|
||||
val status = activeSessionStatus.orEmpty()
|
||||
if (status in RESUMABLE_STATUSES || status == "idle") return true
|
||||
}
|
||||
if (streamCompleted) return false
|
||||
|
||||
val scheduled = parseApiInstant(scheduledAt)
|
||||
return scheduled != null && scheduled.isScheduledFuture(now)
|
||||
}
|
||||
|
||||
fun List<Match>.coachHubVisible(now: Instant = Instant.now()): List<Match> =
|
||||
filter { it.isCoachHubVisible(now) }
|
||||
|
||||
private val RESUMABLE_STATUSES = setOf("connecting", "live", "reconnecting", "paused")
|
||||
@@ -33,6 +33,12 @@ data class Team(
|
||||
val isYoutubeReady: Boolean get() = youtubeSelectable
|
||||
}
|
||||
|
||||
data class ScoringRules(
|
||||
val pointsPerSet: Int = 25,
|
||||
val pointsDecidingSet: Int = 15,
|
||||
val minPointLead: Int = 2,
|
||||
)
|
||||
|
||||
data class Match(
|
||||
val id: String,
|
||||
val teamId: String,
|
||||
@@ -41,11 +47,13 @@ data class Match(
|
||||
val location: String? = null,
|
||||
val scheduledAt: String? = null,
|
||||
val setsToWin: Int = 3,
|
||||
/** Campionato / descrizione torneo (facoltativo). */
|
||||
val category: String? = null,
|
||||
val phase: String? = null,
|
||||
val rosterNumbers: List<Int> = emptyList(),
|
||||
val scoringRules: ScoringRules? = null,
|
||||
val activeSessionId: String? = null,
|
||||
val activeSessionStatus: String? = null,
|
||||
val streamCompleted: Boolean = false,
|
||||
val coachHubVisible: Boolean? = null,
|
||||
) {
|
||||
val hasActiveSession: Boolean get() = activeSessionId != null
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.matchlivetv.match_live_tv.core.isScheduledFuture
|
||||
import com.matchlivetv.match_live_tv.core.parseApiInstant
|
||||
import com.matchlivetv.match_live_tv.domain.Match
|
||||
import com.matchlivetv.match_live_tv.domain.Team
|
||||
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
|
||||
@@ -217,7 +219,7 @@ fun SelectMatchBottomSheet(
|
||||
val now = Instant.now()
|
||||
val scheduled = selectable
|
||||
.filter { it.scheduledAt != null }
|
||||
.sortedBy { runCatching { Instant.parse(it.scheduledAt!!) }.getOrNull() }
|
||||
.sortedBy { parseApiInstant(it.scheduledAt) }
|
||||
val unscheduled = selectable.filter { it.scheduledAt == null }
|
||||
|
||||
ModalBottomSheet(
|
||||
@@ -429,7 +431,7 @@ private fun SheetOptionTile(
|
||||
|
||||
@Composable
|
||||
private fun SelectMatchTile(match: Match, now: Instant, onClick: () -> Unit) {
|
||||
val scheduledInstant = match.scheduledAt?.let { runCatching { Instant.parse(it) }.getOrNull() }
|
||||
val scheduledInstant = parseApiInstant(match.scheduledAt)
|
||||
val isFuture = scheduledInstant?.isAfter(now) == true
|
||||
Row(
|
||||
Modifier
|
||||
@@ -548,8 +550,8 @@ fun MatchOutlinedField(
|
||||
fun matchStatusLabel(match: Match): String {
|
||||
if (match.canResumeCamera) return "RIPRENDI"
|
||||
if (match.hasActiveSession) return "IN CORSO"
|
||||
val at = match.scheduledAt?.let { runCatching { Instant.parse(it) }.getOrNull() }
|
||||
if (at != null && at.isAfter(Instant.now())) return "PROGRAMMATA"
|
||||
val at = parseApiInstant(match.scheduledAt)
|
||||
if (at != null && at.isScheduledFuture()) return "PROGRAMMATA"
|
||||
return "AVVIA"
|
||||
}
|
||||
|
||||
@@ -557,4 +559,4 @@ fun formatMatchDate(instant: Instant): String =
|
||||
sheetDateFormat.format(instant.atZone(ZoneId.systemDefault()))
|
||||
|
||||
fun formatMatchDate(iso: String?): String? =
|
||||
iso?.let { runCatching { formatMatchDate(Instant.parse(it)) }.getOrNull() }
|
||||
parseApiInstant(iso)?.let { formatMatchDate(it) }
|
||||
|
||||
@@ -28,6 +28,8 @@ import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -41,6 +43,8 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.matchlivetv.match_live_tv.core.isScheduledFuture
|
||||
import com.matchlivetv.match_live_tv.core.parseApiInstant
|
||||
import com.matchlivetv.match_live_tv.data.AppContainer
|
||||
import com.matchlivetv.match_live_tv.data.repository.MatchSessionLauncher
|
||||
import com.matchlivetv.match_live_tv.domain.Match
|
||||
@@ -66,6 +70,7 @@ fun MatchesScreen(
|
||||
val session by container.authRepository.sessionFlow.collectAsState(initial = null)
|
||||
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var refreshing by remember { mutableStateOf(false) }
|
||||
var actionLoading by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var teams by remember { mutableStateOf<List<Team>>(emptyList()) }
|
||||
@@ -74,7 +79,6 @@ fun MatchesScreen(
|
||||
|
||||
var showNewMatchSheet by remember { mutableStateOf(false) }
|
||||
var showScheduleSheet by remember { mutableStateOf(false) }
|
||||
var showSelectMatchSheet by remember { mutableStateOf(false) }
|
||||
var showTeamSheet by remember { mutableStateOf(false) }
|
||||
var resumeMatch by remember { mutableStateOf<Match?>(null) }
|
||||
var configureMatch by remember { mutableStateOf<Match?>(null) }
|
||||
@@ -84,8 +88,12 @@ fun MatchesScreen(
|
||||
snackbarHostState.showSnackbar(message)
|
||||
}
|
||||
|
||||
fun reload() {
|
||||
loading = true
|
||||
fun reload(showInitialSpinner: Boolean = false) {
|
||||
if (showInitialSpinner) {
|
||||
loading = true
|
||||
} else {
|
||||
refreshing = true
|
||||
}
|
||||
error = null
|
||||
scope.launch {
|
||||
runCatching {
|
||||
@@ -98,6 +106,7 @@ fun MatchesScreen(
|
||||
error = it.message ?: "Errore caricamento"
|
||||
}
|
||||
loading = false
|
||||
refreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,9 +137,16 @@ fun MatchesScreen(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { reload() }
|
||||
LaunchedEffect(Unit) { reload(showInitialSpinner = true) }
|
||||
|
||||
val activeMatch = matches.firstOrNull { it.hasActiveSession }
|
||||
val pullRefreshState = rememberPullToRefreshState()
|
||||
|
||||
val activeMatch = matches.firstOrNull { it.canResumeCamera || it.hasActiveSession }
|
||||
val scheduledMatches = matches.filter { match ->
|
||||
!match.hasActiveSession && parseApiInstant(match.scheduledAt)?.isScheduledFuture() == true
|
||||
}
|
||||
val draftMatches = matches.filter { !it.hasActiveSession && it.scheduledAt == null }
|
||||
val calendarMatches = (scheduledMatches + draftMatches).distinctBy { it.id }
|
||||
|
||||
MatchScreenScaffold(
|
||||
topBar = {
|
||||
@@ -157,25 +173,31 @@ fun MatchesScreen(
|
||||
},
|
||||
) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
when {
|
||||
loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = MatchColors.PrimaryRed)
|
||||
}
|
||||
teams.isEmpty() -> NoTeamContent(onRetry = { reload() })
|
||||
error != null -> Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(error!!, color = MatchColors.PrimaryRed, textAlign = TextAlign.Center)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
MatchPrimaryButton(label = "RIPROVA", onClick = { reload() })
|
||||
}
|
||||
else -> LazyColumn(
|
||||
contentPadding = PaddingValues(bottom = 24.dp),
|
||||
) {
|
||||
PullToRefreshBox(
|
||||
isRefreshing = refreshing,
|
||||
onRefresh = { reload(showInitialSpinner = false) },
|
||||
state = pullRefreshState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
when {
|
||||
loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = MatchColors.PrimaryRed)
|
||||
}
|
||||
teams.isEmpty() -> NoTeamContent(onRetry = { reload(showInitialSpinner = false) })
|
||||
error != null -> Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(error!!, color = MatchColors.PrimaryRed, textAlign = TextAlign.Center)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
MatchPrimaryButton(label = "RIPROVA", onClick = { reload(showInitialSpinner = false) })
|
||||
}
|
||||
else -> LazyColumn(
|
||||
contentPadding = PaddingValues(bottom = 24.dp),
|
||||
) {
|
||||
item {
|
||||
Column(Modifier.padding(horizontal = 20.dp, vertical = 8.dp)) {
|
||||
Text(
|
||||
@@ -184,23 +206,14 @@ fun MatchesScreen(
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"Scegli una partita programmata o creane una nuova.",
|
||||
"Riprendi una diretta in corso o avvia una partita programmata.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
MatchSecondaryButton(
|
||||
label = "PARTITA PROGRAMMATA",
|
||||
onClick = {
|
||||
val selectable = matches.filter { !it.hasActiveSession }
|
||||
if (selectable.isEmpty()) {
|
||||
scope.launch {
|
||||
showMessage("Nessuna partita disponibile. Crea una nuova partita.")
|
||||
}
|
||||
} else {
|
||||
showSelectMatchSheet = true
|
||||
}
|
||||
},
|
||||
onClick = { showScheduleSheet = true },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
MatchPrimaryButton(
|
||||
@@ -213,8 +226,8 @@ fun MatchesScreen(
|
||||
activeTeam?.let { team ->
|
||||
TeamPickerBar(
|
||||
team = team,
|
||||
showPicker = teams.size > 1,
|
||||
onClick = { showTeamSheet = true },
|
||||
showPicker = true,
|
||||
onClick = { if (teams.size > 1) showTeamSheet = true },
|
||||
)
|
||||
}
|
||||
activeMatch?.let { match ->
|
||||
@@ -225,33 +238,59 @@ fun MatchesScreen(
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
if (matches.isEmpty()) "Calendario vuoto" else "Calendario",
|
||||
when {
|
||||
calendarMatches.isEmpty() && activeMatch == null -> "Nessuna partita in calendario"
|
||||
scheduledMatches.isNotEmpty() -> "Partite programmate"
|
||||
else -> "Pronte da avviare"
|
||||
},
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MatchColors.TextSecondary,
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
if (matches.isEmpty()) {
|
||||
if (calendarMatches.isEmpty() && activeMatch == null) {
|
||||
item {
|
||||
Text(
|
||||
"Nessuna partita ancora. Usa «Nuova partita» per programmare o avviare subito.",
|
||||
buildString {
|
||||
append("Programma una partita o avviane una nuova con «Nuova partita».")
|
||||
activeTeam?.name?.let { teamName ->
|
||||
append("\n\nSquadra attiva: ")
|
||||
append(teamName)
|
||||
append('.')
|
||||
}
|
||||
if (teams.size > 1) {
|
||||
append("\nHai più squadre: verifica quella selezionata sopra.")
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
} else if (calendarMatches.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
"Nessuna altra partita in calendario.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(matches, key = { it.id }) { match ->
|
||||
items(calendarMatches, key = { it.id }) { match ->
|
||||
MatchListCard(
|
||||
match = match,
|
||||
onClick = { openMatch(match) },
|
||||
onDelete = if (match.canResumeCamera) null else {
|
||||
onDelete = if (!match.hasActiveSession && !match.streamCompleted) {
|
||||
{ deleteMatch = match }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (actionLoading) {
|
||||
@@ -328,17 +367,6 @@ fun MatchesScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (showSelectMatchSheet) {
|
||||
SelectMatchBottomSheet(
|
||||
matches = matches,
|
||||
onDismiss = { showSelectMatchSheet = false },
|
||||
onSelect = { match ->
|
||||
showSelectMatchSheet = false
|
||||
openMatch(match)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (showTeamSheet) {
|
||||
TeamPickerBottomSheet(
|
||||
teams = teams,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package com.matchlivetv.match_live_tv.ui.wizard
|
||||
|
||||
import android.app.DatePickerDialog
|
||||
import android.app.TimePickerDialog
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -11,9 +9,6 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CalendarToday
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
@@ -26,7 +21,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.matchlivetv.match_live_tv.data.AppContainer
|
||||
import com.matchlivetv.match_live_tv.data.api.ScoringRulesBody
|
||||
@@ -37,9 +31,11 @@ import com.matchlivetv.match_live_tv.ui.matches.MatchOutlinedField
|
||||
import com.matchlivetv.match_live_tv.ui.matches.formatMatchDate
|
||||
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
|
||||
private const val DEFAULT_SETS_TO_WIN = 3
|
||||
private const val DEFAULT_POINTS_PER_SET = 25
|
||||
private const val DEFAULT_POINTS_DECIDING_SET = 15
|
||||
private const val DEFAULT_MIN_POINT_LEAD = 2
|
||||
|
||||
@Composable
|
||||
fun StepMatchScreen(
|
||||
@@ -48,45 +44,27 @@ fun StepMatchScreen(
|
||||
onNext: () -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val isScheduledMatch = match.scheduledAt != null
|
||||
|
||||
var opponent by remember { mutableStateOf(match.opponentName) }
|
||||
var location by remember { mutableStateOf(match.location.orEmpty()) }
|
||||
var category by remember { mutableStateOf(match.category.orEmpty()) }
|
||||
var phase by remember { mutableStateOf(match.phase.orEmpty()) }
|
||||
var roster by remember { mutableStateOf(match.rosterNumbers.joinToString(", ")) }
|
||||
var setsToWin by remember { mutableIntStateOf(match.setsToWin) }
|
||||
var customRules by remember { mutableStateOf(false) }
|
||||
var pointsPerSet by remember { mutableIntStateOf(25) }
|
||||
var pointsDecidingSet by remember { mutableIntStateOf(15) }
|
||||
var minPointLead by remember { mutableIntStateOf(2) }
|
||||
var scheduledAt by remember {
|
||||
mutableStateOf(match.scheduledAt?.let { runCatching { Instant.parse(it) }.getOrNull() })
|
||||
}
|
||||
var saving by remember { mutableStateOf(false) }
|
||||
var campionato by remember { mutableStateOf(match.category.orEmpty()) }
|
||||
|
||||
fun pickDateTime() {
|
||||
val initial = scheduledAt?.atZone(ZoneId.systemDefault())?.toLocalDateTime()
|
||||
?: LocalDateTime.now().plusHours(2)
|
||||
DatePickerDialog(
|
||||
context,
|
||||
{ _, year, month, day ->
|
||||
TimePickerDialog(
|
||||
context,
|
||||
{ _, hour, minute ->
|
||||
scheduledAt = LocalDateTime.of(year, month + 1, day, hour, minute)
|
||||
.atZone(ZoneId.systemDefault()).toInstant()
|
||||
},
|
||||
initial.hour,
|
||||
initial.minute,
|
||||
true,
|
||||
).show()
|
||||
},
|
||||
initial.year,
|
||||
initial.monthValue - 1,
|
||||
initial.dayOfMonth,
|
||||
).show()
|
||||
val existingRules = match.scoringRules
|
||||
var customRules by remember {
|
||||
mutableStateOf(existingRules != null || match.setsToWin != DEFAULT_SETS_TO_WIN)
|
||||
}
|
||||
var setsToWin by remember { mutableIntStateOf(match.setsToWin.coerceIn(2, 3)) }
|
||||
var pointsPerSet by remember {
|
||||
mutableIntStateOf(existingRules?.pointsPerSet ?: DEFAULT_POINTS_PER_SET)
|
||||
}
|
||||
var pointsDecidingSet by remember {
|
||||
mutableIntStateOf(existingRules?.pointsDecidingSet ?: DEFAULT_POINTS_DECIDING_SET)
|
||||
}
|
||||
var pointsPerSetText by remember { mutableStateOf(pointsPerSet.toString()) }
|
||||
var pointsDecidingSetText by remember { mutableStateOf(pointsDecidingSet.toString()) }
|
||||
var saving by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
@@ -95,44 +73,34 @@ fun StepMatchScreen(
|
||||
) {
|
||||
Text("Dettagli partita", style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
WizardReadOnlyField(label = "Squadra casa", value = match.teamName)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
MatchOutlinedField(value = opponent, onValueChange = { opponent = it }, label = "Avversario")
|
||||
Spacer(Modifier.height(12.dp))
|
||||
MatchOutlinedField(value = location, onValueChange = { location = it }, label = "Luogo")
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column {
|
||||
Text("Orario", style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
scheduledAt?.let { formatMatchDate(it) } ?: "Seleziona data e ora",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
Icon(Icons.Default.CalendarToday, null, tint = MatchColors.PrimaryRed, modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.then(Modifier))
|
||||
MatchOutlinedField(
|
||||
value = campionato,
|
||||
onValueChange = { campionato = it },
|
||||
label = "Campionato (facoltativo)",
|
||||
)
|
||||
Text(
|
||||
"Es. Serie C, torneo estivo — lo useremo in descrizione e overlay.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MatchColors.TextSecondary,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
|
||||
if (isScheduledMatch) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
WizardReadOnlyField(
|
||||
label = "Programmata per",
|
||||
value = formatMatchDate(match.scheduledAt) ?: "—",
|
||||
)
|
||||
}
|
||||
MatchSecondaryButton(label = "SCEGLI DATA E ORA", onClick = { pickDateTime() })
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text("Set da vincere", style = MaterialTheme.typography.bodyMedium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
listOf(2, 3).forEach { value ->
|
||||
MatchSecondaryButton(
|
||||
label = value.toString(),
|
||||
onClick = { setsToWin = value },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
@@ -141,22 +109,60 @@ fun StepMatchScreen(
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Regole punteggio personalizzate")
|
||||
Text(
|
||||
"Per tornei non standard",
|
||||
if (customRules) {
|
||||
"Torneo non standard: imposta set e punteggi."
|
||||
} else {
|
||||
"Standard FIPAV: 3 set per vincere, set a 25, tie-break a 15."
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MatchColors.TextSecondary,
|
||||
)
|
||||
}
|
||||
Switch(checked = customRules, onCheckedChange = { customRules = it })
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
MatchOutlinedField(value = category, onValueChange = { category = it }, label = "Categoria")
|
||||
Spacer(Modifier.height(12.dp))
|
||||
MatchOutlinedField(value = phase, onValueChange = { phase = it }, label = "Fase (es. semifinale)")
|
||||
Spacer(Modifier.height(12.dp))
|
||||
MatchOutlinedField(
|
||||
value = roster,
|
||||
onValueChange = { roster = it },
|
||||
label = "Convocate (numeri maglia)",
|
||||
)
|
||||
|
||||
if (customRules) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text("Set da vincere la partita", style = MaterialTheme.typography.bodyMedium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
listOf(2, 3).forEach { value ->
|
||||
val selected = setsToWin == value
|
||||
if (selected) {
|
||||
MatchPrimaryButton(
|
||||
label = value.toString(),
|
||||
onClick = { setsToWin = value },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
} else {
|
||||
MatchSecondaryButton(
|
||||
label = value.toString(),
|
||||
onClick = { setsToWin = value },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
MatchOutlinedField(
|
||||
value = pointsPerSetText,
|
||||
onValueChange = { text ->
|
||||
pointsPerSetText = text.filter { it.isDigit() }.take(2)
|
||||
pointsPerSetText.toIntOrNull()?.let { pointsPerSet = it.coerceIn(1, 99) }
|
||||
},
|
||||
label = "Punti per vincere un set",
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
MatchOutlinedField(
|
||||
value = pointsDecidingSetText,
|
||||
onValueChange = { text ->
|
||||
pointsDecidingSetText = text.filter { it.isDigit() }.take(2)
|
||||
pointsDecidingSetText.toIntOrNull()?.let { pointsDecidingSet = it.coerceIn(1, 99) }
|
||||
},
|
||||
label = "Punti tie-break (ultimo set)",
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
MatchPrimaryButton(
|
||||
label = "AVANTI >",
|
||||
@@ -166,6 +172,36 @@ fun StepMatchScreen(
|
||||
onError("Inserisci il nome avversario")
|
||||
return@MatchPrimaryButton
|
||||
}
|
||||
if (customRules) {
|
||||
val perSet = pointsPerSetText.toIntOrNull()
|
||||
val deciding = pointsDecidingSetText.toIntOrNull()
|
||||
if (perSet == null || perSet < 1) {
|
||||
onError("Inserisci i punti per vincere un set")
|
||||
return@MatchPrimaryButton
|
||||
}
|
||||
if (deciding == null || deciding < 1) {
|
||||
onError("Inserisci i punti del tie-break")
|
||||
return@MatchPrimaryButton
|
||||
}
|
||||
pointsPerSet = perSet
|
||||
pointsDecidingSet = deciding
|
||||
}
|
||||
|
||||
val resolvedSets = if (customRules) setsToWin else DEFAULT_SETS_TO_WIN
|
||||
val resolvedRules: ScoringRulesBody? = if (customRules) {
|
||||
ScoringRulesBody(
|
||||
pointsPerSet = pointsPerSet,
|
||||
pointsDecidingSet = pointsDecidingSet,
|
||||
minPointLead = existingRules?.minPointLead ?: DEFAULT_MIN_POINT_LEAD,
|
||||
)
|
||||
} else {
|
||||
ScoringRulesBody(
|
||||
pointsPerSet = DEFAULT_POINTS_PER_SET,
|
||||
pointsDecidingSet = DEFAULT_POINTS_DECIDING_SET,
|
||||
minPointLead = DEFAULT_MIN_POINT_LEAD,
|
||||
)
|
||||
}
|
||||
|
||||
saving = true
|
||||
scope.launch {
|
||||
runCatching {
|
||||
@@ -173,17 +209,10 @@ fun StepMatchScreen(
|
||||
matchId = match.id,
|
||||
opponentName = opponent.trim(),
|
||||
location = location.trim(),
|
||||
scheduledAt = scheduledAt?.toString(),
|
||||
setsToWin = setsToWin,
|
||||
category = category.trim(),
|
||||
phase = phase.trim(),
|
||||
rosterNumbers = roster.split(',', ' ', ';')
|
||||
.mapNotNull { it.trim().toIntOrNull() },
|
||||
scoringRules = if (customRules) {
|
||||
ScoringRulesBody(pointsPerSet, pointsDecidingSet, minPointLead)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
scheduledAt = match.scheduledAt,
|
||||
setsToWin = resolvedSets,
|
||||
category = campionato.trim(),
|
||||
scoringRules = resolvedRules,
|
||||
)
|
||||
}.onSuccess { updated ->
|
||||
container.wizardSession.match = updated
|
||||
|
||||
Reference in New Issue
Block a user