import SwiftUI struct RoundView: View { @EnvironmentObject private var localization: LocalizationStore @EnvironmentObject private var analytics: HermesAnalyticsClient @EnvironmentObject private var repository: HermesRepository @EnvironmentObject private var playerCoordinator: PlayerCoordinator let onRetry: () -> Void @State private var phase: Phase = .preview @State private var selectedOutcomeID: String? = nil @State private var lockAt: Date = .now @State private var transitionTask: Task? @State private var actionMessage: String? @State private var isSubmitting = false private enum Phase { case preview case locked case reveal case result } var body: some View { TimelineView(.periodic(from: Date(), by: 1)) { _ in let round = repository.currentRound let now = repository.serverNow() let hasRound = round != nil let remaining = max(lockAt.timeIntervalSince(now), 0) let timerLocked = round != nil && remaining <= 0 let countdownText = Self.countdownText(for: remaining) let bannerMessage = actionMessage ?? hermesUserFacingErrorMessage( localization: localization, localeCode: localization.localeCode, error: repository.errorCause ) VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) { HermesSectionHeader( title: localization.string(for: "round.title"), subtitle: localization.string(for: "round.subtitle") ) if !hasRound { if let bannerMessage { roundErrorState( message: bannerMessage, retryText: localization.string(for: "common.retry"), onRetry: onRetry ) } else { roundLoadingState( title: localization.string(for: "common.loading"), subtitle: localization.string(for: "round.subtitle") ) } } else { if let bannerMessage { roundBanner(message: bannerMessage) } videoSection( round: round, countdownText: countdownText, remaining: remaining, isTimerLocked: timerLocked ) if let round { switch phase { case .preview, .locked: SelectionView( statusText: phase == .preview && !timerLocked ? localization.string(for: "round.selection_prompt") : localization.string(for: "round.locked_label"), options: selectionOptions(for: round), selectedOptionID: selectedOutcomeID, isLocked: phase != .preview || timerLocked || isSubmitting, confirmTitle: localization.string(for: "round.primary_cta"), onSelect: handleSelection, onConfirm: confirmSelection ) case .reveal: RevealView( title: localization.string(for: "reveal.title"), subtitle: localization.string(for: "reveal.subtitle"), statusText: localization.string(for: "reveal.status"), selectionLabel: localization.string(for: "result.selection_label"), selectionValue: selectedOutcomeTitle(for: round), continueTitle: localization.string(for: "reveal.cta"), onContinue: showResult ) case .result: ResultView( title: localization.string(for: "result.title"), subtitle: localization.string(for: "result.subtitle"), selectionLabel: localization.string(for: "result.selection_label"), selectionValue: selectedOutcomeTitle(for: round), outcomeLabel: localization.string(for: "result.outcome_label"), outcomeValue: winningOutcomeTitle(for: round), didWin: selectedOutcomeID == round.settlement.winningOutcomeId.uuidString, winLabel: localization.string(for: "result.win"), loseLabel: localization.string(for: "result.lose"), nextRoundTitle: localization.string(for: "result.next_round"), onNextRound: nextRound ) } } } } .onAppear { if let round { startPreview(round) } } .onChange(of: round?.event.id) { _, newValue in guard newValue != nil, let round else { return } startPreview(round) } } .hermesCard(elevated: true) .onDisappear { transitionTask?.cancel() playerCoordinator.pause() } } @ViewBuilder private func videoSection(round: HermesRound?, countdownText: String, remaining: TimeInterval, isTimerLocked: Bool) -> some View { ZStack(alignment: .topTrailing) { RoundedRectangle(cornerRadius: HermesTheme.cornerRadius, style: .continuous) .fill(HermesTheme.surfaceElevated) .frame(height: 220) if let round { HermesVideoPlayerView(coordinator: playerCoordinator) } else { Text(localization.string(for: "round.video_placeholder")) .font(.headline.weight(.semibold)) .foregroundStyle(HermesTheme.textSecondary) } if let round { VStack(alignment: .trailing, spacing: 10) { HermesCountdownBadge( label: localization.string(for: "round.countdown_label"), value: countdownText, warning: !isTimerLocked && remaining <= 10 ) HermesMetricPill( label: localization.string(for: "round.odds_label"), value: formatOdds(round) ) .frame(maxWidth: 160) } .padding(12) } Text(phaseLabel(isTimerLocked: isTimerLocked)) .font(.caption.weight(.bold)) .foregroundStyle(HermesTheme.textPrimary) .padding(.horizontal, 12) .padding(.vertical, 8) .background(.black.opacity(0.35)) .clipShape(Capsule()) .padding(12) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) } .clipShape(RoundedRectangle(cornerRadius: HermesTheme.cornerRadius, style: .continuous)) } private func startPreview(_ round: HermesRound) { transitionTask?.cancel() phase = .preview selectedOutcomeID = nil actionMessage = nil isSubmitting = false lockAt = round.event.lockAt playerCoordinator.prepareForPreview(url: round.media.hlsMasterUrl, startTimeMs: round.media.previewStartMs) analytics.track("round_loaded", attributes: roundAnalyticsAttributes(round)) analytics.track("preview_started", attributes: roundAnalyticsAttributes(round)) analytics.track("screen_viewed", attributes: ["screen_name": "round"]) } private func handleSelection(_ option: SelectionOption) { guard phase == .preview, !isSubmitting else { return } selectedOutcomeID = option.id analytics.track("outcome_focused", attributes: ["screen_name": "round", "outcome_id": option.id]) analytics.track("outcome_selected", attributes: ["screen_name": "round", "outcome_id": option.id]) } private func confirmSelection() { guard phase == .preview, !isSubmitting, let round = repository.currentRound else { return } guard let selectedOutcomeID, let session = repository.currentSession else { actionMessage = localization.string(for: "errors.session_expired") return } guard let outcomeID = UUID(uuidString: selectedOutcomeID) ?? round.market.outcomes.first?.id else { actionMessage = localization.string(for: "errors.generic") return } if repository.serverNow() >= round.event.lockAt { return } isSubmitting = true actionMessage = nil Task { @MainActor in do { let request = HermesBetIntentRequest( sessionId: session.sessionId, eventId: round.event.id, marketId: round.market.id, outcomeId: outcomeID, idempotencyKey: UUID().uuidString, clientSentAt: Date() ) let response = try await repository.submitBetIntent(request) guard response.accepted else { actionMessage = localization.string(for: "errors.generic") phase = .preview isSubmitting = false return } analytics.track("selection_submitted", attributes: baseSelectionAttributes(selectedOutcomeID)) analytics.track("selection_accepted", attributes: baseSelectionAttributes(selectedOutcomeID)) analytics.track("market_locked", attributes: baseSelectionAttributes(selectedOutcomeID).merging(["lock_reason": "manual_selection"]) { _, new in new }) phase = .locked playerCoordinator.pause() transitionTask?.cancel() transitionTask = Task { @MainActor in try? await Task.sleep(nanoseconds: 750_000_000) guard !Task.isCancelled, phase == .locked else { return } phase = .reveal analytics.track("reveal_started", attributes: roundAnalyticsAttributes(round).merging(baseSelectionAttributes(selectedOutcomeID)) { _, new in new }) } } catch { actionMessage = hermesUserFacingErrorMessage(localization: localization, localeCode: localization.localeCode, error: error) ?? localization.string(for: "errors.generic") phase = .preview } isSubmitting = false } } private func showResult() { guard let round = repository.currentRound, let selectedOutcomeID else { return } analytics.track("reveal_completed", attributes: roundAnalyticsAttributes(round).merging(baseSelectionAttributes(selectedOutcomeID)) { _, new in new }) phase = .result analytics.track( "result_viewed", attributes: roundAnalyticsAttributes(round) .merging(baseSelectionAttributes(selectedOutcomeID)) { _, new in new } .merging(["outcome": winningOutcomeTitle(for: round)]) { _, new in new } ) } private func nextRound() { analytics.track("next_round_requested", attributes: ["screen_name": "result"]) transitionTask?.cancel() actionMessage = nil Task { @MainActor in do { _ = try await repository.refreshRoundFromNetwork() } catch { actionMessage = hermesUserFacingErrorMessage(localization: localization, localeCode: localization.localeCode, error: error) ?? localization.string(for: "errors.generic") } } } private func roundLoadingState(title: String, subtitle: String) -> some View { VStack(alignment: .leading, spacing: 8) { Text(title) .font(.headline.weight(.semibold)) .foregroundStyle(HermesTheme.textPrimary) Text(subtitle) .font(.callout) .foregroundStyle(HermesTheme.textSecondary) } } private func roundErrorState(message: String, retryText: String, onRetry: @escaping () -> Void) -> some View { VStack(alignment: .leading, spacing: 12) { Text(message) .font(.callout) .foregroundStyle(HermesTheme.warning) Button { onRetry() } label: { Text(retryText) } .buttonStyle(HermesSecondaryButtonStyle()) } } private func roundBanner(message: String) -> some View { Text(message) .font(.callout) .foregroundStyle(HermesTheme.warning) .padding(.horizontal, 12) .padding(.vertical, 10) .frame(maxWidth: .infinity, alignment: .leading) .background(HermesTheme.warning.opacity(0.12)) .clipShape(RoundedRectangle(cornerRadius: HermesTheme.insetRadius, style: .continuous)) } private func selectionOptions(for round: HermesRound) -> [SelectionOption] { let oddsByOutcomeId = Dictionary(uniqueKeysWithValues: round.oddsVersion.odds.map { ($0.outcomeId, $0) }) return round.market.outcomes .sorted(by: { $0.sortOrder < $1.sortOrder }) .map { outcome in SelectionOption( id: outcome.id.uuidString, title: outcomeTitle(outcome), subtitle: localization.string(for: "round.selection_prompt"), odds: oddsByOutcomeId[outcome.id].map { String(format: "%.2f", $0.decimalOdds) } ?? "--" ) } } private func outcomeTitle(_ outcome: HermesOutcome) -> String { switch outcome.labelKey { case "round.home": return localization.string(for: "round.home") case "round.away": return localization.string(for: "round.away") default: return outcome.outcomeCode.capitalized } } private func selectedOutcomeTitle(for round: HermesRound) -> String { guard let selectedOutcomeID, let outcome = round.market.outcomes.first(where: { $0.id.uuidString == selectedOutcomeID }) else { return localization.string(for: "round.selection_prompt") } return outcomeTitle(outcome) } private func winningOutcomeTitle(for round: HermesRound) -> String { guard let outcome = round.market.outcomes.first(where: { $0.id == round.settlement.winningOutcomeId }) else { return localization.string(for: "round.selection_prompt") } return outcomeTitle(outcome) } private func formatOdds(_ round: HermesRound) -> String { let oddsByOutcomeId = Dictionary(uniqueKeysWithValues: round.oddsVersion.odds.map { ($0.outcomeId, $0) }) return round.market.outcomes .sorted(by: { $0.sortOrder < $1.sortOrder }) .compactMap { oddsByOutcomeId[$0.id]?.decimalOdds } .map { String(format: "%.2f", $0) } .joined(separator: " / ") } private func phaseLabel(isTimerLocked: Bool) -> String { switch phase { case .preview: if isTimerLocked { return localization.string(for: "round.locked_label") } return localization.string(for: "round.preview_label") case .locked: return localization.string(for: "round.locked_label") case .reveal: return localization.string(for: "reveal.title") case .result: return localization.string(for: "result.title") } } private static func countdownText(for remaining: TimeInterval) -> String { let totalSeconds = max(Int(remaining.rounded(.down)), 0) let minutes = totalSeconds / 60 let seconds = totalSeconds % 60 return String(format: "%02d:%02d", minutes, seconds) } private func baseSelectionAttributes(_ outcomeId: String) -> [String: String] { ["screen_name": "round", "outcome_id": outcomeId] } private func roundAnalyticsAttributes(_ round: HermesRound) -> [String: String] { [ "screen_name": "round", "event_id": round.event.id.uuidString, "market_id": round.market.id.uuidString, ] } }