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:
2026-06-07 10:49:30 +02:00
parent f3ff657fc2
commit 08e800120a
16 changed files with 552 additions and 176 deletions

View File

@@ -3,9 +3,12 @@ package com.matchlivetv.match_live_tv
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.matchlivetv.match_live_tv.core.parseApiInstant
import com.matchlivetv.match_live_tv.data.AppContainer
import com.matchlivetv.match_live_tv.domain.isCoachHubVisible
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -40,4 +43,20 @@ class ReleaseApiSmokeTest {
val matches = container.matchRepository.fetchMatches()
assertTrue(matches.isNotEmpty())
}
@Test
fun scheduledMatch_parsesAndIsVisible() = runBlocking {
container.authRepository.login(
email = "coach@matchlivetv.test",
password = "password123",
)
val teams = container.matchRepository.fetchTeams()
val tigers = teams.first { it.name == "Tigers Volley" }
val raw = container.api.matches(tigers.id)
val scheduled = raw.first { it.opponentName.contains("Crazy Volley") }
assertNotNull(scheduled.scheduledAt)
assertNotNull(parseApiInstant(scheduled.scheduledAt))
val domain = scheduled.toDomain()
assertTrue(domain.isCoachHubVisible())
}
}

View File

@@ -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)

View File

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

View File

@@ -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 }
}
}

View File

@@ -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")

View File

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

View File

@@ -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) }

View File

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

View File

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

View File

@@ -0,0 +1,20 @@
package com.matchlivetv.match_live_tv.core
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.Instant
class ApiInstantTest {
@Test
fun parseApiInstant_railsOffsetWithMillis() {
val instant = parseApiInstant("2026-06-06T20:00:00.000+02:00")
assertNotNull(instant)
assertTrue(instant!!.isScheduledFuture(Instant.parse("2026-06-06T16:00:00Z")))
}
@Test
fun parseApiInstant_utcZulu() {
assertNotNull(parseApiInstant("2026-06-06T18:00:00Z"))
}
}