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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
60
backend/lib/tasks/matches.rake
Normal file
60
backend/lib/tasks/matches.rake
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
5
native/android/app/proguard-rules.pro
vendored
5
native/android/app/proguard-rules.pro
vendored
@@ -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 { *; }
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.matchlivetv.match_live_tv.core
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.DateTimeParseException
|
||||||
|
|
||||||
|
/** Parsing date ISO da Rails/API (`2026-06-06T20:00:00.000+02:00`, ecc.). */
|
||||||
|
fun parseApiInstant(raw: String?): Instant? {
|
||||||
|
val value = raw?.trim().orEmpty()
|
||||||
|
if (value.isEmpty()) return null
|
||||||
|
|
||||||
|
parseInstantOrNull(value)?.let { return it }
|
||||||
|
|
||||||
|
// Fallback: normalizza spazi e riprova.
|
||||||
|
val normalized = value.replace(' ', 'T')
|
||||||
|
if (normalized != value) {
|
||||||
|
parseInstantOrNull(normalized)?.let { return it }
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseInstantOrNull(value: String): Instant? {
|
||||||
|
try {
|
||||||
|
return Instant.parse(value)
|
||||||
|
} catch (_: DateTimeParseException) {
|
||||||
|
// Instant.parse fallisce su alcuni dispositivi con offset + frazioni di secondo.
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return OffsetDateTime.parse(value, DateTimeFormatter.ISO_OFFSET_DATE_TIME).toInstant()
|
||||||
|
} catch (_: DateTimeParseException) {
|
||||||
|
// continua
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return ZonedDateTime.parse(value, DateTimeFormatter.ISO_ZONED_DATE_TIME).toInstant()
|
||||||
|
} catch (_: DateTimeParseException) {
|
||||||
|
// continua
|
||||||
|
}
|
||||||
|
|
||||||
|
return runCatching {
|
||||||
|
LocalDateTime.parse(value, DateTimeFormatter.ISO_LOCAL_DATE_TIME)
|
||||||
|
.atZone(ZoneId.of("Europe/Rome"))
|
||||||
|
.toInstant()
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Instant.isScheduledFuture(now: Instant = Instant.now()): Boolean = isAfter(now)
|
||||||
@@ -3,6 +3,7 @@ package com.matchlivetv.match_live_tv.data.api
|
|||||||
import com.matchlivetv.match_live_tv.domain.AuthTokens
|
import com.matchlivetv.match_live_tv.domain.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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.matchlivetv.match_live_tv.domain
|
||||||
|
|
||||||
|
import com.matchlivetv.match_live_tv.core.isScheduledFuture
|
||||||
|
import com.matchlivetv.match_live_tv.core.parseApiInstant
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
/** Partite visibili nell'hub coach: dirette attive, wizard in corso, programmate future. */
|
||||||
|
fun Match.isCoachHubVisible(now: Instant = Instant.now()): Boolean {
|
||||||
|
coachHubVisible?.let { return it }
|
||||||
|
|
||||||
|
if (hasActiveSession) {
|
||||||
|
val status = activeSessionStatus.orEmpty()
|
||||||
|
if (status in RESUMABLE_STATUSES || status == "idle") return true
|
||||||
|
}
|
||||||
|
if (streamCompleted) return false
|
||||||
|
|
||||||
|
val scheduled = parseApiInstant(scheduledAt)
|
||||||
|
return scheduled != null && scheduled.isScheduledFuture(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun List<Match>.coachHubVisible(now: Instant = Instant.now()): List<Match> =
|
||||||
|
filter { it.isCoachHubVisible(now) }
|
||||||
|
|
||||||
|
private val RESUMABLE_STATUSES = setOf("connecting", "live", "reconnecting", "paused")
|
||||||
@@ -33,6 +33,12 @@ data class Team(
|
|||||||
val isYoutubeReady: Boolean get() = youtubeSelectable
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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) {
|
||||||
loading = true
|
if (showInitialSpinner) {
|
||||||
|
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,25 +173,31 @@ fun MatchesScreen(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
when {
|
PullToRefreshBox(
|
||||||
loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
isRefreshing = refreshing,
|
||||||
CircularProgressIndicator(color = MatchColors.PrimaryRed)
|
onRefresh = { reload(showInitialSpinner = false) },
|
||||||
}
|
state = pullRefreshState,
|
||||||
teams.isEmpty() -> NoTeamContent(onRetry = { reload() })
|
modifier = Modifier.fillMaxSize(),
|
||||||
error != null -> Column(
|
) {
|
||||||
Modifier
|
when {
|
||||||
.fillMaxSize()
|
loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
.padding(24.dp),
|
CircularProgressIndicator(color = MatchColors.PrimaryRed)
|
||||||
verticalArrangement = Arrangement.Center,
|
}
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
teams.isEmpty() -> NoTeamContent(onRetry = { reload(showInitialSpinner = false) })
|
||||||
) {
|
error != null -> Column(
|
||||||
Text(error!!, color = MatchColors.PrimaryRed, textAlign = TextAlign.Center)
|
Modifier
|
||||||
Spacer(Modifier.height(16.dp))
|
.fillMaxSize()
|
||||||
MatchPrimaryButton(label = "RIPROVA", onClick = { reload() })
|
.padding(24.dp),
|
||||||
}
|
verticalArrangement = Arrangement.Center,
|
||||||
else -> LazyColumn(
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
contentPadding = PaddingValues(bottom = 24.dp),
|
) {
|
||||||
) {
|
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 {
|
item {
|
||||||
Column(Modifier.padding(horizontal = 20.dp, vertical = 8.dp)) {
|
Column(Modifier.padding(horizontal = 20.dp, vertical = 8.dp)) {
|
||||||
Text(
|
Text(
|
||||||
@@ -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,33 +238,59 @@ 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) {
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
Text(
|
||||||
) {
|
"Es. Serie C, torneo estivo — lo useremo in descrizione e overlay.",
|
||||||
Column {
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
Text("Orario", style = MaterialTheme.typography.bodyMedium)
|
color = MatchColors.TextSecondary,
|
||||||
Text(
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
scheduledAt?.let { formatMatchDate(it) } ?: "Seleziona data e ora",
|
)
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
)
|
if (isScheduledMatch) {
|
||||||
}
|
Spacer(Modifier.height(16.dp))
|
||||||
Icon(Icons.Default.CalendarToday, null, tint = MatchColors.PrimaryRed, modifier = Modifier
|
WizardReadOnlyField(
|
||||||
.padding(8.dp)
|
label = "Programmata per",
|
||||||
.then(Modifier))
|
value = formatMatchDate(match.scheduledAt) ?: "—",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
MatchSecondaryButton(label = "SCEGLI DATA E ORA", onClick = { pickDateTime() })
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(20.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(12.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
MatchOutlinedField(
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
value = roster,
|
listOf(2, 3).forEach { value ->
|
||||||
onValueChange = { roster = it },
|
val selected = setsToWin == value
|
||||||
label = "Convocate (numeri maglia)",
|
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))
|
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
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user