diff --git a/PLAN.md b/PLAN.md index 22889e9..d4b2454 100644 --- a/PLAN.md +++ b/PLAN.md @@ -25,12 +25,14 @@ This is the canonical working plan and progress log for the project. Use this fi - iOS sources and tests build and pass in Xcode on an iPhone simulator, and a standalone localization/error-mapping harness passes. - Android local unit tests now cover localization, error mapping, and analytics batch conversion, and `./gradlew testDebugUnitTest` passes with the Android SDK installed. - Android swipe-down gesture handling now uses cumulative drag distance, and edge-case tests cover upward drags, slow swipes, and per-gesture single-trigger behavior. +- iOS now defaults to a demo test mode with mock rounds, and the primary round flow is a fullscreen swipe-driven video experience with a 15-second lock timer and accelerated reveal transition. ### Still Open - Continue through the remaining plan phases and finish any leftover localization and polish work. - Keep the iOS project in sync with the plan as the app grows. - Keep expanding tests around session, odds, settlement, and analytics behavior. +- Align Android with the clarified fullscreen swipe flow and demo-mode testing path. ## 1. Purpose diff --git a/contracts/localization/en.json b/contracts/localization/en.json index f5f7f97..7d97d96 100644 --- a/contracts/localization/en.json +++ b/contracts/localization/en.json @@ -23,6 +23,13 @@ "round.selection_confirmed": "Selection accepted", "round.selection_submitting": "Submitting selection", "round.odds_label": "Odds", + "round.freeze_title": "Time is up", + "round.freeze_subtitle": "The clip is frozen. Wait for the showdown.", + "round.reveal_loading": "Fast-forwarding to the showdown.", + "round.yes": "Yes", + "round.no": "No", + "round.swipe_left": "Swipe left", + "round.swipe_right": "Swipe right", "reveal.title": "Reveal", "reveal.subtitle": "See what happened next.", "result.title": "Result", @@ -35,6 +42,11 @@ "settings.enabled": "Enabled", "settings.haptics": "Haptics", "settings.analytics": "Analytics", + "mode.demo": "Demo", + "mode.live": "Live", + "event.question.score": "Will he score?", + "event.question.save": "Will the keeper save it?", + "event.question.convert": "Will they convert this chance?", "errors.generic": "Please try again.", "errors.network": "Network error. Check your connection.", "errors.playback": "Video playback failed.", diff --git a/contracts/localization/sv.json b/contracts/localization/sv.json index bd760a0..cd07236 100644 --- a/contracts/localization/sv.json +++ b/contracts/localization/sv.json @@ -23,6 +23,13 @@ "round.selection_confirmed": "Valet accepterat", "round.selection_submitting": "Skickar val", "round.odds_label": "Odds", + "round.freeze_title": "Tiden är ute", + "round.freeze_subtitle": "Klippet fryser nu. Vänta på avgörandet.", + "round.reveal_loading": "Snabbspolar till avgörandet.", + "round.yes": "Ja", + "round.no": "Nej", + "round.swipe_left": "Svajpa vänster", + "round.swipe_right": "Svajpa höger", "reveal.title": "Avslöjande", "reveal.subtitle": "Se vad som hände sedan.", "result.title": "Resultat", @@ -35,6 +42,11 @@ "settings.enabled": "Aktiverad", "settings.haptics": "Haptik", "settings.analytics": "Analys", + "mode.demo": "Demo", + "mode.live": "Live", + "event.question.score": "Gör han mål?", + "event.question.save": "Räddar målvakten?", + "event.question.convert": "Tar de vara på chansen?", "errors.generic": "Försök igen.", "errors.network": "Nätverksfel. Kontrollera anslutningen.", "errors.playback": "Videouppspelningen misslyckades.", diff --git a/docs/localization-catalog.md b/docs/localization-catalog.md index d2228cc..d2278d1 100644 --- a/docs/localization-catalog.md +++ b/docs/localization-catalog.md @@ -36,6 +36,13 @@ | `round.selection_confirmed` | Selection accepted | | `round.selection_submitting` | Selection submitting | | `round.odds_label` | Odds label | +| `round.freeze_title` | Timer expired title | +| `round.freeze_subtitle` | Timer expired subtitle | +| `round.reveal_loading` | Reveal transition copy | +| `round.yes` | Yes outcome | +| `round.no` | No outcome | +| `round.swipe_left` | Swipe left hint | +| `round.swipe_right` | Swipe right hint | | `reveal.title` | Reveal title | | `reveal.subtitle` | Reveal subtitle | | `result.title` | Result title | @@ -48,6 +55,11 @@ | `settings.enabled` | Enabled status | | `settings.haptics` | Haptics setting | | `settings.analytics` | Analytics setting | +| `mode.demo` | Demo mode label | +| `mode.live` | Live mode label | +| `event.question.score` | Score event prompt | +| `event.question.save` | Save event prompt | +| `event.question.convert` | Convert event prompt | | `errors.generic` | Generic error copy | | `errors.network` | Network error copy | | `errors.playback` | Playback error copy | diff --git a/mobile/ios-app/App/HermesApp.swift b/mobile/ios-app/App/HermesApp.swift index 52ad70b..9293e8f 100644 --- a/mobile/ios-app/App/HermesApp.swift +++ b/mobile/ios-app/App/HermesApp.swift @@ -12,43 +12,67 @@ struct HermesApp: App { @StateObject private var analytics = HermesAnalyticsClient() @StateObject private var playerCoordinator = PlayerCoordinator() @State private var isBootstrapping = false + @State private var appMode: HermesAppMode = .demo var body: some Scene { WindowGroup { - 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 + RootView( + mode: appMode, + onModeSelected: { selectedMode in + guard appMode != selectedMode else { + return } - 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]) + appMode = selectedMode + repository.reset() + }, + onStartSession: { localeCode, selectedMode in + if selectedMode == .demo { + Task { @MainActor in + repository.reset() + await repository.bootstrapMock( + localeCode: localeCode, + appVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.1.0" + ) + analytics.track("session_started", attributes: ["screen_name": "session", "locale_code": localeCode, "mode": selectedMode.rawValue]) + } + return + } + + 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, "mode": selectedMode.rawValue]) + + Task { @MainActor in + defer { + isBootstrapping = false + } + + do { + _ = try await repository.bootstrap(request) + analytics.track("session_started", attributes: ["screen_name": "session", "locale_code": localeCode, "mode": selectedMode.rawValue]) + await analytics.flush(using: repository) + } catch { + analytics.track("session_start_failed", attributes: ["screen_name": "session", "locale_code": localeCode, "mode": selectedMode.rawValue]) + } } } - }) + ) .preferredColorScheme(.dark) .tint(HermesTheme.accent) .environmentObject(analytics) diff --git a/mobile/ios-app/App/HermesRepository.swift b/mobile/ios-app/App/HermesRepository.swift index d4a5af0..1a2192a 100644 --- a/mobile/ios-app/App/HermesRepository.swift +++ b/mobile/ios-app/App/HermesRepository.swift @@ -10,11 +10,40 @@ final class HermesRepository: ObservableObject { @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 @@ -143,3 +172,151 @@ final class HermesRepository: ObservableObject { ) } } + +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) + } +} diff --git a/mobile/ios-app/App/RootView.swift b/mobile/ios-app/App/RootView.swift index f263962..32d24b1 100644 --- a/mobile/ios-app/App/RootView.swift +++ b/mobile/ios-app/App/RootView.swift @@ -1,37 +1,36 @@ import SwiftUI +enum HermesAppMode: String { + case demo + case live +} + struct RootView: View { @StateObject private var localization = LocalizationStore() @EnvironmentObject private var analytics: HermesAnalyticsClient @EnvironmentObject private var repository: HermesRepository - let onStartSession: (String) -> Void + let mode: HermesAppMode + let onModeSelected: (HermesAppMode) -> Void + let onStartSession: (String, HermesAppMode) -> Void var body: some View { - NavigationStack { - ScrollView { - VStack(alignment: .leading, spacing: 24) { - header - OnboardingView(onStartSession: { onStartSession(localization.localeCode) }) - FeedView(onRetry: { onStartSession(localization.localeCode) }) - RoundView(onRetry: { onStartSession(localization.localeCode) }) - SessionView(onRetry: { onStartSession(localization.localeCode) }) - SettingsView() - } - .padding(.horizontal, HermesTheme.screenPadding) - .padding(.vertical, 24) - } - .background(HermesTheme.background.ignoresSafeArea()) - .navigationTitle(localization.string(for: "app.name")) - .navigationBarTitleDisplayMode(.inline) + ZStack(alignment: .top) { + HermesTheme.background + .ignoresSafeArea() + + RoundView(onRetry: { onStartSession(localization.localeCode, mode) }) + .ignoresSafeArea() + + topChrome } .environmentObject(localization) .onAppear { - analytics.track("app_opened", attributes: ["screen_name": "home"]) - analytics.track("screen_viewed", attributes: ["screen_name": "home"]) + analytics.track("app_opened", attributes: ["screen_name": "round", "mode": mode.rawValue]) + analytics.track("screen_viewed", attributes: ["screen_name": "round", "mode": mode.rawValue]) } - .task(id: localization.localeCode) { - onStartSession(localization.localeCode) + .task(id: "\(localization.localeCode)-\(mode.rawValue)") { + onStartSession(localization.localeCode, mode) } .task { while !Task.isCancelled { @@ -41,20 +40,32 @@ struct RootView: View { } } - private var header: some View { - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 8) { + private var topChrome: some View { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { Text(localization.string(for: "app.name")) - .font(.largeTitle.bold()) + .font(.headline.weight(.bold)) .foregroundStyle(HermesTheme.textPrimary) - Text(localization.string(for: "app.subtitle")) - .font(.callout) + Text(mode == .demo ? localization.string(for: "mode.demo" ) : localization.string(for: "mode.live")) + .font(.caption.weight(.semibold)) .foregroundStyle(HermesTheme.textSecondary) } Spacer(minLength: 12) - localeToggle + VStack(alignment: .trailing, spacing: 10) { + modeToggle + localeToggle + } + } + .padding(.horizontal, HermesTheme.screenPadding) + .padding(.top, 16) + } + + private var modeToggle: some View { + HStack(spacing: 8) { + modeButton(title: localization.string(for: "mode.demo"), appMode: .demo) + modeButton(title: localization.string(for: "mode.live"), appMode: .live) } } @@ -65,6 +76,23 @@ struct RootView: View { } } + private func modeButton(title: String, appMode: HermesAppMode) -> some View { + let isSelected = mode == appMode + + return Button { + onModeSelected(appMode) + analytics.track("mode_changed", attributes: ["mode": appMode.rawValue]) + } label: { + Text(title) + .font(.caption.weight(.bold)) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .foregroundStyle(isSelected ? HermesTheme.background : HermesTheme.textPrimary) + .background(isSelected ? HermesTheme.accent : HermesTheme.surfaceElevated.opacity(0.9)) + .clipShape(Capsule()) + } + } + private func localeButton(title: String, localeCode: String) -> some View { let isSelected = localization.localeCode == localeCode @@ -77,7 +105,7 @@ struct RootView: View { .padding(.horizontal, 12) .padding(.vertical, 8) .foregroundStyle(isSelected ? HermesTheme.background : HermesTheme.textPrimary) - .background(isSelected ? HermesTheme.accent : HermesTheme.surfaceElevated) + .background(isSelected ? HermesTheme.accent : HermesTheme.surfaceElevated.opacity(0.9)) .clipShape(Capsule()) } } diff --git a/mobile/ios-app/Core/Media/HermesVideoPlayerView.swift b/mobile/ios-app/Core/Media/HermesVideoPlayerView.swift index 95cab70..0d3f110 100644 --- a/mobile/ios-app/Core/Media/HermesVideoPlayerView.swift +++ b/mobile/ios-app/Core/Media/HermesVideoPlayerView.swift @@ -3,14 +3,16 @@ import SwiftUI struct HermesVideoPlayerView: View { @ObservedObject var coordinator: PlayerCoordinator + var cornerRadius: CGFloat = HermesTheme.cornerRadius + var fixedHeight: CGFloat? = 224 var body: some View { VideoPlayer(player: coordinator.player) .frame(maxWidth: .infinity) - .frame(height: 224) - .clipShape(RoundedRectangle(cornerRadius: HermesTheme.cornerRadius, style: .continuous)) + .frame(height: fixedHeight) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) .overlay( - RoundedRectangle(cornerRadius: HermesTheme.cornerRadius, style: .continuous) + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .stroke(HermesTheme.accent.opacity(0.12), lineWidth: 1) ) .background(HermesTheme.surfaceElevated) diff --git a/mobile/ios-app/Core/Media/PlayerCoordinator.swift b/mobile/ios-app/Core/Media/PlayerCoordinator.swift index 8ab2801..cf7b2b0 100644 --- a/mobile/ios-app/Core/Media/PlayerCoordinator.swift +++ b/mobile/ios-app/Core/Media/PlayerCoordinator.swift @@ -27,11 +27,26 @@ final class PlayerCoordinator: ObservableObject { isPlaying = true } + func play(rate: Float) { + player.playImmediately(atRate: rate) + isPlaying = true + } + func pause() { player.pause() isPlaying = false } + func seek(to timeMs: Int) { + let time = CMTime(seconds: Double(timeMs) / 1_000.0, preferredTimescale: 1_000) + player.seek(to: time) + } + + func play(url: URL, startTimeMs: Int, rate: Float) { + prepareForPreview(url: url, startTimeMs: startTimeMs) + play(rate: rate) + } + func restart(url: URL, startTimeMs: Int = 0) { prepareForPreview(url: url, startTimeMs: startTimeMs) } diff --git a/mobile/ios-app/Features/Round/RoundView.swift b/mobile/ios-app/Features/Round/RoundView.swift index dc0477e..1273c2a 100644 --- a/mobile/ios-app/Features/Round/RoundView.swift +++ b/mobile/ios-app/Features/Round/RoundView.swift @@ -9,11 +9,13 @@ struct RoundView: View { let onRetry: () -> Void @State private var phase: Phase = .preview - @State private var selectedOutcomeID: String? = nil + @State private var selectedOutcomeID: String? @State private var lockAt: Date = .now @State private var transitionTask: Task? @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 @@ -22,93 +24,62 @@ struct RoundView: View { 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 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") + 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 !hasRound { + if let feedback = swipeFeedback { + swipeFeedbackOverlay(feedback) + } + + VStack(spacing: 0) { + header(round: round, remaining: remaining) + Spacer() 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 - ) - } + banner(message: bannerMessage) + .padding(.horizontal, HermesTheme.screenPadding) + .padding(.bottom, 16) } + bottomOverlay(round: round, timerLocked: timerLocked) } } + .offset(x: dragOffset.width * 0.12) + .contentShape(Rectangle()) + .gesture(swipeGesture(round: round, timerLocked: timerLocked)) .onAppear { if let round { startPreview(round) @@ -118,68 +89,222 @@ struct RoundView: View { 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() + } } - .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)) + 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) } - if let round { - VStack(alignment: .trailing, spacing: 10) { - HermesCountdownBadge( - label: localization.string(for: "round.countdown_label"), - value: countdownText, - warning: !isTimerLocked && remaining <= 10 - ) + Spacer(minLength: 12) - 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) + SemiCircleCountdownView(progress: progressValue(for: remaining), 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: + swipeHintRow(round: round) + 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 swipeHintRow(round: HermesRound) -> some View { + let leftOutcome = sortedOutcomes(for: round).dropFirst().first + let rightOutcome = sortedOutcomes(for: round).first + + return HStack(spacing: 12) { + swipeEdgeLabel(title: leftOutcome.map(outcomeTitle) ?? localization.string(for: "round.no"), alignment: .leading, color: .red) + swipeEdgeLabel(title: rightOutcome.map(outcomeTitle) ?? localization.string(for: "round.yes"), alignment: .trailing, color: HermesTheme.positive) + } + } + + private func swipeEdgeLabel(title: String, alignment: HorizontalAlignment, color: Color) -> some View { + VStack(alignment: alignment, spacing: 6) { + Text(title) + .font(.title3.weight(.bold)) + .foregroundStyle(.white) + Text(alignment == .leading ? localization.string(for: "round.swipe_left" ) : localization.string(for: "round.swipe_right")) + .font(.caption.weight(.semibold)) + .foregroundStyle(.white.opacity(0.72)) + } + .frame(maxWidth: .infinity, alignment: alignment == .leading ? .leading : .trailing) + .padding(.horizontal, 18) + .padding(.vertical, 14) + .background(color.opacity(0.18)) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + } + + 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) + } + + private func swipeGesture(round: HermesRound?, timerLocked: Bool) -> some Gesture { + DragGesture(minimumDistance: 24) + .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) > 72, 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) { transitionTask?.cancel() phase = .preview selectedOutcomeID = nil actionMessage = nil isSubmitting = false + swipeFeedback = nil lockAt = round.event.lockAt playerCoordinator.prepareForPreview(url: round.media.hlsMasterUrl, startTimeMs: round.media.previewStartMs) @@ -188,14 +313,14 @@ struct RoundView: View { analytics.track("screen_viewed", attributes: ["screen_name": "round"]) } - private func handleSelection(_ option: SelectionOption) { + private func handleSelection(_ outcomeID: String) { 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]) + 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() { @@ -208,12 +333,14 @@ struct RoundView: View { return } - guard let outcomeID = UUID(uuidString: selectedOutcomeID) ?? round.market.outcomes.first?.id else { + 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 } @@ -242,19 +369,20 @@ struct RoundView: View { 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 }) + analytics.track("market_locked", attributes: baseSelectionAttributes(selectedOutcomeID).merging(["lock_reason": "swipe_selection"]) { _, new in new }) phase = .locked - playerCoordinator.pause() + playerCoordinator.play(rate: 2.0) transitionTask?.cancel() transitionTask = Task { @MainActor in - try? await Task.sleep(nanoseconds: 750_000_000) - guard !Task.isCancelled, phase == .locked else { + try? await Task.sleep(nanoseconds: 1_100_000_000) + guard !Task.isCancelled else { return } phase = .reveal + playerCoordinator.play(url: round.media.hlsMasterUrl, startTimeMs: round.media.revealStartMs, rate: 1.0) analytics.track("reveal_started", attributes: roundAnalyticsAttributes(round).merging(baseSelectionAttributes(selectedOutcomeID)) { _, new in new }) } } catch { @@ -273,12 +401,7 @@ struct RoundView: View { 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 } - ) + playerCoordinator.pause() } private func nextRound() { @@ -288,62 +411,45 @@ struct RoundView: View { Task { @MainActor in do { - _ = try await repository.refreshRoundFromNetwork() + 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 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 sortedOutcomes(for round: HermesRound) -> [HermesOutcome] { + round.market.outcomes.sorted(by: { $0.sortOrder < $1.sortOrder }) + } + + private func promptTitle(for round: HermesRound?) -> String { + guard let round else { + return localization.string(for: "common.loading") } - } - 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()) + 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 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 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 { @@ -352,6 +458,10 @@ struct RoundView: View { 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 } @@ -374,29 +484,8 @@ struct RoundView: View { 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 func progressValue(for remaining: TimeInterval) -> Double { + min(max(remaining / 15.0, 0), 1) } private static func countdownText(for remaining: TimeInterval) -> String { @@ -418,3 +507,37 @@ struct RoundView: View { ] } } + +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 + } +} diff --git a/mobile/ios-app/Resources/Localization/en.lproj/Localizable.strings b/mobile/ios-app/Resources/Localization/en.lproj/Localizable.strings index bb6e6ec..2a9e83b 100644 --- a/mobile/ios-app/Resources/Localization/en.lproj/Localizable.strings +++ b/mobile/ios-app/Resources/Localization/en.lproj/Localizable.strings @@ -29,8 +29,15 @@ "round.selection_prompt" = "Choose an outcome."; "round.primary_cta" = "Confirm selection"; "round.locked_label" = "Locked"; +"round.freeze_title" = "Time is up"; +"round.freeze_subtitle" = "The clip is frozen. Wait for the showdown."; +"round.reveal_loading" = "Fast-forwarding to the showdown."; "round.home" = "Home"; "round.away" = "Away"; +"round.yes" = "Yes"; +"round.no" = "No"; +"round.swipe_left" = "Swipe left"; +"round.swipe_right" = "Swipe right"; "reveal.title" = "Reveal"; "reveal.subtitle" = "The clip is now showing the outcome."; "reveal.status" = "Reveal segment is playing."; @@ -62,3 +69,8 @@ "settings.enabled" = "Enabled"; "settings.haptics" = "Haptics"; "settings.analytics" = "Analytics"; +"mode.demo" = "Demo"; +"mode.live" = "Live"; +"event.question.score" = "Will he score?"; +"event.question.save" = "Will the keeper save it?"; +"event.question.convert" = "Will they convert this chance?"; diff --git a/mobile/ios-app/Resources/Localization/sv.lproj/Localizable.strings b/mobile/ios-app/Resources/Localization/sv.lproj/Localizable.strings index a0d258f..6fa8099 100644 --- a/mobile/ios-app/Resources/Localization/sv.lproj/Localizable.strings +++ b/mobile/ios-app/Resources/Localization/sv.lproj/Localizable.strings @@ -29,8 +29,15 @@ "round.selection_prompt" = "Välj ett utfall."; "round.primary_cta" = "Bekräfta valet"; "round.locked_label" = "Låst"; +"round.freeze_title" = "Tiden är ute"; +"round.freeze_subtitle" = "Klippet fryser nu. Vänta på avgörandet."; +"round.reveal_loading" = "Snabbspolar till avgörandet."; "round.home" = "Hemma"; "round.away" = "Borta"; +"round.yes" = "Ja"; +"round.no" = "Nej"; +"round.swipe_left" = "Svajpa vänster"; +"round.swipe_right" = "Svajpa höger"; "reveal.title" = "Avslöjande"; "reveal.subtitle" = "Klippet visar nu utfallet."; "reveal.status" = "Avslöjningssegmentet spelas upp."; @@ -62,3 +69,8 @@ "settings.enabled" = "Aktiverad"; "settings.haptics" = "Haptik"; "settings.analytics" = "Analys"; +"mode.demo" = "Demo"; +"mode.live" = "Live"; +"event.question.score" = "Gör han mål?"; +"event.question.save" = "Räddar målvakten?"; +"event.question.convert" = "Tar de vara på chansen?";