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

@@ -8,6 +8,7 @@ module Api
matches = @team.matches matches = @team.matches
.includes(:team, :stream_sessions) .includes(:team, :stream_sessions)
.order(Arel.sql("scheduled_at ASC NULLS LAST"), created_at: :desc) .order(Arel.sql("scheduled_at ASC NULLS LAST"), created_at: :desc)
.select(&:coach_hub_visible?)
render json: matches.map { |m| match_json(m) } render json: matches.map { |m| match_json(m) }
end end
@@ -21,7 +22,9 @@ module Api
end end
def update def update
@match.update!(match_params) attrs = match_params.to_h
normalize_scoring_rules!(attrs)
@match.update!(attrs)
render json: match_json(@match) render json: match_json(@match)
end end
@@ -52,11 +55,19 @@ module Api
def match_params def match_params
params.require(:match).permit( params.require(:match).permit(
:opponent_name, :location, :scheduled_at, :sport, :sets_to_win, :opponent_name, :location, :scheduled_at, :sport, :sets_to_win,
:category, :phase, roster_numbers: [], :category,
scoring_rules: %i[points_per_set points_deciding_set min_point_lead] scoring_rules: %i[points_per_set points_deciding_set min_point_lead]
) )
end end
# Regolamento standard: scoring_rules assente o {} → default FIPAV lato Scoring::Rules.
def normalize_scoring_rules!(attrs)
return unless attrs.key?("scoring_rules")
rules = attrs["scoring_rules"]
attrs["scoring_rules"] = {} if rules.blank?
end
def match_json(match, detail: false) def match_json(match, detail: false)
active = active_session_for(match) active = active_session_for(match)
{ {
@@ -69,11 +80,11 @@ module Api
sport: match.sport, sport: match.sport,
sets_to_win: match.sets_to_win, sets_to_win: match.sets_to_win,
scoring_rules: match.scoring_rules.presence, scoring_rules: match.scoring_rules.presence,
roster_numbers: match.roster_numbers,
category: match.category, category: match.category,
phase: match.phase,
active_session_id: active&.id, active_session_id: active&.id,
active_session_status: active&.status active_session_status: active&.status,
stream_completed: match.stream_completed?,
coach_hub_visible: match.coach_hub_visible?
} }
end end

View File

@@ -6,6 +6,9 @@ class Match < ApplicationRecord
validates :opponent_name, presence: true validates :opponent_name, presence: true
validates :sets_to_win, numericality: { greater_than: 0, less_than: 6 } validates :sets_to_win, numericality: { greater_than: 0, less_than: 6 }
validate :scoring_rules_shape, if: -> { scoring_rules.present? }
# category: campionato / descrizione torneo (facoltativo, es. "Serie C").
# Partite ancora da giocare in diretta (orario nel futuro). # Partite ancora da giocare in diretta (orario nel futuro).
scope :scheduled_for_live, -> { scope :scheduled_for_live, -> {
@@ -55,4 +58,37 @@ class Match < ApplicationRecord
def broadcast_active? def broadcast_active?
stream_sessions.where(status: ACTIVE_SESSION_STATUSES).exists? stream_sessions.where(status: ACTIVE_SESSION_STATUSES).exists?
end end
def stream_completed?
stream_sessions.where(status: %w[ended error]).exists?
end
# Hub app coach: dirette da riprendere o partite programmate future.
# Le bozze «Avversario» abbandonate (senza sessione) non compaiono.
def coach_hub_visible?
active = active_stream_session
return true if active&.resumable?
return true if active&.idle?
return false if stream_completed?
scheduled_upcoming?
end
private
def scoring_rules_shape
rules = scoring_rules.is_a?(Hash) ? scoring_rules : {}
%w[points_per_set points_deciding_set min_point_lead].each do |key|
val = rules[key] || rules[key.to_sym]
next if val.blank?
unless val.is_a?(Integer) || val.to_s.match?(/\A\d+\z/)
errors.add(:scoring_rules, "#{key} non valido")
next
end
int_val = val.to_i
errors.add(:scoring_rules, "#{key} deve essere positivo") if int_val < 1
end
end
end end

View File

@@ -0,0 +1,60 @@
namespace :matches do
desc "Pulizia bozze abbandonate e sessioni idle obsolete (hub coach). DRY_RUN=1 per anteprima."
task cleanup_stale: :environment do
dry = ENV["DRY_RUN"] == "1"
cutoff_abandoned = ENV.fetch("ABANDONED_DAYS", "1").to_i.days.ago
cutoff_idle = ENV.fetch("IDLE_HOURS", "48").to_i.hours.ago
abandoned = Match
.where(scheduled_at: nil)
.where.missing(:stream_sessions)
.where("matches.created_at < ?", cutoff_abandoned)
stale_idle_sessions = StreamSession
.where(status: "idle")
.where("stream_sessions.updated_at < ?", cutoff_idle)
puts "DRY RUN — nessuna modifica" if dry
puts "Bozze abbandonate (senza sessione, > #{cutoff_abandoned}): #{abandoned.count}"
abandoned.find_each do |match|
label = "#{match.team.name} vs #{match.opponent_name} (#{match.id})"
if dry
puts " would delete match #{label}"
else
match.destroy!
puts " deleted match #{label}"
end
end
puts "Sessioni idle obsolete (> #{cutoff_idle}): #{stale_idle_sessions.count}"
stale_idle_sessions.find_each do |session|
match = session.match
label = "#{match.team.name} vs #{match.opponent_name} session=#{session.id}"
if dry
puts " would end idle session #{label}"
else
session.end_stream!
puts " ended idle session #{label}"
end
end
hidden_completed = Match.includes(:stream_sessions).select do |m|
m.stream_completed? && m.active_stream_session.nil?
end
puts "Partite concluse (già nascoste dall'hub): #{hidden_completed.size}"
if ENV["DELETE_COMPLETED"] == "1"
hidden_completed.each do |match|
session_ids = match.stream_sessions.pluck(:id)
next if Recording.where(stream_session_id: session_ids).exists?
label = "#{match.team.name} vs #{match.opponent_name} (#{match.id})"
if dry
puts " would delete completed #{label}"
else
match.destroy!
puts " deleted completed #{label}"
end
end
end
end
end

View File

@@ -5,6 +5,7 @@ RSpec.describe Match, "scheduling scopes" do
Club.create!(name: "Sched Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") Club.create!(name: "Sched Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff")
end end
let!(:team) { club.teams.create!(name: "U16", sport: "volleyball") } let!(:team) { club.teams.create!(name: "U16", sport: "volleyball") }
let!(:user) { User.create!(email: "coach@test.com", name: "Coach", password: "password123", role: "coach") }
it "scheduled_for_live esclude partite già passate oggi" do it "scheduled_for_live esclude partite già passate oggi" do
past = team.matches.create!(opponent_name: "Past", scheduled_at: 2.hours.ago, sets_to_win: 3) past = team.matches.create!(opponent_name: "Past", scheduled_at: 2.hours.ago, sets_to_win: 3)
@@ -13,4 +14,75 @@ RSpec.describe Match, "scheduling scopes" do
expect(described_class.scheduled_for_live).to include(future) expect(described_class.scheduled_for_live).to include(future)
expect(described_class.scheduled_for_live).not_to include(past) expect(described_class.scheduled_for_live).not_to include(past)
end end
describe "#coach_hub_visible?" do
it "mostra diretta attiva da riprendere" do
match = team.matches.create!(opponent_name: "Live", sets_to_win: 3)
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "paused")
expect(match.coach_hub_visible?).to be(true)
end
it "mostra wizard in corso (sessione idle)" do
match = team.matches.create!(opponent_name: "Setup", sets_to_win: 3)
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "idle")
expect(match.coach_hub_visible?).to be(true)
end
it "mostra partita programmata futura mai trasmessa" do
match = team.matches.create!(
opponent_name: "Future",
scheduled_at: 2.hours.from_now,
sets_to_win: 3
)
expect(match.coach_hub_visible?).to be(true)
end
it "nasconde bozza abbandonata senza sessione" do
match = team.matches.create!(opponent_name: "Avversario", sets_to_win: 3)
expect(match.coach_hub_visible?).to be(false)
end
it "nasconde partita già trasmessa e terminata" do
match = team.matches.create!(opponent_name: "Done", sets_to_win: 3)
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "ended")
expect(match.coach_hub_visible?).to be(false)
end
it "nasconde partita programmata nel passato mai trasmessa" do
match = team.matches.create!(
opponent_name: "Missed",
scheduled_at: 2.hours.ago,
sets_to_win: 3
)
expect(match.coach_hub_visible?).to be(false)
end
end
describe "scoring_rules" do
it "accetta regole personalizzate valide" do
match = team.matches.new(
opponent_name: "Opp",
sets_to_win: 2,
scoring_rules: { points_per_set: 21, points_deciding_set: 15, min_point_lead: 2 }
)
expect(match).to be_valid
end
it "rifiuta punteggi non positivi" do
match = team.matches.new(
opponent_name: "Opp",
scoring_rules: { points_per_set: 0 }
)
expect(match).not_to be_valid
expect(match.errors[:scoring_rules]).to be_present
end
end
end end

View File

@@ -72,6 +72,8 @@ dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
testImplementation("junit:junit:4.13.2")
implementation("com.github.pedroSG94.RootEncoder:library:2.5.5") implementation("com.github.pedroSG94.RootEncoder:library:2.5.5")
androidTestImplementation("androidx.test.ext:junit:1.2.1") androidTestImplementation("androidx.test.ext:junit:1.2.1")

View File

@@ -15,9 +15,10 @@
-keep interface com.matchlivetv.match_live_tv.data.api.MatchLiveApi { *; } -keep interface com.matchlivetv.match_live_tv.data.api.MatchLiveApi { *; }
-keep class com.matchlivetv.match_live_tv.data.api.** { *; } -keep @kotlin.Metadata class com.matchlivetv.match_live_tv.data.api.** { *; }
-keepclassmembers class com.matchlivetv.match_live_tv.data.api.** { -keepclassmembers class com.matchlivetv.match_live_tv.data.api.** {
<init>(...); <fields>;
} }
-keep class kotlin.Metadata { *; } -keep class kotlin.Metadata { *; }

View File

@@ -3,9 +3,12 @@ package com.matchlivetv.match_live_tv
import android.content.Context import android.content.Context
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 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.data.AppContainer
import com.matchlivetv.match_live_tv.domain.isCoachHubVisible
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
@@ -40,4 +43,20 @@ class ReleaseApiSmokeTest {
val matches = container.matchRepository.fetchMatches() val matches = container.matchRepository.fetchMatches()
assertTrue(matches.isNotEmpty()) 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.AuthTokens
import com.matchlivetv.match_live_tv.domain.Match import com.matchlivetv.match_live_tv.domain.Match
import com.matchlivetv.match_live_tv.domain.Recording 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.StreamSession
import com.matchlivetv.match_live_tv.domain.Team import com.matchlivetv.match_live_tv.domain.Team
import com.matchlivetv.match_live_tv.domain.User import com.matchlivetv.match_live_tv.domain.User
@@ -74,11 +75,13 @@ data class MatchDto(
val location: String? = null, val location: String? = null,
@Json(name = "scheduled_at") val scheduledAt: String? = null, @Json(name = "scheduled_at") val scheduledAt: String? = null,
@Json(name = "sets_to_win") val setsToWin: Int? = null, @Json(name = "sets_to_win") val setsToWin: Int? = null,
/** Campionato / descrizione torneo (facoltativo). */
val category: String? = null, val category: String? = null,
val phase: String? = null, @Json(name = "scoring_rules") val scoringRules: ScoringRulesBody? = null,
@Json(name = "roster_numbers") val rosterNumbers: List<Int>? = null,
@Json(name = "active_session_id") val activeSessionId: String? = null, @Json(name = "active_session_id") val activeSessionId: String? = null,
@Json(name = "active_session_status") val activeSessionStatus: 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( fun toDomain() = Match(
id = id, id = id,
@@ -89,10 +92,11 @@ data class MatchDto(
scheduledAt = scheduledAt, scheduledAt = scheduledAt,
setsToWin = setsToWin ?: 3, setsToWin = setsToWin ?: 3,
category = category, category = category,
phase = phase, scoringRules = scoringRules?.toDomain(),
rosterNumbers = rosterNumbers.orEmpty(),
activeSessionId = activeSessionId, activeSessionId = activeSessionId,
activeSessionStatus = activeSessionStatus, activeSessionStatus = activeSessionStatus,
streamCompleted = streamCompleted ?: false,
coachHubVisible = coachHubVisible,
) )
} }
@@ -186,8 +190,6 @@ data class UpdateMatchBody(
@Json(name = "scheduled_at") val scheduledAt: String? = null, @Json(name = "scheduled_at") val scheduledAt: String? = null,
@Json(name = "sets_to_win") val setsToWin: Int? = null, @Json(name = "sets_to_win") val setsToWin: Int? = null,
val category: String? = null, val category: String? = null,
val phase: String? = null,
@Json(name = "roster_numbers") val rosterNumbers: List<Int>? = null,
) )
data class UpdateMatchRequest(val match: UpdateMatchBody) data class UpdateMatchRequest(val match: UpdateMatchBody)
@@ -196,6 +198,18 @@ data class ScoringRulesBody(
@Json(name = "points_per_set") val pointsPerSet: Int, @Json(name = "points_per_set") val pointsPerSet: Int,
@Json(name = "points_deciding_set") val pointsDecidingSet: Int, @Json(name = "points_deciding_set") val pointsDecidingSet: Int,
@Json(name = "min_point_lead") val minPointLead: 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( data class UpdateMatchBodyFull(
@@ -204,8 +218,6 @@ data class UpdateMatchBodyFull(
@Json(name = "scheduled_at") val scheduledAt: String? = null, @Json(name = "scheduled_at") val scheduledAt: String? = null,
@Json(name = "sets_to_win") val setsToWin: Int, @Json(name = "sets_to_win") val setsToWin: Int,
val category: String? = null, 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 = "scoring_rules") val scoringRules: ScoringRulesBody? = null,
) )

View File

@@ -1,6 +1,7 @@
package com.matchlivetv.match_live_tv.data.repository package com.matchlivetv.match_live_tv.data.repository
import com.matchlivetv.match_live_tv.core.TokenStore 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.CreateMatchBody
import com.matchlivetv.match_live_tv.data.api.CreateMatchRequest import com.matchlivetv.match_live_tv.data.api.CreateMatchRequest
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi 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.Match
import com.matchlivetv.match_live_tv.domain.Recording import com.matchlivetv.match_live_tv.domain.Recording
import com.matchlivetv.match_live_tv.domain.Team import com.matchlivetv.match_live_tv.domain.Team
import com.matchlivetv.match_live_tv.domain.coachHubVisible
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import java.time.Instant import java.time.Instant
@@ -37,6 +39,7 @@ class MatchRepository(
suspend fun fetchMatchesForTeam(teamId: String): List<Match> = suspend fun fetchMatchesForTeam(teamId: String): List<Match> =
api.matches(teamId) api.matches(teamId)
.map { it.toDomain() } .map { it.toDomain() }
.coachHubVisible()
.sortedWith(matchComparator) .sortedWith(matchComparator)
suspend fun fetchMatches(): List<Match> { suspend fun fetchMatches(): List<Match> {
@@ -81,8 +84,6 @@ class MatchRepository(
scheduledAt: String?, scheduledAt: String?,
setsToWin: Int, setsToWin: Int,
category: String?, category: String?,
phase: String?,
rosterNumbers: List<Int>,
scoringRules: ScoringRulesBody?, scoringRules: ScoringRulesBody?,
): Match = api.updateMatch( ): Match = api.updateMatch(
matchId, matchId,
@@ -93,8 +94,6 @@ class MatchRepository(
scheduledAt = scheduledAt, scheduledAt = scheduledAt,
setsToWin = setsToWin, setsToWin = setsToWin,
category = category?.takeIf { it.isNotBlank() }, category = category?.takeIf { it.isNotBlank() },
phase = phase?.takeIf { it.isNotBlank() },
rosterNumbers = rosterNumbers.takeIf { it.isNotEmpty() },
scoringRules = scoringRules, scoringRules = scoringRules,
), ),
), ),
@@ -111,7 +110,7 @@ class MatchRepository(
companion object { companion object {
private val matchComparator = compareBy<Match, Instant?>(nullsLast()) { match -> private val matchComparator = compareBy<Match, Instant?>(nullsLast()) { match ->
match.scheduledAt?.let { runCatching { Instant.parse(it) }.getOrNull() } parseApiInstant(match.scheduledAt)
}.thenByDescending { it.id } }.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 val isYoutubeReady: Boolean get() = youtubeSelectable
} }
data class ScoringRules(
val pointsPerSet: Int = 25,
val pointsDecidingSet: Int = 15,
val minPointLead: Int = 2,
)
data class Match( data class Match(
val id: String, val id: String,
val teamId: String, val teamId: String,
@@ -41,11 +47,13 @@ data class Match(
val location: String? = null, val location: String? = null,
val scheduledAt: String? = null, val scheduledAt: String? = null,
val setsToWin: Int = 3, val setsToWin: Int = 3,
/** Campionato / descrizione torneo (facoltativo). */
val category: String? = null, val category: String? = null,
val phase: String? = null, val scoringRules: ScoringRules? = null,
val rosterNumbers: List<Int> = emptyList(),
val activeSessionId: String? = null, val activeSessionId: String? = null,
val activeSessionStatus: String? = null, val activeSessionStatus: String? = null,
val streamCompleted: Boolean = false,
val coachHubVisible: Boolean? = null,
) { ) {
val hasActiveSession: Boolean get() = activeSessionId != 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.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp 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.Match
import com.matchlivetv.match_live_tv.domain.Team import com.matchlivetv.match_live_tv.domain.Team
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
@@ -217,7 +219,7 @@ fun SelectMatchBottomSheet(
val now = Instant.now() val now = Instant.now()
val scheduled = selectable val scheduled = selectable
.filter { it.scheduledAt != null } .filter { it.scheduledAt != null }
.sortedBy { runCatching { Instant.parse(it.scheduledAt!!) }.getOrNull() } .sortedBy { parseApiInstant(it.scheduledAt) }
val unscheduled = selectable.filter { it.scheduledAt == null } val unscheduled = selectable.filter { it.scheduledAt == null }
ModalBottomSheet( ModalBottomSheet(
@@ -429,7 +431,7 @@ private fun SheetOptionTile(
@Composable @Composable
private fun SelectMatchTile(match: Match, now: Instant, onClick: () -> Unit) { 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 val isFuture = scheduledInstant?.isAfter(now) == true
Row( Row(
Modifier Modifier
@@ -548,8 +550,8 @@ fun MatchOutlinedField(
fun matchStatusLabel(match: Match): String { fun matchStatusLabel(match: Match): String {
if (match.canResumeCamera) return "RIPRENDI" if (match.canResumeCamera) return "RIPRENDI"
if (match.hasActiveSession) return "IN CORSO" if (match.hasActiveSession) return "IN CORSO"
val at = match.scheduledAt?.let { runCatching { Instant.parse(it) }.getOrNull() } val at = parseApiInstant(match.scheduledAt)
if (at != null && at.isAfter(Instant.now())) return "PROGRAMMATA" if (at != null && at.isScheduledFuture()) return "PROGRAMMATA"
return "AVVIA" return "AVVIA"
} }
@@ -557,4 +559,4 @@ fun formatMatchDate(instant: Instant): String =
sheetDateFormat.format(instant.atZone(ZoneId.systemDefault())) sheetDateFormat.format(instant.atZone(ZoneId.systemDefault()))
fun formatMatchDate(iso: String?): String? = 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.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -41,6 +43,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp 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.AppContainer
import com.matchlivetv.match_live_tv.data.repository.MatchSessionLauncher import com.matchlivetv.match_live_tv.data.repository.MatchSessionLauncher
import com.matchlivetv.match_live_tv.domain.Match import com.matchlivetv.match_live_tv.domain.Match
@@ -66,6 +70,7 @@ fun MatchesScreen(
val session by container.authRepository.sessionFlow.collectAsState(initial = null) val session by container.authRepository.sessionFlow.collectAsState(initial = null)
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
var refreshing by remember { mutableStateOf(false) }
var actionLoading by remember { mutableStateOf(false) } var actionLoading by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var teams by remember { mutableStateOf<List<Team>>(emptyList()) } var teams by remember { mutableStateOf<List<Team>>(emptyList()) }
@@ -74,7 +79,6 @@ fun MatchesScreen(
var showNewMatchSheet by remember { mutableStateOf(false) } var showNewMatchSheet by remember { mutableStateOf(false) }
var showScheduleSheet by remember { mutableStateOf(false) } var showScheduleSheet by remember { mutableStateOf(false) }
var showSelectMatchSheet by remember { mutableStateOf(false) }
var showTeamSheet by remember { mutableStateOf(false) } var showTeamSheet by remember { mutableStateOf(false) }
var resumeMatch by remember { mutableStateOf<Match?>(null) } var resumeMatch by remember { mutableStateOf<Match?>(null) }
var configureMatch by remember { mutableStateOf<Match?>(null) } var configureMatch by remember { mutableStateOf<Match?>(null) }
@@ -84,8 +88,12 @@ fun MatchesScreen(
snackbarHostState.showSnackbar(message) snackbarHostState.showSnackbar(message)
} }
fun reload() { fun reload(showInitialSpinner: Boolean = false) {
if (showInitialSpinner) {
loading = true loading = true
} else {
refreshing = true
}
error = null error = null
scope.launch { scope.launch {
runCatching { runCatching {
@@ -98,6 +106,7 @@ fun MatchesScreen(
error = it.message ?: "Errore caricamento" error = it.message ?: "Errore caricamento"
} }
loading = false 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( MatchScreenScaffold(
topBar = { topBar = {
@@ -157,11 +173,17 @@ fun MatchesScreen(
}, },
) { ) {
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
PullToRefreshBox(
isRefreshing = refreshing,
onRefresh = { reload(showInitialSpinner = false) },
state = pullRefreshState,
modifier = Modifier.fillMaxSize(),
) {
when { when {
loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MatchColors.PrimaryRed) CircularProgressIndicator(color = MatchColors.PrimaryRed)
} }
teams.isEmpty() -> NoTeamContent(onRetry = { reload() }) teams.isEmpty() -> NoTeamContent(onRetry = { reload(showInitialSpinner = false) })
error != null -> Column( error != null -> Column(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
@@ -171,7 +193,7 @@ fun MatchesScreen(
) { ) {
Text(error!!, color = MatchColors.PrimaryRed, textAlign = TextAlign.Center) Text(error!!, color = MatchColors.PrimaryRed, textAlign = TextAlign.Center)
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
MatchPrimaryButton(label = "RIPROVA", onClick = { reload() }) MatchPrimaryButton(label = "RIPROVA", onClick = { reload(showInitialSpinner = false) })
} }
else -> LazyColumn( else -> LazyColumn(
contentPadding = PaddingValues(bottom = 24.dp), contentPadding = PaddingValues(bottom = 24.dp),
@@ -184,23 +206,14 @@ fun MatchesScreen(
) )
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Text( Text(
"Scegli una partita programmata o creane una nuova.", "Riprendi una diretta in corso o avvia una partita programmata.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
MatchSecondaryButton( MatchSecondaryButton(
label = "PARTITA PROGRAMMATA", label = "PARTITA PROGRAMMATA",
onClick = { onClick = { showScheduleSheet = true },
val selectable = matches.filter { !it.hasActiveSession }
if (selectable.isEmpty()) {
scope.launch {
showMessage("Nessuna partita disponibile. Crea una nuova partita.")
}
} else {
showSelectMatchSheet = true
}
},
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
MatchPrimaryButton( MatchPrimaryButton(
@@ -213,8 +226,8 @@ fun MatchesScreen(
activeTeam?.let { team -> activeTeam?.let { team ->
TeamPickerBar( TeamPickerBar(
team = team, team = team,
showPicker = teams.size > 1, showPicker = true,
onClick = { showTeamSheet = true }, onClick = { if (teams.size > 1) showTeamSheet = true },
) )
} }
activeMatch?.let { match -> activeMatch?.let { match ->
@@ -225,34 +238,60 @@ fun MatchesScreen(
} }
item { item {
Text( 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, style = MaterialTheme.typography.labelLarge,
color = MatchColors.TextSecondary, color = MatchColors.TextSecondary,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
) )
} }
if (matches.isEmpty()) { if (calendarMatches.isEmpty() && activeMatch == null) {
item { item {
Text( 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, style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp), modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp),
) )
} }
} else { } else {
items(matches, key = { it.id }) { match -> items(calendarMatches, key = { it.id }) { match ->
MatchListCard( MatchListCard(
match = match, match = match,
onClick = { openMatch(match) }, onClick = { openMatch(match) },
onDelete = if (match.canResumeCamera) null else { onDelete = if (!match.hasActiveSession && !match.streamCompleted) {
{ deleteMatch = match } { deleteMatch = match }
} else {
null
}, },
) )
} }
} }
} }
} }
}
if (actionLoading) { if (actionLoading) {
Box( Box(
@@ -328,17 +367,6 @@ fun MatchesScreen(
) )
} }
if (showSelectMatchSheet) {
SelectMatchBottomSheet(
matches = matches,
onDismiss = { showSelectMatchSheet = false },
onSelect = { match ->
showSelectMatchSheet = false
openMatch(match)
},
)
}
if (showTeamSheet) { if (showTeamSheet) {
TeamPickerBottomSheet( TeamPickerBottomSheet(
teams = teams, teams = teams,

View File

@@ -1,7 +1,5 @@
package com.matchlivetv.match_live_tv.ui.wizard 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll 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.MaterialTheme
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -26,7 +21,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.matchlivetv.match_live_tv.data.AppContainer import com.matchlivetv.match_live_tv.data.AppContainer
import com.matchlivetv.match_live_tv.data.api.ScoringRulesBody 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.matches.formatMatchDate
import com.matchlivetv.match_live_tv.ui.theme.MatchColors import com.matchlivetv.match_live_tv.ui.theme.MatchColors
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.Instant
import java.time.LocalDateTime private const val DEFAULT_SETS_TO_WIN = 3
import java.time.ZoneId 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 @Composable
fun StepMatchScreen( fun StepMatchScreen(
@@ -48,45 +44,27 @@ fun StepMatchScreen(
onNext: () -> Unit, onNext: () -> Unit,
onError: (String) -> Unit, onError: (String) -> Unit,
) { ) {
val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val isScheduledMatch = match.scheduledAt != null
var opponent by remember { mutableStateOf(match.opponentName) } var opponent by remember { mutableStateOf(match.opponentName) }
var location by remember { mutableStateOf(match.location.orEmpty()) } var location by remember { mutableStateOf(match.location.orEmpty()) }
var category by remember { mutableStateOf(match.category.orEmpty()) } var campionato 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) }
fun pickDateTime() { val existingRules = match.scoringRules
val initial = scheduledAt?.atZone(ZoneId.systemDefault())?.toLocalDateTime() var customRules by remember {
?: LocalDateTime.now().plusHours(2) mutableStateOf(existingRules != null || match.setsToWin != DEFAULT_SETS_TO_WIN)
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()
} }
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( Column(
Modifier Modifier
@@ -95,44 +73,34 @@ fun StepMatchScreen(
) { ) {
Text("Dettagli partita", style = MaterialTheme.typography.headlineMedium) Text("Dettagli partita", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
WizardReadOnlyField(label = "Squadra casa", value = match.teamName) WizardReadOnlyField(label = "Squadra casa", value = match.teamName)
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
MatchOutlinedField(value = opponent, onValueChange = { opponent = it }, label = "Avversario") MatchOutlinedField(value = opponent, onValueChange = { opponent = it }, label = "Avversario")
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
MatchOutlinedField(value = location, onValueChange = { location = it }, label = "Luogo") MatchOutlinedField(value = location, onValueChange = { location = it }, label = "Luogo")
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Row( MatchOutlinedField(
Modifier value = campionato,
.fillMaxWidth() onValueChange = { campionato = it },
.padding(vertical = 8.dp), label = "Campionato (facoltativo)",
horizontalArrangement = Arrangement.SpaceBetween, )
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text("Orario", style = MaterialTheme.typography.bodyMedium)
Text( Text(
scheduledAt?.let { formatMatchDate(it) } ?: "Seleziona data e ora", "Es. Serie C, torneo estivo — lo useremo in descrizione e overlay.",
style = MaterialTheme.typography.titleMedium, 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) ?: "",
) )
} }
Icon(Icons.Default.CalendarToday, null, tint = MatchColors.PrimaryRed, modifier = Modifier
.padding(8.dp) Spacer(Modifier.height(20.dp))
.then(Modifier))
}
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))
Row( Row(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
@@ -141,22 +109,60 @@ fun StepMatchScreen(
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text("Regole punteggio personalizzate") Text("Regole punteggio personalizzate")
Text( 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, style = MaterialTheme.typography.bodyMedium,
color = MatchColors.TextSecondary,
) )
} }
Switch(checked = customRules, onCheckedChange = { customRules = it }) Switch(checked = customRules, onCheckedChange = { customRules = it })
} }
Spacer(Modifier.height(12.dp))
MatchOutlinedField(value = category, onValueChange = { category = it }, label = "Categoria") if (customRules) {
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(16.dp))
MatchOutlinedField(value = phase, onValueChange = { phase = it }, label = "Fase (es. semifinale)") 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)) Spacer(Modifier.height(12.dp))
MatchOutlinedField( MatchOutlinedField(
value = roster, value = pointsPerSetText,
onValueChange = { roster = it }, onValueChange = { text ->
label = "Convocate (numeri maglia)", 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)) Spacer(Modifier.height(32.dp))
MatchPrimaryButton( MatchPrimaryButton(
label = "AVANTI >", label = "AVANTI >",
@@ -166,6 +172,36 @@ fun StepMatchScreen(
onError("Inserisci il nome avversario") onError("Inserisci il nome avversario")
return@MatchPrimaryButton 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 saving = true
scope.launch { scope.launch {
runCatching { runCatching {
@@ -173,17 +209,10 @@ fun StepMatchScreen(
matchId = match.id, matchId = match.id,
opponentName = opponent.trim(), opponentName = opponent.trim(),
location = location.trim(), location = location.trim(),
scheduledAt = scheduledAt?.toString(), scheduledAt = match.scheduledAt,
setsToWin = setsToWin, setsToWin = resolvedSets,
category = category.trim(), category = campionato.trim(),
phase = phase.trim(), scoringRules = resolvedRules,
rosterNumbers = roster.split(',', ' ', ';')
.mapNotNull { it.trim().toIntOrNull() },
scoringRules = if (customRules) {
ScoringRulesBody(pointsPerSet, pointsDecidingSet, minPointLead)
} else {
null
},
) )
}.onSuccess { updated -> }.onSuccess { updated ->
container.wizardSession.match = 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"))
}
}