diff --git a/docs/localization-catalog.md b/docs/localization-catalog.md index 364918e..8f6ec44 100644 --- a/docs/localization-catalog.md +++ b/docs/localization-catalog.md @@ -30,18 +30,41 @@ | `feed.next_round_body` | Next round body | | `feed.watch_preview` | Watch preview CTA | | `feed.round_ready` | Round ready label | +| `feed.title` | Feed title | +| `feed.subtitle` | Feed subtitle | +| `feed.hero_title` | Feed hero title | +| `feed.hero_subtitle` | Feed hero subtitle | +| `feed.lock_label` | Feed lock label | +| `feed.odds_label` | Feed odds label | +| `feed.cta` | Feed CTA | | `round.countdown_label` | Countdown label | | `round.locked_label` | Locked label | | `round.selection_prompt` | Selection prompt | | `round.selection_confirmed` | Selection accepted | | `round.selection_submitting` | Selection submitting | | `round.odds_label` | Odds label | +| `round.title` | Round title | +| `round.subtitle` | Round subtitle | +| `round.video_placeholder` | Video placeholder | +| `round.preview_label` | Preview label | +| `round.primary_cta` | Round confirm CTA | +| `round.home` | Home outcome label | +| `round.away` | Away outcome label | | `reveal.title` | Reveal title | | `reveal.subtitle` | Reveal subtitle | +| `reveal.status` | Reveal status | +| `reveal.cta` | Reveal CTA | | `result.title` | Result title | -| `result.user_selection` | User selection label | -| `result.outcome` | Outcome label | +| `result.selection_label` | Result selection label | +| `result.outcome_label` | Result outcome label | +| `result.win` | Winning result label | +| `result.lose` | Losing result label | | `result.next_round` | Next round CTA | +| `result.subtitle` | Result subtitle | +| `result.selection_label` | Result selection label | +| `result.outcome_label` | Result outcome label | +| `result.win` | Winning result label | +| `result.lose` | Losing result label | | `settings.title` | Settings title | | `settings.language` | Language setting | | `settings.haptics` | Haptics setting | diff --git a/mobile/ios-app/App/HermesApp.swift b/mobile/ios-app/App/HermesApp.swift index f355743..6d881be 100644 --- a/mobile/ios-app/App/HermesApp.swift +++ b/mobile/ios-app/App/HermesApp.swift @@ -2,11 +2,14 @@ import SwiftUI @main struct HermesApp: App { + @StateObject private var analytics = HermesAnalyticsClient() + var body: some Scene { WindowGroup { RootView() .preferredColorScheme(.dark) .tint(HermesTheme.accent) + .environmentObject(analytics) } } } diff --git a/mobile/ios-app/App/RootView.swift b/mobile/ios-app/App/RootView.swift index e6b4b3f..65d173c 100644 --- a/mobile/ios-app/App/RootView.swift +++ b/mobile/ios-app/App/RootView.swift @@ -2,6 +2,7 @@ import SwiftUI struct RootView: View { @StateObject private var localization = LocalizationStore() + @EnvironmentObject private var analytics: HermesAnalyticsClient var body: some View { NavigationStack { @@ -20,6 +21,10 @@ struct RootView: View { .navigationBarTitleDisplayMode(.inline) } .environmentObject(localization) + .onAppear { + analytics.track("app_opened", attributes: ["screen_name": "home"]) + analytics.track("screen_viewed", attributes: ["screen_name": "home"]) + } } private var header: some View { @@ -51,6 +56,7 @@ struct RootView: View { return Button { localization.setLocale(localeCode) + analytics.track("locale_changed", attributes: ["locale_code": localeCode]) } label: { Text(title) .font(.caption.weight(.bold)) diff --git a/mobile/ios-app/Core/Analytics/AnalyticsClient.swift b/mobile/ios-app/Core/Analytics/AnalyticsClient.swift index d2b51c4..03b10f5 100644 --- a/mobile/ios-app/Core/Analytics/AnalyticsClient.swift +++ b/mobile/ios-app/Core/Analytics/AnalyticsClient.swift @@ -1,13 +1,28 @@ +import Combine import Foundation protocol AnalyticsTracking { func track(_ event: String, attributes: [String: String]) } -final class HermesAnalyticsClient: AnalyticsTracking { +struct HermesTrackedEvent: Identifiable, Equatable { + let id = UUID() + let event: String + let attributes: [String: String] + let timestamp: Date +} + +@MainActor +final class HermesAnalyticsClient: ObservableObject, AnalyticsTracking { + @Published private(set) var trackedEvents: [HermesTrackedEvent] = [] + func track(_ event: String, attributes: [String: String]) { - // Scaffold implementation. - _ = event - _ = attributes + trackedEvents.append( + HermesTrackedEvent( + event: event, + attributes: attributes, + timestamp: Date() + ) + ) } } diff --git a/mobile/ios-app/Core/DesignSystem/Theme.swift b/mobile/ios-app/Core/DesignSystem/Theme.swift index eb13ae8..5a34da8 100644 --- a/mobile/ios-app/Core/DesignSystem/Theme.swift +++ b/mobile/ios-app/Core/DesignSystem/Theme.swift @@ -115,6 +115,7 @@ struct HermesMetricPill: View { struct HermesCountdownBadge: View { let label: String let value: String + var warning: Bool = false var body: some View { VStack(alignment: .leading, spacing: 4) { @@ -123,11 +124,11 @@ struct HermesCountdownBadge: View { .foregroundStyle(HermesTheme.textTertiary) Text(value) .font(.title3.weight(.bold)) - .foregroundStyle(HermesTheme.accent) + .foregroundStyle(warning ? HermesTheme.warning : HermesTheme.accent) } .padding(.horizontal, 14) .padding(.vertical, 12) - .background(HermesTheme.accentSoft) + .background((warning ? HermesTheme.warning : HermesTheme.accent).opacity(0.16)) .clipShape(RoundedRectangle(cornerRadius: HermesTheme.insetRadius, style: .continuous)) } } diff --git a/mobile/ios-app/Core/Media/PlayerCoordinator.swift b/mobile/ios-app/Core/Media/PlayerCoordinator.swift index a529352..d6ca520 100644 --- a/mobile/ios-app/Core/Media/PlayerCoordinator.swift +++ b/mobile/ios-app/Core/Media/PlayerCoordinator.swift @@ -1,7 +1,37 @@ +import AVFoundation import Combine import Foundation +@MainActor final class PlayerCoordinator: ObservableObject { + let player: AVPlayer + @Published var isPlaying = false @Published var playbackPositionMs: Int = 0 + + init(previewURL: URL = URL(string: "https://cdn.example.com/hermes/sample-event/master.m3u8")!) { + self.player = AVPlayer(url: previewURL) + self.player.actionAtItemEnd = .pause + } + + func prepareForPreview() { + player.seek(to: .zero) + player.play() + isPlaying = true + } + + func play() { + player.play() + isPlaying = true + } + + func pause() { + player.pause() + isPlaying = false + } + + func restart() { + player.seek(to: .zero) + play() + } } diff --git a/mobile/ios-app/Core/Media/StudyVideoPlayerView.swift b/mobile/ios-app/Core/Media/StudyVideoPlayerView.swift new file mode 100644 index 0000000..bb0a469 --- /dev/null +++ b/mobile/ios-app/Core/Media/StudyVideoPlayerView.swift @@ -0,0 +1,18 @@ +import AVKit +import SwiftUI + +struct StudyVideoPlayerView: View { + @ObservedObject var coordinator: PlayerCoordinator + + var body: some View { + VideoPlayer(player: coordinator.player) + .frame(maxWidth: .infinity) + .frame(height: 224) + .clipShape(RoundedRectangle(cornerRadius: HermesTheme.cornerRadius, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: HermesTheme.cornerRadius, style: .continuous) + .stroke(HermesTheme.accent.opacity(0.12), lineWidth: 1) + ) + .background(HermesTheme.surfaceElevated) + } +} diff --git a/mobile/ios-app/Core/Networking/APIModels.swift b/mobile/ios-app/Core/Networking/APIModels.swift index b7a44eb..2ed187e 100644 --- a/mobile/ios-app/Core/Networking/APIModels.swift +++ b/mobile/ios-app/Core/Networking/APIModels.swift @@ -42,8 +42,8 @@ struct HermesEventMedia: Codable { var id: UUID var eventId: UUID var mediaType: String - var hlsMasterURL: URL - var posterURL: URL? + var hlsMasterUrl: URL + var posterUrl: URL? var durationMs: Int var previewStartMs: Int var previewEndMs: Int diff --git a/mobile/ios-app/Features/Feed/FeedView.swift b/mobile/ios-app/Features/Feed/FeedView.swift index af7e236..ef74137 100644 --- a/mobile/ios-app/Features/Feed/FeedView.swift +++ b/mobile/ios-app/Features/Feed/FeedView.swift @@ -2,6 +2,7 @@ import SwiftUI struct FeedView: View { @EnvironmentObject private var localization: LocalizationStore + @EnvironmentObject private var analytics: HermesAnalyticsClient var body: some View { VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) { @@ -40,11 +41,17 @@ struct FeedView: View { } Button { + analytics.track("next_round_requested", attributes: ["screen_name": "feed"]) + analytics.track("cta_pressed", attributes: ["screen_name": "feed", "action": "watch_preview"]) } label: { Text(localization.string(for: "feed.cta")) } .buttonStyle(HermesPrimaryButtonStyle()) } .hermesCard(elevated: true) + .onAppear { + analytics.track("feed_viewed", attributes: ["screen_name": "feed"]) + analytics.track("screen_viewed", attributes: ["screen_name": "feed"]) + } } } diff --git a/mobile/ios-app/Features/Onboarding/OnboardingView.swift b/mobile/ios-app/Features/Onboarding/OnboardingView.swift index a765de3..30be9a7 100644 --- a/mobile/ios-app/Features/Onboarding/OnboardingView.swift +++ b/mobile/ios-app/Features/Onboarding/OnboardingView.swift @@ -2,6 +2,7 @@ import SwiftUI struct OnboardingView: View { @EnvironmentObject private var localization: LocalizationStore + @EnvironmentObject private var analytics: HermesAnalyticsClient var body: some View { VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) { @@ -24,11 +25,17 @@ struct OnboardingView: View { } Button { + analytics.track("consent_accepted", attributes: ["screen_name": "onboarding"]) + analytics.track("cta_pressed", attributes: ["screen_name": "onboarding", "action": "start_session"]) } label: { Text(localization.string(for: "onboarding.start_session")) } .buttonStyle(HermesPrimaryButtonStyle()) } .hermesCard(elevated: true) + .onAppear { + analytics.track("screen_viewed", attributes: ["screen_name": "onboarding"]) + analytics.track("consent_viewed", attributes: ["screen_name": "onboarding"]) + } } } diff --git a/mobile/ios-app/Features/Result/ResultView.swift b/mobile/ios-app/Features/Result/ResultView.swift index e975548..bd85753 100644 --- a/mobile/ios-app/Features/Result/ResultView.swift +++ b/mobile/ios-app/Features/Result/ResultView.swift @@ -1,7 +1,48 @@ import SwiftUI struct ResultView: View { + let title: String + let subtitle: String + let selectionLabel: String + let selectionValue: String + let outcomeLabel: String + let outcomeValue: String + let didWin: Bool + let winLabel: String + let loseLabel: String + let nextRoundTitle: String + let onNextRound: () -> Void + + @EnvironmentObject private var analytics: HermesAnalyticsClient + var body: some View { - Text("Result scaffold") + VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) { + HermesSectionHeader(title: title, subtitle: subtitle) + + HStack(spacing: 12) { + HermesMetricPill(label: selectionLabel, value: selectionValue) + HermesMetricPill(label: outcomeLabel, value: outcomeValue) + } + + HStack(spacing: 10) { + Image(systemName: didWin ? "checkmark.seal.fill" : "xmark.seal.fill") + .foregroundStyle(didWin ? HermesTheme.positive : HermesTheme.warning) + Text(didWin ? winLabel : loseLabel) + .font(.headline.weight(.semibold)) + .foregroundStyle(HermesTheme.textPrimary) + } + + Button { + analytics.track("next_round_requested", attributes: ["screen_name": "result"]) + onNextRound() + } label: { + Text(nextRoundTitle) + } + .buttonStyle(HermesPrimaryButtonStyle()) + } + .onAppear { + analytics.track("screen_viewed", attributes: ["screen_name": "result"]) + analytics.track("result_viewed", attributes: ["screen_name": "result", "selection": selectionValue, "outcome": outcomeValue]) + } } } diff --git a/mobile/ios-app/Features/Reveal/RevealView.swift b/mobile/ios-app/Features/Reveal/RevealView.swift index 193091e..3621539 100644 --- a/mobile/ios-app/Features/Reveal/RevealView.swift +++ b/mobile/ios-app/Features/Reveal/RevealView.swift @@ -1,7 +1,39 @@ import SwiftUI struct RevealView: View { + let title: String + let subtitle: String + let statusText: String + let selectionLabel: String + let selectionValue: String + let continueTitle: String + let onContinue: () -> Void + + @EnvironmentObject private var analytics: HermesAnalyticsClient + var body: some View { - Text("Reveal scaffold") + VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) { + HermesSectionHeader(title: title, subtitle: subtitle) + + VStack(alignment: .leading, spacing: 12) { + HermesMetricPill(label: selectionLabel, value: selectionValue) + + Text(statusText) + .font(.callout) + .foregroundStyle(HermesTheme.textSecondary) + } + + Button { + analytics.track("reveal_completed", attributes: ["screen_name": "reveal", "selection": selectionValue]) + onContinue() + } label: { + Text(continueTitle) + } + .buttonStyle(HermesPrimaryButtonStyle()) + } + .onAppear { + analytics.track("screen_viewed", attributes: ["screen_name": "reveal"]) + analytics.track("reveal_started", attributes: ["screen_name": "reveal", "selection": selectionValue]) + } } } diff --git a/mobile/ios-app/Features/Round/RoundView.swift b/mobile/ios-app/Features/Round/RoundView.swift index 2bd7aca..c6b7911 100644 --- a/mobile/ios-app/Features/Round/RoundView.swift +++ b/mobile/ios-app/Features/Round/RoundView.swift @@ -2,104 +2,228 @@ import SwiftUI struct RoundView: View { @EnvironmentObject private var localization: LocalizationStore - @State private var selectedOutcomeID = "home" + @EnvironmentObject private var analytics: HermesAnalyticsClient - private struct OutcomeOption: Identifiable { - let id: String - let label: String - let odds: String + @StateObject private var playerCoordinator = PlayerCoordinator() + @State private var phase: Phase = .preview + @State private var selectedOutcomeID: String? = nil + @State private var lockAt: Date = Date().addingTimeInterval(47) + @State private var transitionTask: Task? + + private let previewDuration: TimeInterval = 47 + private let winningOutcomeID = "home" + + private enum Phase { + case preview + case locked + case reveal + case result } - private var outcomeOptions: [OutcomeOption] { + private var selectionOptions: [SelectionOption] { [ - OutcomeOption(id: "home", label: localization.string(for: "round.home"), odds: "1.85"), - OutcomeOption(id: "away", label: localization.string(for: "round.away"), odds: "2.05"), + SelectionOption( + id: "home", + title: localization.string(for: "round.home"), + subtitle: localization.string(for: "round.selection_prompt"), + odds: "1.85" + ), + SelectionOption( + id: "away", + title: localization.string(for: "round.away"), + subtitle: localization.string(for: "round.selection_prompt"), + odds: "2.05" + ), ] } + private var selectedOutcome: SelectionOption? { + guard let selectedOutcomeID else { + return nil + } + + return selectionOptions.first { $0.id == selectedOutcomeID } + } + + private var winningOutcome: SelectionOption { + selectionOptions.first { $0.id == winningOutcomeID } ?? selectionOptions[0] + } + var body: some View { - VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) { - HermesSectionHeader( - title: localization.string(for: "round.title"), - subtitle: localization.string(for: "round.subtitle") - ) + TimelineView(.periodic(from: Date(), by: 1)) { context in + let remaining = max(lockAt.timeIntervalSince(context.date), 0) + let timerLocked = remaining <= 0 + let countdownText = Self.countdownText(for: remaining) - VStack(alignment: .leading, spacing: 14) { - ZStack(alignment: .topTrailing) { - RoundedRectangle(cornerRadius: HermesTheme.cornerRadius, style: .continuous) - .fill( - LinearGradient( - colors: [HermesTheme.surfaceElevated, HermesTheme.background], - startPoint: .top, - endPoint: .bottom - ) - ) - .frame(height: 200) + VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) { + HermesSectionHeader( + title: localization.string(for: "round.title"), + subtitle: localization.string(for: "round.subtitle") + ) - Text(localization.string(for: "round.video_placeholder")) - .font(.caption.weight(.semibold)) - .foregroundStyle(HermesTheme.textSecondary) - .padding(12) - } + videoSection(countdownText: countdownText, remaining: remaining, isTimerLocked: timerLocked) - HStack(spacing: 12) { - HermesCountdownBadge( - label: localization.string(for: "round.countdown_label"), - value: "00:47" - ) - HermesMetricPill(label: localization.string(for: "round.odds_label"), value: "1.85 / 2.05") - } - - Text(localization.string(for: "round.selection_prompt")) - .font(.callout) - .foregroundStyle(HermesTheme.textSecondary) - - VStack(spacing: 10) { - ForEach(outcomeOptions) { option in - outcomeButton(option) - } - } - - Button { - } label: { - Text(localization.string(for: "round.primary_cta")) - } - .buttonStyle(HermesPrimaryButtonStyle()) + phaseContent(isTimerLocked: timerLocked) } } .hermesCard(elevated: true) + .onAppear { + startPreview() + } + .onDisappear { + transitionTask?.cancel() + playerCoordinator.pause() + } } - private func outcomeButton(_ option: OutcomeOption) -> some View { - let isSelected = selectedOutcomeID == option.id - - return Button { - selectedOutcomeID = option.id - } label: { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text(option.label) - .font(.headline.weight(.semibold)) - Text(option.odds) - .font(.caption.weight(.semibold)) - .foregroundStyle(isSelected ? HermesTheme.background.opacity(0.72) : HermesTheme.textSecondary) - } - - Spacer() - - Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") - .font(.headline) - } - .padding(.horizontal, 16) - .padding(.vertical, 14) - .foregroundStyle(isSelected ? HermesTheme.background : HermesTheme.textPrimary) - .background(isSelected ? HermesTheme.accent : HermesTheme.surfaceElevated) - .overlay( - RoundedRectangle(cornerRadius: HermesTheme.insetRadius, style: .continuous) - .stroke(isSelected ? HermesTheme.accent : HermesTheme.accent.opacity(0.14), lineWidth: 1) + @ViewBuilder + private func phaseContent(isTimerLocked: Bool) -> some View { + switch phase { + case .preview, .locked: + SelectionView( + statusText: isTimerLocked || phase != .preview ? localization.string(for: "round.locked_label") : localization.string(for: "round.selection_prompt"), + options: selectionOptions, + selectedOptionID: selectedOutcomeID, + isLocked: isTimerLocked || phase != .preview, + 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: selectedOutcome?.title ?? localization.string(for: "round.selection_prompt"), + 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: selectedOutcome?.title ?? localization.string(for: "round.selection_prompt"), + outcomeLabel: localization.string(for: "result.outcome_label"), + outcomeValue: winningOutcome.title, + didWin: selectedOutcomeID == winningOutcomeID, + winLabel: localization.string(for: "result.win"), + loseLabel: localization.string(for: "result.lose"), + nextRoundTitle: localization.string(for: "result.next_round"), + onNextRound: resetRound ) - .clipShape(RoundedRectangle(cornerRadius: HermesTheme.insetRadius, style: .continuous)) } - .buttonStyle(.plain) + } + + @ViewBuilder + private func videoSection(countdownText: String, remaining: TimeInterval, isTimerLocked: Bool) -> some View { + ZStack(alignment: .topTrailing) { + StudyVideoPlayerView(coordinator: playerCoordinator) + + 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: "1.85 / 2.05" + ) + } + .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) + } + } + + private func phaseLabel(isTimerLocked: Bool) -> String { + switch phase { + case .preview: + return isTimerLocked ? localization.string(for: "round.locked_label") : 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 startPreview() { + transitionTask?.cancel() + phase = .preview + lockAt = Date().addingTimeInterval(previewDuration) + selectedOutcomeID = nil + playerCoordinator.restart() + + analytics.track("round_loaded", attributes: ["screen_name": "round"]) + analytics.track("preview_started", attributes: ["screen_name": "round"]) + analytics.track("screen_viewed", attributes: ["screen_name": "round"]) + } + + private func handleSelection(_ option: SelectionOption) { + guard phase == .preview 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, let selectedOutcomeID else { + return + } + + analytics.track("selection_submitted", attributes: ["screen_name": "round", "outcome_id": selectedOutcomeID]) + analytics.track("selection_accepted", attributes: ["screen_name": "round", "outcome_id": selectedOutcomeID]) + analytics.track("market_locked", attributes: ["screen_name": "round", "lock_reason": "manual_selection"]) + + phase = .locked + playerCoordinator.pause() + + transitionTask?.cancel() + transitionTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 750_000_000) + guard !Task.isCancelled else { + return + } + + phase = .reveal + } + } + + private func showResult() { + analytics.track("reveal_completed", attributes: ["screen_name": "round"]) + phase = .result + } + + private func resetRound() { + transitionTask?.cancel() + selectedOutcomeID = nil + phase = .preview + lockAt = Date().addingTimeInterval(previewDuration) + playerCoordinator.restart() + } + + 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) } } diff --git a/mobile/ios-app/Features/Selection/SelectionView.swift b/mobile/ios-app/Features/Selection/SelectionView.swift index f901880..d451219 100644 --- a/mobile/ios-app/Features/Selection/SelectionView.swift +++ b/mobile/ios-app/Features/Selection/SelectionView.swift @@ -1,7 +1,90 @@ import SwiftUI +struct SelectionOption: Identifiable, Equatable { + let id: String + let title: String + let subtitle: String + let odds: String +} + struct SelectionView: View { + let statusText: String + let options: [SelectionOption] + let selectedOptionID: String? + let isLocked: Bool + let confirmTitle: String + let onSelect: (SelectionOption) -> Void + let onConfirm: () -> Void + var body: some View { - Text("Selection scaffold") + VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) { + statusBadge + + VStack(spacing: 10) { + ForEach(options) { option in + optionButton(option) + } + } + + Button { + onConfirm() + } label: { + Text(confirmTitle) + } + .buttonStyle(HermesPrimaryButtonStyle()) + .disabled(isLocked || selectedOptionID == nil) + } + } + + private var statusBadge: some View { + HStack(spacing: 8) { + Image(systemName: isLocked ? "lock.fill" : "hand.tap.fill") + .font(.caption.weight(.semibold)) + Text(statusText) + .font(.caption.weight(.semibold)) + } + .foregroundStyle(isLocked ? HermesTheme.warning : HermesTheme.textSecondary) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background((isLocked ? HermesTheme.warning : HermesTheme.accent).opacity(0.16)) + .clipShape(Capsule()) + } + + private func optionButton(_ option: SelectionOption) -> some View { + let selected = selectedOptionID == option.id + + return Button { + onSelect(option) + } label: { + HStack(alignment: .center, spacing: 14) { + VStack(alignment: .leading, spacing: 4) { + Text(option.title) + .font(.headline.weight(.semibold)) + Text(option.subtitle) + .font(.caption) + .foregroundStyle(selected ? HermesTheme.background.opacity(0.68) : HermesTheme.textSecondary) + } + + Spacer(minLength: 8) + + VStack(alignment: .trailing, spacing: 4) { + Text(option.odds) + .font(.headline.weight(.bold)) + Image(systemName: selected ? "checkmark.circle.fill" : "circle") + .font(.subheadline.weight(.semibold)) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .foregroundStyle(selected ? HermesTheme.background : HermesTheme.textPrimary) + .background(selected ? HermesTheme.accent : HermesTheme.surfaceElevated) + .overlay( + RoundedRectangle(cornerRadius: HermesTheme.insetRadius, style: .continuous) + .stroke(selected ? HermesTheme.accent : HermesTheme.accent.opacity(0.14), lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: HermesTheme.insetRadius, style: .continuous)) + } + .buttonStyle(.plain) + .disabled(isLocked) } } diff --git a/mobile/ios-app/Resources/Localization/en.lproj/Localizable.strings b/mobile/ios-app/Resources/Localization/en.lproj/Localizable.strings index dd4c79f..dd2c03c 100644 --- a/mobile/ios-app/Resources/Localization/en.lproj/Localizable.strings +++ b/mobile/ios-app/Resources/Localization/en.lproj/Localizable.strings @@ -16,6 +16,7 @@ "round.title" = "Active round"; "round.subtitle" = "Make your choice before lock, then wait for the reveal."; "round.video_placeholder" = "Video preview"; +"round.preview_label" = "Preview playing"; "round.countdown_label" = "Lock in"; "round.odds_label" = "Odds"; "round.selection_prompt" = "Choose an outcome."; @@ -23,3 +24,14 @@ "round.locked_label" = "Locked"; "round.home" = "Home"; "round.away" = "Away"; +"reveal.title" = "Reveal"; +"reveal.subtitle" = "The clip is now showing the outcome."; +"reveal.status" = "Reveal segment is playing."; +"reveal.cta" = "See result"; +"result.title" = "Result"; +"result.subtitle" = "Your selection versus the actual outcome."; +"result.selection_label" = "Your selection"; +"result.outcome_label" = "Outcome"; +"result.win" = "Winning selection"; +"result.lose" = "Not this time"; +"result.next_round" = "Next round"; diff --git a/mobile/ios-app/Resources/Localization/sv.lproj/Localizable.strings b/mobile/ios-app/Resources/Localization/sv.lproj/Localizable.strings index 24f51ce..aa36ce6 100644 --- a/mobile/ios-app/Resources/Localization/sv.lproj/Localizable.strings +++ b/mobile/ios-app/Resources/Localization/sv.lproj/Localizable.strings @@ -16,6 +16,7 @@ "round.title" = "Aktiv runda"; "round.subtitle" = "Gör ditt val före låsning och vänta sedan på avslöjandet."; "round.video_placeholder" = "Videoförhandsvisning"; +"round.preview_label" = "Förhandsvisning spelas"; "round.countdown_label" = "Låsning om"; "round.odds_label" = "Odds"; "round.selection_prompt" = "Välj ett utfall."; @@ -23,3 +24,14 @@ "round.locked_label" = "Låst"; "round.home" = "Hemma"; "round.away" = "Borta"; +"reveal.title" = "Avslöjande"; +"reveal.subtitle" = "Klippet visar nu utfallet."; +"reveal.status" = "Avslöjningssegmentet spelas upp."; +"reveal.cta" = "Se resultat"; +"result.title" = "Resultat"; +"result.subtitle" = "Ditt val jämfört med det faktiska utfallet."; +"result.selection_label" = "Ditt val"; +"result.outcome_label" = "Utfall"; +"result.win" = "Vinnande val"; +"result.lose" = "Inte denna gång"; +"result.next_round" = "Nästa runda";