Fix punteggio broadcast iOS: decodifica score e aggiornamento UI.

ScoreStateDto non leggeva home_points con convertFromSnakeCase; ScoreController assegna sempre lo stato, aggiornamento ottimistico e refresh overlay.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Emiliano Frascaro
2026-06-15 07:37:27 +02:00
co-authored by Cursor
parent 80813b390b
commit 594853ed04
10 changed files with 364 additions and 286 deletions
+66 -94
View File
@@ -199,38 +199,8 @@ struct SetPartialDto: Codable {
let away: Int?
}
/// Decodifica tollerante per campi JSONB (`score_states.data`).
private enum FlexibleJSON: Decodable {
case int(Int)
case bool(Bool)
case string(String)
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let v = try? container.decode(Bool.self) { self = .bool(v); return }
if let v = try? container.decode(Int.self) { self = .int(v); return }
if let v = try? container.decode(String.self) { self = .string(v); return }
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value")
}
var intValue: Int? {
switch self {
case .int(let v): return v
case .string(let s): return Int(s)
case .bool(let v): return v ? 1 : 0
}
}
var boolValue: Bool? {
switch self {
case .bool(let v): return v
case .int(let v): return v != 0
case .string(let s): return (s as NSString).boolValue
}
}
}
struct ScoreStateDto: Decodable {
let type: String?
let boardType: String?
let homeSets: Int?
let awaySets: Int?
@@ -240,68 +210,16 @@ struct ScoreStateDto: Decodable {
let setPartials: [SetPartialDto]?
let timeoutHome: Bool?
let timeoutAway: Bool?
let period: String?
let period: FlexiblePeriod?
let clockSecs: Int?
let clockRunning: Bool?
enum CodingKeys: String, CodingKey {
case boardType = "board_type"
case homeSets = "home_sets"
case awaySets = "away_sets"
case homePoints = "home_points"
case awayPoints = "away_points"
case currentSet = "current_set"
case setPartials = "set_partials"
case timeoutHome = "timeout_home"
case timeoutAway = "timeout_away"
case period
case clockSecs = "clock_secs"
case clockRunning = "clock_running"
case data
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
boardType = try c.decodeIfPresent(String.self, forKey: .boardType)
homeSets = Self.decodeInt(c, key: .homeSets)
awaySets = Self.decodeInt(c, key: .awaySets)
homePoints = Self.decodeInt(c, key: .homePoints)
awayPoints = Self.decodeInt(c, key: .awayPoints)
currentSet = Self.decodeInt(c, key: .currentSet)
setPartials = try c.decodeIfPresent([SetPartialDto].self, forKey: .setPartials)
timeoutHome = Self.decodeBool(c, key: .timeoutHome)
timeoutAway = Self.decodeBool(c, key: .timeoutAway)
period = Self.decodeString(c, key: .period)
nestedData = try c.decodeIfPresent([String: FlexibleJSON].self, forKey: .data)
clockSecs = Self.decodeInt(c, key: .clockSecs) ?? nestedData?["clock_secs"]?.intValue
clockRunning = Self.decodeBool(c, key: .clockRunning) ?? nestedData?["clock_running"]?.boolValue
}
private let nestedData: [String: FlexibleJSON]?
private static func decodeInt(_ c: KeyedDecodingContainer<CodingKeys>, key: CodingKeys) -> Int? {
if let v = try? c.decodeIfPresent(Int.self, forKey: key) { return v }
if let s = try? c.decodeIfPresent(String.self, forKey: key), let v = Int(s) { return v }
return nil
}
private static func decodeBool(_ c: KeyedDecodingContainer<CodingKeys>, key: CodingKeys) -> Bool? {
if let v = try? c.decodeIfPresent(Bool.self, forKey: key) { return v }
if let i = try? c.decodeIfPresent(Int.self, forKey: key) { return i != 0 }
return nil
}
private static func decodeString(_ c: KeyedDecodingContainer<CodingKeys>, key: CodingKeys) -> String? {
if let s = try? c.decodeIfPresent(String.self, forKey: key) { return s }
if let i = try? c.decodeIfPresent(Int.self, forKey: key) { return String(i) }
return nil
}
let clockRunning: FlexibleBool?
let data: ScoreStateDataDto?
func toDomain() -> ScoreState {
let board = boardType ?? "volley"
let homePts = homePoints ?? nestedData?["home_score"]?.intValue ?? 0
let awayPts = awayPoints ?? nestedData?["away_score"]?.intValue ?? 0
let overtime = nestedData?["overtime"]?.boolValue ?? false
let homePts = homePoints ?? data?.homeScore ?? 0
let awayPts = awayPoints ?? data?.awayScore ?? 0
let periodLabel = period?.label
return ScoreState(
homeSets: homeSets ?? 0,
awaySets: awaySets ?? 0,
@@ -314,11 +232,11 @@ struct ScoreStateDto: Decodable {
timeoutHome: timeoutHome ?? false,
timeoutAway: timeoutAway ?? false,
boardType: board,
period: parsePeriodNumber(period, currentSet: currentSet),
periodLabel: period,
clockSecs: clockSecs ?? 0,
clockRunning: clockRunning ?? false,
overtime: overtime
period: parsePeriodNumber(periodLabel, currentSet: currentSet),
periodLabel: periodLabel,
clockSecs: clockSecs ?? data?.clockSecs ?? 0,
clockRunning: clockRunning?.value ?? data?.clockRunning?.value ?? false,
overtime: data?.overtime?.value ?? false
)
}
@@ -334,6 +252,60 @@ struct ScoreStateDto: Decodable {
}
}
/// `period` può essere stringa ("Q2"), intero o null.
enum FlexiblePeriod: Decodable {
case string(String)
case int(Int)
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let v = try? container.decode(Int.self) { self = .int(v); return }
if let v = try? container.decode(String.self) { self = .string(v); return }
throw DecodingError.typeMismatch(
FlexiblePeriod.self,
DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "period non valido")
)
}
var label: String? {
switch self {
case .string(let s): return s
case .int(let v): return String(v)
}
}
}
enum FlexibleBool: Decodable {
case bool(Bool)
case int(Int)
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let v = try? container.decode(Bool.self) { self = .bool(v); return }
if let v = try? container.decode(Int.self) { self = .int(v); return }
throw DecodingError.typeMismatch(
FlexibleBool.self,
DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "bool non valido")
)
}
var value: Bool {
switch self {
case .bool(let v): return v
case .int(let v): return v != 0
}
}
}
struct ScoreStateDataDto: Decodable {
let homeScore: Int?
let awayScore: Int?
let clockSecs: Int?
let clockRunning: FlexibleBool?
let period: Int?
let overtime: FlexibleBool?
}
struct ScoreActionRequest: Encodable {
let scoreAction: String
}
@@ -3,6 +3,7 @@ import Foundation
@MainActor
final class ScoreController: ObservableObject {
@Published private(set) var score = ScoreState()
@Published private(set) var lastActionError: String?
private let scoreRepository: ScoreRepository
private let sessionCable: SessionCableService
@@ -28,43 +29,57 @@ final class ScoreController: ObservableObject {
}
func incrementHome() {
score.homePoints += 1
updateScore { $0.homePoints += 1 }
schedulePush()
}
func incrementAway() {
score.awayPoints += 1
updateScore { $0.awayPoints += 1 }
schedulePush()
}
func decrementHome() {
if score.homePoints > 0 { score.homePoints -= 1 }
updateScore { if $0.homePoints > 0 { $0.homePoints -= 1 } }
schedulePush()
}
func decrementAway() {
if score.awayPoints > 0 { score.awayPoints -= 1 }
updateScore { if $0.awayPoints > 0 { $0.awayPoints -= 1 } }
schedulePush()
}
func applyAction(_ action: String) {
applyOptimistic(action)
updateScore { applyOptimistic(to: &$0, action: action) }
scheduleAction(action)
}
/// Azione punteggio via API (volley/racket): il server è fonte di verità.
func applyBoardAction(_ action: String) async {
/// Azione punteggio via API (volley/racket): aggiornamento ottimistico + conferma server.
@discardableResult
func applyBoardAction(_ action: String) async -> Bool {
lastActionError = nil
let previous = score
updateScore { applyOptimistic(to: &$0, action: action) }
syncTask?.cancel()
syncGeneration += 1
guard let sessionId else { return }
guard let sessionId else {
score = previous
lastActionError = "Sessione non collegata"
return false
}
do {
if let updated = try await scoreRepository.applyScoreAction(sessionId: sessionId, action: action) {
suppressRemoteUntil = Date().addingTimeInterval(0.8)
score = updated
sessionCable.sendScoreUpdate(updated)
return true
}
score = previous
lastActionError = "Risposta punteggio non valida"
return false
} catch {
// Mantieni lo stato locale; la UI può mostrare errore a livello schermata se serve.
score = previous
lastActionError = UserFacingError.message(for: error)
return false
}
}
@@ -82,20 +97,27 @@ final class ScoreController: ObservableObject {
}
return false
} catch {
lastActionError = UserFacingError.message(for: error)
return false
}
}
private func applyOptimistic(_ action: String) {
private func updateScore(_ transform: (inout ScoreState) -> Void) {
var next = score
transform(&next)
score = next
}
private func applyOptimistic(to state: inout ScoreState, action: String) {
switch action {
case "home_point": score.homePoints += 1
case "away_point": score.awayPoints += 1
case "home_point_2": score.homePoints += 2
case "away_point_2": score.awayPoints += 2
case "home_point_3": score.homePoints += 3
case "away_point_3": score.awayPoints += 3
case "home_undo": score.homePoints = max(0, score.homePoints - 1)
case "away_undo": score.awayPoints = max(0, score.awayPoints - 1)
case "home_point": state.homePoints += 1
case "away_point": state.awayPoints += 1
case "home_point_2": state.homePoints += 2
case "away_point_2": state.awayPoints += 2
case "home_point_3": state.homePoints += 3
case "away_point_3": state.awayPoints += 3
case "home_undo": state.homePoints = max(0, state.homePoints - 1)
case "away_undo": state.awayPoints = max(0, state.awayPoints - 1)
default: break
}
}
@@ -106,11 +128,16 @@ final class ScoreController: ObservableObject {
let generation = syncGeneration
syncTask = Task {
guard let sessionId else { return }
if let updated = try? await scoreRepository.applyScoreAction(sessionId: sessionId, action: action) {
do {
if let updated = try await scoreRepository.applyScoreAction(sessionId: sessionId, action: action) {
guard generation == syncGeneration else { return }
suppressRemoteUntil = Date().addingTimeInterval(0.5)
score = updated
sessionCable.sendScoreUpdate(updated)
}
} catch {
guard generation == syncGeneration else { return }
suppressRemoteUntil = Date().addingTimeInterval(0.5)
score = updated
sessionCable.sendScoreUpdate(updated)
lastActionError = UserFacingError.message(for: error)
}
}
}
@@ -8,6 +8,7 @@ struct LivePreviewView: UIViewRepresentable {
func makeUIView(context: Context) -> MTHKView {
let view = MTHKView(frame: .zero)
view.videoGravity = .resizeAspectFill
view.isUserInteractionEnabled = false
Task { await engine.bindPreview(to: view) }
return view
}
@@ -45,6 +45,8 @@ struct BroadcastControlsOverlay: View {
var body: some View {
Color.clear
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
.overlay(alignment: .topLeading) {
MatchStatusBadge(
text: statusText,
@@ -50,6 +50,7 @@ struct BroadcastScreen: View {
.ignoresSafeArea()
.allowsHitTesting(false)
broadcastOverlay(session: session, match: match)
.id(scoreController.score.progressKey())
.zIndex(1)
}
}
@@ -75,6 +76,9 @@ struct BroadcastScreen: View {
await bootstrap()
}
.onChange(of: scoreController.score) { _ in updateOverlay() }
.onChange(of: scoreController.lastActionError) { message in
if let message { snackbarMessage = message }
}
.onChange(of: broadcastCoordinator.metrics.phase) { _ in updateOverlay() }
.alert("Errore", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
Button("OK") { onFinished() }
@@ -143,8 +147,10 @@ struct BroadcastScreen: View {
if usesActionScoring {
scoreController.applyAction("home_point")
} else if usesSetScoring {
await scoreController.applyBoardAction("home_point")
await liveScoreActions(for: match).afterPointChange(score: scoreController.score)
let ok = await scoreController.applyBoardAction("home_point")
if ok {
await liveScoreActions(for: match).afterPointChange(score: scoreController.score)
}
} else {
scoreController.incrementHome()
}
@@ -155,8 +161,10 @@ struct BroadcastScreen: View {
if usesActionScoring {
scoreController.applyAction("away_point")
} else if usesSetScoring {
await scoreController.applyBoardAction("away_point")
await liveScoreActions(for: match).afterPointChange(score: scoreController.score)
let ok = await scoreController.applyBoardAction("away_point")
if ok {
await liveScoreActions(for: match).afterPointChange(score: scoreController.score)
}
} else {
scoreController.incrementAway()
}