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:
co-authored by
Cursor
parent
80813b390b
commit
594853ed04
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user