Porting SwiftUI da Android (auth, hub, wizard, broadcast RTMP, overlay, scoring board-aware), reload hub al ritorno da diretta, decodifica score tollerante e documentazione allineata. Co-authored-by: Cursor <cursoragent@cursor.com>
476 lines
14 KiB
Swift
476 lines
14 KiB
Swift
import Foundation
|
|
|
|
struct LoginRequest: Encodable {
|
|
let email: String
|
|
let password: String
|
|
}
|
|
|
|
struct RefreshRequest: Encodable {
|
|
let refreshToken: String
|
|
}
|
|
|
|
struct LoginResponse: Decodable {
|
|
let user: UserDto
|
|
let accessToken: String
|
|
let refreshToken: String
|
|
|
|
func toDomain() -> (User, AuthTokens) {
|
|
(user.toDomain(), AuthTokens(accessToken: accessToken, refreshToken: refreshToken))
|
|
}
|
|
}
|
|
|
|
struct UserDto: Decodable {
|
|
let id: String
|
|
let email: String
|
|
let name: String
|
|
let role: String?
|
|
|
|
func toDomain() -> User {
|
|
User(id: id, email: email, name: name, role: role ?? "coach")
|
|
}
|
|
}
|
|
|
|
struct SportDto: Decodable {
|
|
let key: String
|
|
let label: String
|
|
let board: String
|
|
let overlay: String
|
|
let allowedOverlays: [String]?
|
|
let defaultRules: [String: Int]?
|
|
|
|
func toDomain() -> SportOption {
|
|
SportOption(
|
|
key: key,
|
|
label: label,
|
|
board: board,
|
|
overlay: overlay,
|
|
allowedOverlays: allowedOverlays ?? [],
|
|
defaultRules: defaultRules ?? [:]
|
|
)
|
|
}
|
|
}
|
|
|
|
struct TeamDto: Decodable {
|
|
let id: String
|
|
let name: String
|
|
let sport: String?
|
|
let sportKey: String?
|
|
let boardType: String?
|
|
let canStream: Bool?
|
|
let clubName: String?
|
|
let youtubeEnabled: Bool?
|
|
let youtubeConnected: Bool?
|
|
let youtubeSelectable: Bool?
|
|
let youtubeChannelTitle: String?
|
|
let youtubeUsesPlatformChannel: Bool?
|
|
let youtubeTeamChannelTitle: String?
|
|
let planName: String?
|
|
let recordingsEnabled: Bool?
|
|
let phoneDownloadEnabled: Bool?
|
|
let billingUrl: String?
|
|
let staffManageUrl: String?
|
|
let logoUrl: String?
|
|
let primaryColor: String?
|
|
let secondaryColor: String?
|
|
|
|
func toDomain() -> Team {
|
|
Team(
|
|
id: id,
|
|
name: name,
|
|
sport: sportKey ?? sport ?? "pallavolo",
|
|
sportKey: sportKey ?? sport ?? "pallavolo",
|
|
boardType: boardType ?? "volley",
|
|
clubName: clubName,
|
|
logoUrl: logoUrl,
|
|
primaryColor: primaryColor,
|
|
secondaryColor: secondaryColor,
|
|
canStream: canStream ?? true,
|
|
youtubeEnabled: youtubeEnabled ?? false,
|
|
youtubeConnected: youtubeConnected ?? false,
|
|
youtubeSelectable: youtubeSelectable ?? false,
|
|
youtubeChannelTitle: youtubeChannelTitle,
|
|
youtubeUsesPlatformChannel: youtubeUsesPlatformChannel ?? false,
|
|
youtubeTeamChannelTitle: youtubeTeamChannelTitle,
|
|
planName: planName,
|
|
recordingsEnabled: recordingsEnabled ?? false,
|
|
phoneDownloadEnabled: phoneDownloadEnabled ?? false,
|
|
billingUrl: billingUrl,
|
|
staffManageUrl: staffManageUrl
|
|
)
|
|
}
|
|
}
|
|
|
|
struct MatchDto: Decodable {
|
|
let id: String
|
|
let teamId: String
|
|
let teamName: String?
|
|
let opponentName: String
|
|
let location: String?
|
|
let scheduledAt: String?
|
|
let setsToWin: Int?
|
|
let sportKey: String?
|
|
let sport: String?
|
|
let sportLabel: String?
|
|
let boardType: String?
|
|
let overlayKind: String?
|
|
let effectiveOverlayKind: String?
|
|
let category: String?
|
|
let scoringRules: ScoringRulesBody?
|
|
let activeSessionId: String?
|
|
let activeSessionStatus: String?
|
|
let streamCompleted: Bool?
|
|
let coachHubVisible: Bool?
|
|
let homePrimaryColor: String?
|
|
let homeSecondaryColor: String?
|
|
let homeLogoUrl: String?
|
|
let opponentPrimaryColor: String?
|
|
let opponentLogoUrl: String?
|
|
|
|
func toDomain() -> Match {
|
|
Match(
|
|
id: id,
|
|
teamId: teamId,
|
|
teamName: teamName ?? "",
|
|
opponentName: opponentName,
|
|
location: location,
|
|
scheduledAt: scheduledAt,
|
|
sportKey: sportKey ?? sport ?? "pallavolo",
|
|
sportLabel: sportLabel,
|
|
boardType: boardType ?? "volley",
|
|
overlayKind: overlayKind,
|
|
effectiveOverlayKind: effectiveOverlayKind ?? overlayKind ?? "volley",
|
|
setsToWin: setsToWin ?? 3,
|
|
category: category,
|
|
scoringRules: scoringRules?.toDomain(),
|
|
activeSessionId: activeSessionId,
|
|
activeSessionStatus: activeSessionStatus,
|
|
streamCompleted: streamCompleted ?? false,
|
|
coachHubVisible: coachHubVisible,
|
|
homePrimaryColor: homePrimaryColor,
|
|
homeSecondaryColor: homeSecondaryColor,
|
|
homeLogoUrl: homeLogoUrl,
|
|
opponentPrimaryColor: opponentPrimaryColor,
|
|
opponentLogoUrl: opponentLogoUrl
|
|
)
|
|
}
|
|
}
|
|
|
|
struct StreamSessionDto: Decodable {
|
|
let id: String
|
|
let matchId: String
|
|
let status: String?
|
|
let platform: String?
|
|
let rtmpIngestUrl: String?
|
|
let hlsPlaybackUrl: String?
|
|
let watchPageUrl: String?
|
|
let shareUrl: String?
|
|
let youtubeWatchUrl: String?
|
|
let youtubeReady: Bool?
|
|
let privacyStatus: String?
|
|
let targetBitrate: Int?
|
|
let targetFps: Int?
|
|
let startedAt: String?
|
|
let score: ScoreStateDto?
|
|
|
|
func toDomain() -> StreamSession {
|
|
StreamSession(
|
|
id: id,
|
|
matchId: matchId,
|
|
status: status ?? "idle",
|
|
platform: platform ?? "matchlivetv",
|
|
rtmpIngestUrl: rtmpIngestUrl,
|
|
hlsPlaybackUrl: hlsPlaybackUrl,
|
|
watchPageUrl: watchPageUrl,
|
|
shareUrl: shareUrl,
|
|
youtubeWatchUrl: youtubeWatchUrl,
|
|
youtubeReady: youtubeReady ?? false,
|
|
privacyStatus: privacyStatus ?? "public",
|
|
targetBitrate: targetBitrate ?? 2_500_000,
|
|
targetFps: targetFps ?? 30,
|
|
startedAt: startedAt,
|
|
score: score?.toDomain()
|
|
)
|
|
}
|
|
}
|
|
|
|
struct SetPartialDto: Codable {
|
|
let set: Int?
|
|
let home: Int?
|
|
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 boardType: String?
|
|
let homeSets: Int?
|
|
let awaySets: Int?
|
|
let homePoints: Int?
|
|
let awayPoints: Int?
|
|
let currentSet: Int?
|
|
let setPartials: [SetPartialDto]?
|
|
let timeoutHome: Bool?
|
|
let timeoutAway: Bool?
|
|
let period: String?
|
|
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
|
|
}
|
|
|
|
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
|
|
return ScoreState(
|
|
homeSets: homeSets ?? 0,
|
|
awaySets: awaySets ?? 0,
|
|
homePoints: homePts,
|
|
awayPoints: awayPts,
|
|
currentSet: max(currentSet ?? 1, 1),
|
|
setPartials: (setPartials ?? []).map {
|
|
SetPartial(set: $0.set ?? 1, home: $0.home ?? 0, away: $0.away ?? 0)
|
|
},
|
|
timeoutHome: timeoutHome ?? false,
|
|
timeoutAway: timeoutAway ?? false,
|
|
boardType: board,
|
|
period: parsePeriodNumber(period, currentSet: currentSet),
|
|
periodLabel: period,
|
|
clockSecs: clockSecs ?? 0,
|
|
clockRunning: clockRunning ?? false,
|
|
overtime: overtime
|
|
)
|
|
}
|
|
|
|
private func parsePeriodNumber(_ label: String?, currentSet: Int?) -> Int {
|
|
if let text = label {
|
|
if let m = text.range(of: #"Q(\d+)"#, options: .regularExpression) {
|
|
let num = text[m].dropFirst()
|
|
if let v = Int(num) { return v }
|
|
}
|
|
if let v = Int(text), v > 0 { return v }
|
|
}
|
|
return max(currentSet ?? 1, 1)
|
|
}
|
|
}
|
|
|
|
struct ScoreActionRequest: Encodable {
|
|
let scoreAction: String
|
|
}
|
|
|
|
struct ScoreSyncRequest: Encodable {
|
|
let homeSets: Int
|
|
let awaySets: Int
|
|
let homePoints: Int
|
|
let awayPoints: Int
|
|
let currentSet: Int
|
|
let setPartials: [SetPartialDto]
|
|
let clockSecs: Int?
|
|
let clockRunning: Bool?
|
|
let period: Int?
|
|
let homeScore: Int?
|
|
let awayScore: Int?
|
|
}
|
|
|
|
struct CreateSessionRequest: Encodable {
|
|
let platform: String
|
|
let privacyStatus: String
|
|
let qualityPreset: String
|
|
let targetBitrate: Int
|
|
let targetFps: Int
|
|
let youtubeChannel: String?
|
|
}
|
|
|
|
struct ScoringRulesBody: Codable {
|
|
let setsToWin: Int?
|
|
let pointsPerSet: Int?
|
|
let pointsDecidingSet: Int?
|
|
let minPointLead: Int?
|
|
let periods: Int?
|
|
let periodDurationSecs: Int?
|
|
let overtimeDurationSecs: Int?
|
|
|
|
func toDomain() -> ScoringRules {
|
|
ScoringRules(
|
|
pointsPerSet: pointsPerSet ?? 25,
|
|
pointsDecidingSet: pointsDecidingSet ?? 15,
|
|
minPointLead: minPointLead ?? 2,
|
|
periods: periods ?? 4,
|
|
periodDurationSecs: periodDurationSecs ?? 600,
|
|
overtimeDurationSecs: overtimeDurationSecs ?? 300
|
|
)
|
|
}
|
|
}
|
|
|
|
struct UpdateMatchBodyFull: Encodable {
|
|
let opponentName: String
|
|
let location: String?
|
|
let scheduledAt: String?
|
|
let setsToWin: Int
|
|
let sportKey: String?
|
|
let overlayKind: String?
|
|
let category: String?
|
|
let opponentPrimaryColor: String?
|
|
let scoringRules: [String: Int]?
|
|
}
|
|
|
|
struct UpdateMatchRequestFull: Encodable {
|
|
let match: UpdateMatchBodyFull
|
|
}
|
|
|
|
struct UpdateTeamBody: Encodable {
|
|
let primaryColor: String?
|
|
let secondaryColor: String?
|
|
}
|
|
|
|
struct UpdateTeamRequest: Encodable {
|
|
let team: UpdateTeamBody
|
|
}
|
|
|
|
struct NetworkTestResponse: Decodable {
|
|
let ready: Bool
|
|
let targetUploadMbps: Double?
|
|
let qualityPreset: String?
|
|
let targetBitrate: Int?
|
|
let targetFps: Int?
|
|
}
|
|
|
|
struct RegiaLinkResponse: Decodable {
|
|
let regiaUrl: String
|
|
}
|
|
|
|
struct CreateMatchBody: Encodable {
|
|
let opponentName: String
|
|
let sport: String
|
|
let setsToWin: Int
|
|
let scheduledAt: String?
|
|
let location: String?
|
|
}
|
|
|
|
struct CreateMatchRequest: Encodable {
|
|
let match: CreateMatchBody
|
|
}
|
|
|
|
struct NetworkTestRequest: Encodable {
|
|
let downloadMbps: Double
|
|
let uploadMbps: Double
|
|
let latencyMs: Int
|
|
let networkType: String
|
|
}
|
|
|
|
struct TelemetryRequest: Encodable {
|
|
let deviceRole: String
|
|
let batteryLevel: Int?
|
|
let networkType: String?
|
|
let signalStrength: Int?
|
|
let currentBitrate: Int?
|
|
let targetBitrate: Int?
|
|
let fps: Int?
|
|
}
|
|
|
|
extension ScoringRules {
|
|
func toBody(setsToWin: Int? = nil) -> ScoringRulesBody {
|
|
ScoringRulesBody(
|
|
setsToWin: setsToWin,
|
|
pointsPerSet: pointsPerSet,
|
|
pointsDecidingSet: pointsDecidingSet,
|
|
minPointLead: minPointLead,
|
|
periods: periods,
|
|
periodDurationSecs: periodDurationSecs,
|
|
overtimeDurationSecs: overtimeDurationSecs
|
|
)
|
|
}
|
|
|
|
func toMap(setsToWin: Int? = nil) -> [String: Int] {
|
|
var map: [String: Int] = [:]
|
|
if let setsToWin { map["sets_to_win"] = setsToWin }
|
|
map["points_per_set"] = pointsPerSet
|
|
map["points_deciding_set"] = pointsDecidingSet
|
|
map["min_point_lead"] = minPointLead
|
|
map["periods"] = periods
|
|
map["period_duration_secs"] = periodDurationSecs
|
|
map["overtime_duration_secs"] = overtimeDurationSecs
|
|
return map
|
|
}
|
|
}
|