scaffolding hermes flow and audit logging
This commit is contained in:
@@ -1,15 +1,59 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct HermesApp: App {
|
||||
@StateObject private var repository = HermesRepository(
|
||||
apiClient: HermesAPIClient(
|
||||
environment: APIEnvironment(baseURL: URL(string: "http://localhost:3000/")!)
|
||||
)
|
||||
)
|
||||
@StateObject private var analytics = HermesAnalyticsClient()
|
||||
@StateObject private var playerCoordinator = PlayerCoordinator()
|
||||
@State private var isBootstrapping = false
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
RootView(onStartSession: { localeCode in
|
||||
if repository.currentSession != nil, repository.currentRound != nil {
|
||||
return
|
||||
}
|
||||
|
||||
guard !isBootstrapping else {
|
||||
return
|
||||
}
|
||||
|
||||
isBootstrapping = true
|
||||
let request = HermesSessionStartRequest(
|
||||
localeCode: localeCode,
|
||||
devicePlatform: "ios",
|
||||
deviceModel: UIDevice.current.model,
|
||||
osVersion: UIDevice.current.systemVersion,
|
||||
appVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.1.0"
|
||||
)
|
||||
|
||||
analytics.track("session_start_requested", attributes: ["screen_name": "session", "locale_code": localeCode])
|
||||
|
||||
Task { @MainActor in
|
||||
defer {
|
||||
isBootstrapping = false
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try await repository.bootstrap(request)
|
||||
analytics.track("session_started", attributes: ["screen_name": "session", "locale_code": localeCode])
|
||||
await analytics.flush(using: repository)
|
||||
} catch {
|
||||
analytics.track("session_start_failed", attributes: ["screen_name": "session", "locale_code": localeCode])
|
||||
}
|
||||
}
|
||||
})
|
||||
.preferredColorScheme(.dark)
|
||||
.tint(HermesTheme.accent)
|
||||
.environmentObject(analytics)
|
||||
.environmentObject(repository)
|
||||
.environmentObject(playerCoordinator)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
|
||||
func hermesUserFacingErrorMessage(localization: LocalizationStore, localeCode: String, error: Error?) -> String? {
|
||||
guard let error else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if error is CancellationError {
|
||||
return nil
|
||||
}
|
||||
|
||||
if error is URLError {
|
||||
return localization.string(for: "errors.network", localeCode: localeCode)
|
||||
}
|
||||
|
||||
if error is HermesAPIError {
|
||||
return localization.string(for: "errors.generic", localeCode: localeCode)
|
||||
}
|
||||
|
||||
return localization.string(for: "errors.generic", localeCode: localeCode)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
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
|
||||
|
||||
init(apiClient: HermesAPIClient) {
|
||||
self.apiClient = apiClient
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,19 @@ import SwiftUI
|
||||
struct RootView: View {
|
||||
@StateObject private var localization = LocalizationStore()
|
||||
@EnvironmentObject private var analytics: HermesAnalyticsClient
|
||||
@EnvironmentObject private var repository: HermesRepository
|
||||
|
||||
let onStartSession: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
header
|
||||
OnboardingView()
|
||||
FeedView()
|
||||
RoundView()
|
||||
OnboardingView(onStartSession: { onStartSession(localization.localeCode) })
|
||||
FeedView(onWatchPreview: {}, onRetry: { onStartSession(localization.localeCode) })
|
||||
RoundView(onRetry: { onStartSession(localization.localeCode) })
|
||||
SessionView(onRetry: { onStartSession(localization.localeCode) })
|
||||
}
|
||||
.padding(.horizontal, HermesTheme.screenPadding)
|
||||
.padding(.vertical, 24)
|
||||
@@ -25,6 +29,15 @@ struct RootView: View {
|
||||
analytics.track("app_opened", attributes: ["screen_name": "home"])
|
||||
analytics.track("screen_viewed", attributes: ["screen_name": "home"])
|
||||
}
|
||||
.task(id: localization.localeCode) {
|
||||
onStartSession(localization.localeCode)
|
||||
}
|
||||
.task {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 5_000_000_000)
|
||||
await analytics.flush(using: repository)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
@@ -46,8 +59,8 @@ struct RootView: View {
|
||||
|
||||
private var localeToggle: some View {
|
||||
HStack(spacing: 8) {
|
||||
localeButton(title: "EN", localeCode: "en")
|
||||
localeButton(title: "SV", localeCode: "sv")
|
||||
localeButton(title: localization.localeName(for: "en"), localeCode: "en")
|
||||
localeButton(title: localization.localeName(for: "sv"), localeCode: "sv")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user