diff --git a/backend/app/controllers/api/v1/matches_controller.rb b/backend/app/controllers/api/v1/matches_controller.rb index 9238aa9..ab9dabc 100644 --- a/backend/app/controllers/api/v1/matches_controller.rb +++ b/backend/app/controllers/api/v1/matches_controller.rb @@ -8,6 +8,7 @@ module Api matches = @team.matches .includes(:team, :stream_sessions) .order(Arel.sql("scheduled_at ASC NULLS LAST"), created_at: :desc) + .select(&:coach_hub_visible?) render json: matches.map { |m| match_json(m) } end @@ -21,7 +22,9 @@ module Api end def update - @match.update!(match_params) + attrs = match_params.to_h + normalize_scoring_rules!(attrs) + @match.update!(attrs) render json: match_json(@match) end @@ -52,11 +55,19 @@ module Api def match_params params.require(:match).permit( :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] ) 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) active = active_session_for(match) { @@ -69,11 +80,11 @@ module Api sport: match.sport, sets_to_win: match.sets_to_win, scoring_rules: match.scoring_rules.presence, - roster_numbers: match.roster_numbers, category: match.category, - phase: match.phase, 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 diff --git a/backend/app/models/match.rb b/backend/app/models/match.rb index c64be5e..4b9dd0f 100644 --- a/backend/app/models/match.rb +++ b/backend/app/models/match.rb @@ -6,6 +6,9 @@ class Match < ApplicationRecord validates :opponent_name, presence: true 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). scope :scheduled_for_live, -> { @@ -55,4 +58,37 @@ class Match < ApplicationRecord def broadcast_active? stream_sessions.where(status: ACTIVE_SESSION_STATUSES).exists? 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 diff --git a/backend/lib/tasks/matches.rake b/backend/lib/tasks/matches.rake new file mode 100644 index 0000000..2223086 --- /dev/null +++ b/backend/lib/tasks/matches.rake @@ -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 diff --git a/backend/spec/models/match_scheduling_spec.rb b/backend/spec/models/match_scheduling_spec.rb index d8597b4..58ae7b3 100644 --- a/backend/spec/models/match_scheduling_spec.rb +++ b/backend/spec/models/match_scheduling_spec.rb @@ -5,6 +5,7 @@ RSpec.describe Match, "scheduling scopes" do Club.create!(name: "Sched Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") end 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 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).not_to include(past) 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 diff --git a/native/android/app/build.gradle.kts b/native/android/app/build.gradle.kts index 0f3f341..25eac58 100644 --- a/native/android/app/build.gradle.kts +++ b/native/android/app/build.gradle.kts @@ -72,6 +72,8 @@ dependencies { 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") androidTestImplementation("androidx.test.ext:junit:1.2.1") diff --git a/native/android/app/proguard-rules.pro b/native/android/app/proguard-rules.pro index fb2d2e0..2ba9ba9 100644 --- a/native/android/app/proguard-rules.pro +++ b/native/android/app/proguard-rules.pro @@ -15,9 +15,10 @@ -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.** { - (...); + ; } -keep class kotlin.Metadata { *; } diff --git a/native/android/app/src/androidTest/kotlin/com/matchlivetv/match_live_tv/ReleaseApiSmokeTest.kt b/native/android/app/src/androidTest/kotlin/com/matchlivetv/match_live_tv/ReleaseApiSmokeTest.kt index 8095c55..a374a15 100644 --- a/native/android/app/src/androidTest/kotlin/com/matchlivetv/match_live_tv/ReleaseApiSmokeTest.kt +++ b/native/android/app/src/androidTest/kotlin/com/matchlivetv/match_live_tv/ReleaseApiSmokeTest.kt @@ -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()) + } } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/ApiInstant.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/ApiInstant.kt new file mode 100644 index 0000000..a6d8798 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/ApiInstant.kt @@ -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) diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt index 371262a..cb940b5 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt @@ -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? = 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? = 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? = null, @Json(name = "scoring_rules") val scoringRules: ScoringRulesBody? = null, ) diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchRepository.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchRepository.kt index 02d15a5..ae4800a 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchRepository.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchRepository.kt @@ -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 = api.matches(teamId) .map { it.toDomain() } + .coachHubVisible() .sortedWith(matchComparator) suspend fun fetchMatches(): List { @@ -81,8 +84,6 @@ class MatchRepository( scheduledAt: String?, setsToWin: Int, category: String?, - phase: String?, - rosterNumbers: List, 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(nullsLast()) { match -> - match.scheduledAt?.let { runCatching { Instant.parse(it) }.getOrNull() } + parseApiInstant(match.scheduledAt) }.thenByDescending { it.id } } } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/MatchHubFilter.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/MatchHubFilter.kt new file mode 100644 index 0000000..c13ad42 --- /dev/null +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/MatchHubFilter.kt @@ -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.coachHubVisible(now: Instant = Instant.now()): List = + filter { it.isCoachHubVisible(now) } + +private val RESUMABLE_STATUSES = setOf("connecting", "live", "reconnecting", "paused") diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt index 048dfe7..04ed9c4 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt @@ -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 = 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 diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchSheets.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchSheets.kt index 63f15c5..66b49dd 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchSheets.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchSheets.kt @@ -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) } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchesScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchesScreen.kt index acf953e..d77adae 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchesScreen.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchesScreen.kt @@ -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(null) } var teams by remember { mutableStateOf>(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(null) } var configureMatch by remember { mutableStateOf(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, diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepMatchScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepMatchScreen.kt index 474bd43..95f44d5 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepMatchScreen.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/wizard/StepMatchScreen.kt @@ -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 diff --git a/native/android/app/src/test/kotlin/com/matchlivetv/match_live_tv/core/ApiInstantTest.kt b/native/android/app/src/test/kotlin/com/matchlivetv/match_live_tv/core/ApiInstantTest.kt new file mode 100644 index 0000000..ced566c --- /dev/null +++ b/native/android/app/src/test/kotlin/com/matchlivetv/match_live_tv/core/ApiInstantTest.kt @@ -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")) + } +}