Files
hermes/mobile/ios-app/App/HermesRepository.swift
T

323 lines
11 KiB
Swift

import Combine
import Foundation
@MainActor
final class HermesRepository: ObservableObject {
@Published private(set) var currentSession: HermesSessionResponse?
@Published private(set) var currentRound: HermesRound?
@Published private(set) var isLoading = true
@Published private(set) var errorCause: Error?
@Published private(set) var serverClockOffset: TimeInterval?
private let apiClient: HermesAPIClient
private var mockRoundIndex = 0
init(apiClient: HermesAPIClient) {
self.apiClient = apiClient
}
func reset() {
currentSession = nil
currentRound = nil
isLoading = false
errorCause = nil
serverClockOffset = nil
mockRoundIndex = 0
}
func bootstrapMock(localeCode: String, appVersion: String) async {
isLoading = true
errorCause = nil
mockRoundIndex = 0
currentSession = MockHermesData.session(localeCode: localeCode, appVersion: appVersion)
currentRound = MockHermesData.round(index: mockRoundIndex, now: Date())
isLoading = false
}
func refreshMockRound() async throws -> HermesRound {
isLoading = true
errorCause = nil
mockRoundIndex = (mockRoundIndex + 1) % MockHermesData.roundCount
let round = MockHermesData.round(index: mockRoundIndex, now: Date())
currentRound = round
isLoading = false
return round
}
func bootstrap(_ request: HermesSessionStartRequest) async throws -> HermesSessionResponse {
isLoading = true
errorCause = nil
do {
await syncClock()
let session: HermesSessionResponse
if let existingSession = currentSession {
session = existingSession
} else {
session = try await startSession(request)
}
if currentRound == nil {
currentRound = try await loadRoundFromNetwork()
}
isLoading = false
return session
} catch {
errorCause = error
isLoading = false
throw error
}
}
func refreshRoundFromNetwork() async throws -> HermesRound {
isLoading = true
errorCause = nil
do {
await syncClock()
let round = try await loadRoundFromNetwork()
currentRound = round
isLoading = false
return round
} catch {
errorCause = error
isLoading = false
throw error
}
}
func startSession(_ request: HermesSessionStartRequest) async throws -> HermesSessionResponse {
let session = try await apiClient.startSession(request)
currentSession = session
return session
}
func endSession() async throws -> HermesSessionResponse {
let session = try await apiClient.endSession()
currentSession = session
return session
}
func submitBetIntent(_ request: HermesBetIntentRequest) async throws -> HermesBetIntentResponse {
try await apiClient.submitBetIntent(request)
}
func currentOdds(marketID: UUID) async throws -> HermesOddsVersion {
try await apiClient.currentOdds(marketID: marketID)
}
func settlement(eventID: UUID) async throws -> HermesSettlement {
try await apiClient.settlement(eventID: eventID)
}
func experimentConfig() async throws -> HermesExperimentConfig {
try await apiClient.experimentConfig()
}
func localization(localeCode: String) async throws -> HermesLocalizationBundle {
try await apiClient.localization(localeCode: localeCode)
}
func submitAnalyticsBatch(_ payload: HermesAnalyticsBatchRequest) async throws {
try await apiClient.submitAnalyticsBatch(payload)
}
func serverNow() -> Date {
guard let serverClockOffset else {
return Date()
}
return Date().addingTimeInterval(serverClockOffset)
}
private func syncClock() async {
do {
let health = try await apiClient.health()
serverClockOffset = health.serverTime.timeIntervalSince(Date())
} catch {
return
}
}
private func loadRoundFromNetwork() async throws -> HermesRound {
let event = try await apiClient.nextEvent()
let manifest = try await apiClient.eventManifest(eventID: event.id)
guard let media = manifest.media.first(where: { $0.mediaType == "hls_main" }) ?? manifest.media.first else {
throw HermesAPIError.invalidResponse
}
let market: HermesMarket
if let manifestMarket = manifest.markets.first {
market = manifestMarket
} else {
let markets = try await apiClient.markets(eventID: event.id)
guard let fallbackMarket = markets.first else {
throw HermesAPIError.invalidResponse
}
market = fallbackMarket
}
let oddsVersion = try await apiClient.currentOdds(marketID: market.id)
let settlement = try await apiClient.settlement(eventID: event.id)
return HermesRound(
event: event,
media: media,
market: market,
oddsVersion: oddsVersion,
settlement: settlement
)
}
}
private enum MockHermesData {
static let roundCount = 3
static func session(localeCode: String, appVersion: String) -> HermesSessionResponse {
HermesSessionResponse(
sessionId: UUID(uuidString: "00000000-0000-0000-0000-000000000001") ?? UUID(),
userId: UUID(uuidString: "00000000-0000-0000-0000-000000000002") ?? UUID(),
startedAt: Date(),
endedAt: nil,
experimentVariant: "modern",
appVersion: appVersion,
deviceModel: "Demo Device",
osVersion: "Demo OS",
localeCode: localeCode,
devicePlatform: "ios"
)
}
static func round(index: Int, now: Date) -> HermesRound {
let scenarios = [
scenario(
eventID: "10000000-0000-0000-0000-000000000001",
marketID: "20000000-0000-0000-0000-000000000001",
yesOutcomeID: "30000000-0000-0000-0000-000000000001",
noOutcomeID: "30000000-0000-0000-0000-000000000002",
promptKey: "event.question.score",
sourceRef: "mock-score",
winningOutcomeID: "30000000-0000-0000-0000-000000000001",
now: now
),
scenario(
eventID: "10000000-0000-0000-0000-000000000011",
marketID: "20000000-0000-0000-0000-000000000011",
yesOutcomeID: "30000000-0000-0000-0000-000000000011",
noOutcomeID: "30000000-0000-0000-0000-000000000012",
promptKey: "event.question.save",
sourceRef: "mock-save",
winningOutcomeID: "30000000-0000-0000-0000-000000000012",
now: now
),
scenario(
eventID: "10000000-0000-0000-0000-000000000021",
marketID: "20000000-0000-0000-0000-000000000021",
yesOutcomeID: "30000000-0000-0000-0000-000000000021",
noOutcomeID: "30000000-0000-0000-0000-000000000022",
promptKey: "event.question.convert",
sourceRef: "mock-convert",
winningOutcomeID: "30000000-0000-0000-0000-000000000021",
now: now
),
]
return scenarios[index % scenarios.count]
}
private static func scenario(
eventID: String,
marketID: String,
yesOutcomeID: String,
noOutcomeID: String,
promptKey: String,
sourceRef: String,
winningOutcomeID: String,
now: Date
) -> HermesRound {
let lockAt = now.addingTimeInterval(15)
let settleAt = lockAt.addingTimeInterval(6)
let mediaURL = URL(string: "https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_ts/master.m3u8")!
let event = HermesEvent(
id: UUID(uuidString: eventID) ?? UUID(),
sportType: "football",
sourceRef: sourceRef,
titleEn: promptKey,
titleSv: promptKey,
status: "active",
previewStartMs: 0,
previewEndMs: 12_000,
revealStartMs: 12_000,
revealEndMs: 18_000,
lockAt: lockAt,
settleAt: settleAt
)
let media = HermesEventMedia(
id: UUID(),
eventId: event.id,
mediaType: "hls_main",
hlsMasterUrl: mediaURL,
posterUrl: nil,
durationMs: 18_000,
previewStartMs: 0,
previewEndMs: 12_000,
revealStartMs: 12_000,
revealEndMs: 18_000
)
let marketUUID = UUID(uuidString: marketID) ?? UUID()
let market = HermesMarket(
id: marketUUID,
eventId: event.id,
questionKey: promptKey,
marketType: promptKey,
status: "open",
lockAt: lockAt,
settlementRuleKey: "demo",
outcomes: [
HermesOutcome(
id: UUID(uuidString: yesOutcomeID) ?? UUID(),
marketId: marketUUID,
outcomeCode: "yes",
labelKey: "round.yes",
sortOrder: 0
),
HermesOutcome(
id: UUID(uuidString: noOutcomeID) ?? UUID(),
marketId: marketUUID,
outcomeCode: "no",
labelKey: "round.no",
sortOrder: 1
),
]
)
let oddsVersionID = UUID()
let oddsVersion = HermesOddsVersion(
id: oddsVersionID,
marketId: market.id,
versionNo: 1,
createdAt: now,
isCurrent: true,
odds: [
HermesOutcomeOdds(id: UUID(), oddsVersionId: oddsVersionID, outcomeId: market.outcomes[0].id, decimalOdds: 1.82, fractionalNum: 91, fractionalDen: 50),
HermesOutcomeOdds(id: UUID(), oddsVersionId: oddsVersionID, outcomeId: market.outcomes[1].id, decimalOdds: 2.05, fractionalNum: 41, fractionalDen: 20),
]
)
let settlement = HermesSettlement(
id: UUID(),
marketId: market.id,
settledAt: settleAt,
winningOutcomeId: UUID(uuidString: winningOutcomeID) ?? market.outcomes[0].id
)
return HermesRound(event: event, media: media, market: market, oddsVersion: oddsVersion, settlement: settlement)
}
}