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? @State private var lockAt: Date = .now @State private var actionMessage: String? @State private var isSubmitting = false @State private var swipeFeedback: SwipeFeedback? @State private var dragOffset: CGSize = .zero private enum Phase { case preview case locked case reveal case result } private enum SwipeDirection { case left case right } private struct SwipeFeedback: Equatable { let direction: SwipeDirection let title: String } var body: some View { TimelineView(.periodic(from: Date(), by: 1)) { _ in let round = repository.currentRound let now = repository.serverNow() let activeLockAt = round?.event.lockAt ?? lockAt let remaining = max(activeLockAt.timeIntervalSince(now), 0) let timerLocked = round != nil && remaining <= 0 let bannerMessage = actionMessage ?? hermesUserFacingErrorMessage( localization: localization, localeCode: localization.localeCode, error: repository.errorCause ) ZStack { sideSelectionOverlay(round: round) swipeSurface(round: round, remaining: remaining, timerLocked: timerLocked, bannerMessage: bannerMessage) } .contentShape(Rectangle()) .gesture(swipeGesture(round: round, timerLocked: timerLocked)) .onAppear { if let round { startPreview(round) } } .onChange(of: round?.event.id) { _, newValue in guard newValue != nil, let round else { return } startPreview(round) } .onChange(of: timerLocked) { _, isLocked in guard isLocked, phase == .preview, selectedOutcomeID == nil else { return } phase = .locked playerCoordinator.pause() } .onChange(of: playerCoordinator.playbackCompletionCount) { _, _ in guard phase == .locked, let round, selectedOutcomeID != nil else { return } startReveal(for: round) } } .onDisappear { playerCoordinator.pause() } } private func swipeSurface(round: HermesRound?, remaining: TimeInterval, timerLocked: Bool, bannerMessage: String?) -> some View { ZStack { HermesTheme.surface .ignoresSafeArea() if round != nil { HermesVideoPlayerView(coordinator: playerCoordinator, cornerRadius: 0, fixedHeight: nil) .ignoresSafeArea() } LinearGradient( colors: [.black.opacity(0.72), .black.opacity(0.12), .black.opacity(0.78)], startPoint: .top, endPoint: .bottom ) .ignoresSafeArea() if let feedback = swipeFeedback { swipeFeedbackOverlay(feedback) } VStack(spacing: 0) { header(round: round, remaining: remaining) Spacer() if let bannerMessage { banner(message: bannerMessage) .padding(.horizontal, HermesTheme.screenPadding) .padding(.bottom, 16) } bottomOverlay(round: round, timerLocked: timerLocked) } } .offset(x: dragOffset.width * 0.72) .rotationEffect(.degrees(max(min(Double(dragOffset.width / 40.0), 6), -6))) .shadow(color: .black.opacity(0.28), radius: 22, x: 0, y: 10) } private func header(round: HermesRound?, remaining: TimeInterval) -> some View { HStack(alignment: .top, spacing: 16) { VStack(alignment: .leading, spacing: 10) { Text(promptTitle(for: round)) .font(.system(size: 30, weight: .bold, design: .rounded)) .foregroundStyle(HermesTheme.textPrimary) Text(phaseSubtitle(for: round)) .font(.callout.weight(.medium)) .foregroundStyle(HermesTheme.textSecondary) } Spacer(minLength: 12) SemiCircleCountdownView(progress: progressValue(for: remaining, round: round), label: Self.countdownText(for: remaining)) } .padding(.horizontal, HermesTheme.screenPadding) .padding(.top, 112) } @ViewBuilder private func bottomOverlay(round: HermesRound?, timerLocked: Bool) -> some View { VStack(spacing: 16) { if let round { switch phase { case .preview: EmptyView() case .locked: lockedOverlay(timerLocked: timerLocked) case .reveal: revealOverlay(round: round) case .result: resultOverlay(round: round) } } else if let banner = actionMessage ?? hermesUserFacingErrorMessage(localization: localization, localeCode: localization.localeCode, error: repository.errorCause) { VStack(spacing: 12) { Text(banner) .font(.headline.weight(.semibold)) .foregroundStyle(HermesTheme.textPrimary) Button(localization.string(for: "common.retry"), action: onRetry) .buttonStyle(HermesPrimaryButtonStyle()) } .padding(HermesTheme.contentPadding) .hermesCard(elevated: true) } } .padding(.horizontal, HermesTheme.screenPadding) .padding(.bottom, 28) } private func lockedOverlay(timerLocked: Bool) -> some View { VStack(alignment: .leading, spacing: 10) { Text(localization.string(for: timerLocked ? "round.freeze_title" : "round.locked_label")) .font(.title3.weight(.bold)) .foregroundStyle(HermesTheme.textPrimary) Text(localization.string(for: timerLocked ? "round.freeze_subtitle" : "round.reveal_loading")) .font(.callout) .foregroundStyle(HermesTheme.textSecondary) } .frame(maxWidth: .infinity, alignment: .leading) .padding(HermesTheme.contentPadding) .background(.black.opacity(0.34)) .clipShape(RoundedRectangle(cornerRadius: HermesTheme.cornerRadius, style: .continuous)) } private func revealOverlay(round: HermesRound) -> some View { VStack(alignment: .leading, spacing: 12) { Text(localization.string(for: "reveal.title")) .font(.title3.weight(.bold)) .foregroundStyle(HermesTheme.textPrimary) Text(localization.string(for: "reveal.subtitle")) .font(.callout) .foregroundStyle(HermesTheme.textSecondary) Button(localization.string(for: "reveal.cta"), action: showResult) .buttonStyle(HermesPrimaryButtonStyle()) } .padding(HermesTheme.contentPadding) .background(.black.opacity(0.34)) .clipShape(RoundedRectangle(cornerRadius: HermesTheme.cornerRadius, style: .continuous)) } private func resultOverlay(round: HermesRound) -> some View { VStack(alignment: .leading, spacing: 12) { Text(selectedOutcomeID == round.settlement.winningOutcomeId.uuidString ? localization.string(for: "result.win") : localization.string(for: "result.lose")) .font(.title3.weight(.bold)) .foregroundStyle(HermesTheme.textPrimary) Text(localization.string(for: "result.selection_label") + ": " + selectedOutcomeTitle(for: round)) .font(.callout) .foregroundStyle(HermesTheme.textSecondary) Text(localization.string(for: "result.outcome_label") + ": " + winningOutcomeTitle(for: round)) .font(.callout) .foregroundStyle(HermesTheme.textSecondary) Button(localization.string(for: "result.next_round"), action: nextRound) .buttonStyle(HermesPrimaryButtonStyle()) } .padding(HermesTheme.contentPadding) .background(.black.opacity(0.34)) .clipShape(RoundedRectangle(cornerRadius: HermesTheme.cornerRadius, style: .continuous)) } private func banner(message: String) -> some View { Text(message) .font(.callout.weight(.semibold)) .foregroundStyle(HermesTheme.textPrimary) .padding(.horizontal, 14) .padding(.vertical, 12) .frame(maxWidth: .infinity, alignment: .leading) .background(HermesTheme.warning.opacity(0.24)) .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) } private func swipeFeedbackOverlay(_ feedback: SwipeFeedback) -> some View { let color = feedback.direction == .right ? HermesTheme.positive : .red return VStack { Spacer() Text(feedback.title) .font(.system(size: 38, weight: .black, design: .rounded)) .foregroundStyle(.white) .padding(.horizontal, 24) .padding(.vertical, 18) .background(color.opacity(0.88)) .clipShape(Capsule()) Spacer() } .frame(maxWidth: .infinity) } @ViewBuilder private func sideSelectionOverlay(round: HermesRound?) -> some View { if let round, phase == .preview { let dragThreshold: CGFloat = 10 let isDraggingRight = dragOffset.width > dragThreshold let isDraggingLeft = dragOffset.width < -dragThreshold HStack(spacing: 0) { sideTint( title: sortedOutcomes(for: round).dropFirst().first.map(outcomeTitle) ?? localization.string(for: "round.no"), subtitle: localization.string(for: "round.swipe_left"), color: .red, opacity: isDraggingLeft ? min(abs(dragOffset.width) / 240.0, 0.95) : 0 ) sideTint( title: sortedOutcomes(for: round).first.map(outcomeTitle) ?? localization.string(for: "round.yes"), subtitle: localization.string(for: "round.swipe_right"), color: HermesTheme.positive, opacity: isDraggingRight ? min(abs(dragOffset.width) / 240.0, 0.95) : 0 ) } .ignoresSafeArea() .allowsHitTesting(false) } else { EmptyView() } } private func sideTint(title: String, subtitle: String, color: Color, opacity: Double) -> some View { ZStack(alignment: .center) { color.opacity(opacity) if opacity > 0.05 { VStack(spacing: 8) { Text(title) .font(.system(size: 30, weight: .black, design: .rounded)) .foregroundStyle(.white) Text(subtitle) .font(.caption.weight(.bold)) .foregroundStyle(.white.opacity(0.88)) } .padding(24) } } .frame(maxWidth: .infinity) } private func swipeGesture(round: HermesRound?, timerLocked: Bool) -> some Gesture { DragGesture(minimumDistance: 12) .onChanged { value in dragOffset = value.translation } .onEnded { value in defer { dragOffset = .zero } guard let round, phase == .preview, !timerLocked, !isSubmitting else { swipeFeedback = nil return } guard abs(value.translation.width) > 145, abs(value.translation.width) > abs(value.translation.height) else { swipeFeedback = nil return } handleSwipe(value.translation.width > 0 ? .right : .left, round: round) } } private func handleSwipe(_ direction: SwipeDirection, round: HermesRound) { let outcomes = sortedOutcomes(for: round) let outcome = direction == .right ? outcomes.first : outcomes.dropFirst().first guard let outcome else { return } swipeFeedback = SwipeFeedback(direction: direction, title: outcomeTitle(outcome)) handleSelection(outcome.id.uuidString) confirmSelection() Task { @MainActor in try? await Task.sleep(nanoseconds: 700_000_000) if swipeFeedback?.title == outcomeTitle(outcome) { swipeFeedback = nil } } } private func startPreview(_ round: HermesRound) { phase = .preview selectedOutcomeID = nil actionMessage = nil isSubmitting = false swipeFeedback = nil 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(_ outcomeID: String) { guard phase == .preview, !isSubmitting else { return } selectedOutcomeID = outcomeID analytics.track("outcome_focused", attributes: ["screen_name": "round", "outcome_id": outcomeID]) analytics.track("outcome_selected", attributes: ["screen_name": "round", "outcome_id": outcomeID]) } private func confirmSelection() { guard phase == .preview, !isSubmitting, let round = repository.currentRound else { return } guard let selectedOutcomeID else { actionMessage = localization.string(for: "errors.session_expired") return } if isDemoMode { analytics.track("selection_submitted", attributes: baseSelectionAttributes(selectedOutcomeID)) analytics.track("selection_accepted", attributes: baseSelectionAttributes(selectedOutcomeID)) analytics.track("market_locked", attributes: baseSelectionAttributes(selectedOutcomeID).merging(["lock_reason": "swipe_selection"]) { _, new in new }) beginRevealTransition(for: round, selectedOutcomeID: selectedOutcomeID) return } guard let session = repository.currentSession else { actionMessage = localization.string(for: "errors.session_expired") return } guard let outcomeID = UUID(uuidString: selectedOutcomeID) ?? sortedOutcomes(for: round).first?.id else { actionMessage = localization.string(for: "errors.generic") return } if repository.serverNow() >= round.event.lockAt { phase = .locked playerCoordinator.pause() 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": "swipe_selection"]) { _, new in new }) beginRevealTransition(for: round, selectedOutcomeID: selectedOutcomeID) } catch { actionMessage = hermesUserFacingErrorMessage(localization: localization, localeCode: localization.localeCode, error: error) ?? localization.string(for: "errors.generic") phase = .preview } isSubmitting = false } } private func beginRevealTransition(for round: HermesRound, selectedOutcomeID: String) { phase = .locked playerCoordinator.play(rate: 2.0) analytics.track("round_accelerated", attributes: roundAnalyticsAttributes(round).merging(baseSelectionAttributes(selectedOutcomeID)) { _, new in new }) } private func startReveal(for round: HermesRound) { guard let selectedOutcomeID else { return } phase = .reveal playerCoordinator.play(url: revealMediaURL(for: round), startTimeMs: revealStartTimeMs(for: round), rate: 1.0) analytics.track("reveal_started", attributes: roundAnalyticsAttributes(round).merging(baseSelectionAttributes(selectedOutcomeID)) { _, new in new }) } 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 playerCoordinator.pause() } private func nextRound() { analytics.track("next_round_requested", attributes: ["screen_name": "result"]) actionMessage = nil Task { @MainActor in do { if repository.currentSession?.deviceModel == "Demo Device" { _ = try await repository.refreshMockRound() } else { _ = try await repository.refreshRoundFromNetwork() } } catch { actionMessage = hermesUserFacingErrorMessage(localization: localization, localeCode: localization.localeCode, error: error) ?? localization.string(for: "errors.generic") } } } private func sortedOutcomes(for round: HermesRound) -> [HermesOutcome] { round.market.outcomes.sorted(by: { $0.sortOrder < $1.sortOrder }) } private func revealMediaURL(for round: HermesRound) -> URL { repository.currentSession?.deviceModel == "Demo Device" ? MockHermesData.revealMediaURL() : round.media.hlsMasterUrl } private var isDemoMode: Bool { repository.currentSession?.deviceModel == "Demo Device" } private func revealStartTimeMs(for round: HermesRound) -> Int { repository.currentSession?.deviceModel == "Demo Device" ? 0 : round.media.revealStartMs } private func promptTitle(for round: HermesRound?) -> String { guard let round else { return localization.string(for: "common.loading") } let localizedQuestion = localization.string(for: round.market.questionKey) if localizedQuestion != round.market.questionKey { return localizedQuestion } return localization.localeCode == "sv" ? round.event.titleSv : round.event.titleEn } private func phaseSubtitle(for round: HermesRound?) -> String { switch phase { case .preview: return localization.string(for: "round.selection_prompt") case .locked: return localization.string(for: "round.reveal_loading") case .reveal: return localization.string(for: "reveal.subtitle") case .result: return round.map { winningOutcomeTitle(for: $0) } ?? localization.string(for: "common.loading") } } 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") case "round.yes": return localization.string(for: "round.yes") case "round.no": return localization.string(for: "round.no") 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 progressValue(for remaining: TimeInterval, round: HermesRound?) -> Double { let totalDuration = round.map { Double(max($0.media.previewEndMs - $0.media.previewStartMs, 1)) / 1_000.0 } ?? 15.0 return min(max(remaining / totalDuration, 0), 1) } 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, ] } } private struct SemiCircleCountdownView: View { let progress: Double let label: String var body: some View { ZStack { SemiCircleShape() .stroke(.white.opacity(0.18), style: StrokeStyle(lineWidth: 10, lineCap: .round)) SemiCircleShape() .trim(from: 0, to: progress) .stroke(HermesTheme.accent, style: StrokeStyle(lineWidth: 10, lineCap: .round)) Text(label) .font(.caption.weight(.bold)) .foregroundStyle(.white) .padding(.top, 22) } .frame(width: 92, height: 56) } } private struct SemiCircleShape: Shape { func path(in rect: CGRect) -> Path { var path = Path() path.addArc( center: CGPoint(x: rect.midX, y: rect.maxY), radius: rect.width / 2, startAngle: .degrees(180), endAngle: .degrees(0), clockwise: false ) return path } }