From 5c0aa9542a72de03e9d169725d9cb37b4aa9460d Mon Sep 17 00:00:00 2001 From: Love Billenius Date: Thu, 9 Apr 2026 15:39:32 +0200 Subject: [PATCH] build iOS study scaffold --- mobile/ios-app/App/HermesApp.swift | 2 + mobile/ios-app/App/RootView.swift | 64 ++++++-- mobile/ios-app/Core/DesignSystem/Theme.swift | 130 +++++++++++++++- .../Core/Localization/LocalizationStore.swift | 58 ++++++- .../Core/Media/PlayerCoordinator.swift | 1 + .../ios-app/Core/Networking/APIClient.swift | 138 ++++++++++++++++- .../ios-app/Core/Networking/APIModels.swift | 144 ++++++++++++++++++ mobile/ios-app/Features/Feed/FeedView.swift | 51 ++++++- .../Features/Onboarding/OnboardingView.swift | 38 +++-- mobile/ios-app/Features/Round/RoundView.swift | 101 +++++++++++- .../Localization/en.lproj/Localizable.strings | 21 +++ .../Localization/sv.lproj/Localizable.strings | 21 +++ 12 files changed, 723 insertions(+), 46 deletions(-) create mode 100644 mobile/ios-app/Core/Networking/APIModels.swift diff --git a/mobile/ios-app/App/HermesApp.swift b/mobile/ios-app/App/HermesApp.swift index cd5e24a..f355743 100644 --- a/mobile/ios-app/App/HermesApp.swift +++ b/mobile/ios-app/App/HermesApp.swift @@ -5,6 +5,8 @@ struct HermesApp: App { var body: some Scene { WindowGroup { RootView() + .preferredColorScheme(.dark) + .tint(HermesTheme.accent) } } } diff --git a/mobile/ios-app/App/RootView.swift b/mobile/ios-app/App/RootView.swift index 99c6851..e6b4b3f 100644 --- a/mobile/ios-app/App/RootView.swift +++ b/mobile/ios-app/App/RootView.swift @@ -1,18 +1,64 @@ import SwiftUI struct RootView: View { + @StateObject private var localization = LocalizationStore() + var body: some View { NavigationStack { - VStack(spacing: 16) { - Text("Hermes") - .font(.largeTitle.bold()) - Text("Native study app scaffold") - .font(.body) - .foregroundStyle(.secondary) - OnboardingView() + ScrollView { + VStack(alignment: .leading, spacing: 24) { + header + OnboardingView() + FeedView() + RoundView() + } + .padding(.horizontal, HermesTheme.screenPadding) + .padding(.vertical, 24) } - .padding() - .navigationTitle("Hermes") + .background(HermesTheme.background.ignoresSafeArea()) + .navigationTitle(localization.string(for: "app.name")) + .navigationBarTitleDisplayMode(.inline) + } + .environmentObject(localization) + } + + private var header: some View { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 8) { + Text(localization.string(for: "app.name")) + .font(.largeTitle.bold()) + .foregroundStyle(HermesTheme.textPrimary) + Text(localization.string(for: "app.subtitle")) + .font(.callout) + .foregroundStyle(HermesTheme.textSecondary) + } + + Spacer(minLength: 12) + + localeToggle + } + } + + private var localeToggle: some View { + HStack(spacing: 8) { + localeButton(title: "EN", localeCode: "en") + localeButton(title: "SV", localeCode: "sv") + } + } + + private func localeButton(title: String, localeCode: String) -> some View { + let isSelected = localization.localeCode == localeCode + + return Button { + localization.setLocale(localeCode) + } 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) + .clipShape(Capsule()) } } } diff --git a/mobile/ios-app/Core/DesignSystem/Theme.swift b/mobile/ios-app/Core/DesignSystem/Theme.swift index d68bf65..eb13ae8 100644 --- a/mobile/ios-app/Core/DesignSystem/Theme.swift +++ b/mobile/ios-app/Core/DesignSystem/Theme.swift @@ -1,9 +1,133 @@ import SwiftUI enum HermesTheme { - static let background = Color.black - static let surface = Color(red: 0.12, green: 0.12, blue: 0.14) - static let accent = Color(red: 0.87, green: 0.74, blue: 0.34) + static let background = Color(red: 0.04, green: 0.05, blue: 0.08) + static let surface = Color(red: 0.11, green: 0.12, blue: 0.16) + static let surfaceElevated = Color(red: 0.16, green: 0.17, blue: 0.22) + static let accent = Color(red: 0.88, green: 0.75, blue: 0.36) + static let accentSoft = Color(red: 0.88, green: 0.75, blue: 0.36).opacity(0.16) + static let positive = Color(red: 0.34, green: 0.78, blue: 0.52) + static let warning = Color(red: 0.95, green: 0.70, blue: 0.30) static let textPrimary = Color.white static let textSecondary = Color.white.opacity(0.72) + static let textTertiary = Color.white.opacity(0.48) + static let cornerRadius: CGFloat = 24 + static let insetRadius: CGFloat = 18 + static let screenPadding: CGFloat = 20 + static let sectionSpacing: CGFloat = 16 + static let contentPadding: CGFloat = 20 +} + +struct HermesCardModifier: ViewModifier { + var elevated: Bool = false + + func body(content: Content) -> some View { + content + .padding(HermesTheme.contentPadding) + .background( + RoundedRectangle(cornerRadius: HermesTheme.cornerRadius, style: .continuous) + .fill(elevated ? HermesTheme.surfaceElevated : HermesTheme.surface) + ) + .overlay( + RoundedRectangle(cornerRadius: HermesTheme.cornerRadius, style: .continuous) + .stroke(HermesTheme.accent.opacity(elevated ? 0.22 : 0.08), lineWidth: 1) + ) + .shadow(color: .black.opacity(elevated ? 0.34 : 0.22), radius: elevated ? 24 : 12, x: 0, y: elevated ? 12 : 6) + } +} + +extension View { + func hermesCard(elevated: Bool = false) -> some View { + modifier(HermesCardModifier(elevated: elevated)) + } +} + +struct HermesPrimaryButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.headline.weight(.semibold)) + .foregroundStyle(HermesTheme.background) + .frame(maxWidth: .infinity, minHeight: 52) + .padding(.horizontal, 18) + .background(HermesTheme.accent) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .opacity(configuration.isPressed ? 0.88 : 1) + .scaleEffect(configuration.isPressed ? 0.98 : 1) + } +} + +struct HermesSecondaryButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.headline.weight(.semibold)) + .foregroundStyle(HermesTheme.textPrimary) + .frame(maxWidth: .infinity, minHeight: 52) + .padding(.horizontal, 18) + .background(HermesTheme.surfaceElevated) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(HermesTheme.accent.opacity(0.18), lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .opacity(configuration.isPressed ? 0.88 : 1) + .scaleEffect(configuration.isPressed ? 0.98 : 1) + } +} + +struct HermesSectionHeader: View { + let title: String + let subtitle: String + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.title2.weight(.bold)) + .foregroundStyle(HermesTheme.textPrimary) + Text(subtitle) + .font(.callout) + .foregroundStyle(HermesTheme.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } +} + +struct HermesMetricPill: View { + let label: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.caption.weight(.semibold)) + .foregroundStyle(HermesTheme.textTertiary) + Text(value) + .font(.headline.weight(.semibold)) + .foregroundStyle(HermesTheme.textPrimary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background(HermesTheme.surfaceElevated) + .clipShape(RoundedRectangle(cornerRadius: HermesTheme.insetRadius, style: .continuous)) + } +} + +struct HermesCountdownBadge: View { + let label: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(label) + .font(.caption.weight(.semibold)) + .foregroundStyle(HermesTheme.textTertiary) + Text(value) + .font(.title3.weight(.bold)) + .foregroundStyle(HermesTheme.accent) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background(HermesTheme.accentSoft) + .clipShape(RoundedRectangle(cornerRadius: HermesTheme.insetRadius, style: .continuous)) + } } diff --git a/mobile/ios-app/Core/Localization/LocalizationStore.swift b/mobile/ios-app/Core/Localization/LocalizationStore.swift index 93c5a46..174a173 100644 --- a/mobile/ios-app/Core/Localization/LocalizationStore.swift +++ b/mobile/ios-app/Core/Localization/LocalizationStore.swift @@ -1,18 +1,62 @@ +import Combine import Foundation -final class LocalizationStore { +@MainActor +final class LocalizationStore: ObservableObject { + @Published private(set) var localeCode: String + private let bundle: Bundle - init(bundle: Bundle = .main) { + private static let supportedLocaleCodes = ["en", "sv"] + private static let fallbackLocaleCode = "en" + + init(bundle: Bundle = .main, localeCode: String = Locale.preferredLanguages.first.map { String($0.prefix(2)) } ?? "en") { self.bundle = bundle + self.localeCode = Self.normalize(localeCode) } - func string(for key: String, locale: String) -> String { - guard let path = bundle.path(forResource: locale, ofType: "lproj"), - let localizedBundle = Bundle(path: path) else { - return bundle.localizedString(forKey: key, value: nil, table: nil) + func setLocale(_ localeCode: String) { + self.localeCode = Self.normalize(localeCode) + } + + func string(for key: String) -> String { + string(for: key, localeCode: localeCode) + } + + func string(for key: String, localeCode: String) -> String { + guard let localizedBundle = localizedBundle(for: localeCode) else { + return fallbackString(for: key, localeCode: localeCode) } - return localizedBundle.localizedString(forKey: key, value: nil, table: nil) + let value = localizedBundle.localizedString(forKey: key, value: nil, table: nil) + if value == key { + return fallbackString(for: key, localeCode: localeCode) + } + + return value + } + + private func fallbackString(for key: String, localeCode: String) -> String { + guard localeCode != Self.fallbackLocaleCode else { + return key + } + + return string(for: key, localeCode: Self.fallbackLocaleCode) + } + + private func localizedBundle(for localeCode: String) -> Bundle? { + guard let path = bundle.path(forResource: localeCode, ofType: "lproj") else { + return nil + } + + return Bundle(path: path) + } + + private static func normalize(_ localeCode: String) -> String { + guard supportedLocaleCodes.contains(localeCode) else { + return fallbackLocaleCode + } + + return localeCode } } diff --git a/mobile/ios-app/Core/Media/PlayerCoordinator.swift b/mobile/ios-app/Core/Media/PlayerCoordinator.swift index 5542bbf..a529352 100644 --- a/mobile/ios-app/Core/Media/PlayerCoordinator.swift +++ b/mobile/ios-app/Core/Media/PlayerCoordinator.swift @@ -1,3 +1,4 @@ +import Combine import Foundation final class PlayerCoordinator: ObservableObject { diff --git a/mobile/ios-app/Core/Networking/APIClient.swift b/mobile/ios-app/Core/Networking/APIClient.swift index 1f77316..dd808e4 100644 --- a/mobile/ios-app/Core/Networking/APIClient.swift +++ b/mobile/ios-app/Core/Networking/APIClient.swift @@ -2,23 +2,149 @@ import Foundation struct APIEnvironment { let baseURL: URL + + init(baseURL: URL) { + self.baseURL = baseURL.absoluteString.hasSuffix("/") ? baseURL : baseURL.appendingPathComponent("") + } +} + +enum HermesAPIError: Error { + case invalidURL(String) + case invalidResponse + case transport(Error) + case unexpectedStatus(Int, Data) + case decoding(Error) } struct HermesAPIClient { let environment: APIEnvironment let session: URLSession + private let encoder: JSONEncoder + private let decoder: JSONDecoder + init(environment: APIEnvironment, session: URLSession = .shared) { self.environment = environment self.session = session + + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + encoder.dateEncodingStrategy = .iso8601 + self.encoder = encoder + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + decoder.dateDecodingStrategy = .iso8601 + self.decoder = decoder } - func get(path: String) async throws -> (Data, HTTPURLResponse) { - let url = environment.baseURL.appendingPathComponent(path) - let (data, response) = try await session.data(from: url) - guard let httpResponse = response as? HTTPURLResponse else { - throw URLError(.badServerResponse) + func startSession(_ payload: HermesSessionStartRequest) async throws -> HermesSessionResponse { + try await send(path: "api/v1/session/start", method: "POST", body: payload) + } + + func endSession() async throws -> HermesSessionResponse { + try await send(path: "api/v1/session/end", method: "POST") + } + + func currentSession() async throws -> HermesSessionResponse { + try await send(path: "api/v1/session/me") + } + + func nextEvent() async throws -> HermesEvent { + try await send(path: "api/v1/feed/next") + } + + func eventManifest(eventID: UUID) async throws -> HermesEventManifest { + try await send(path: "api/v1/events/\(eventID.uuidString)/manifest") + } + + func markets(eventID: UUID) async throws -> [HermesMarket] { + try await send(path: "api/v1/events/\(eventID.uuidString)/markets") + } + + func currentOdds(marketID: UUID) async throws -> HermesOddsVersion { + try await send(path: "api/v1/markets/\(marketID.uuidString)/odds/current") + } + + func submitBetIntent(_ payload: HermesBetIntentRequest) async throws -> HermesBetIntentResponse { + try await send(path: "api/v1/bets/intent", method: "POST", body: payload) + } + + func betIntent(id: UUID) async throws -> HermesBetIntentResponse { + try await send(path: "api/v1/bets/\(id.uuidString)") + } + + func settlement(eventID: UUID) async throws -> HermesSettlement { + try await send(path: "api/v1/events/\(eventID.uuidString)/result") + } + + func experimentConfig() async throws -> HermesExperimentConfig { + try await send(path: "api/v1/experiments/config") + } + + func localization(localeCode: String) async throws -> HermesLocalizationBundle { + try await send(path: "api/v1/localization/\(localeCode)") + } + + func submitAnalyticsBatch(_ payload: HermesAnalyticsBatchRequest) async throws { + try await perform(path: "api/v1/analytics/batch", method: "POST", body: payload) + } + + private func send(path: String, method: String = "GET") async throws -> Response { + let (data, _) = try await perform(path: path, method: method) + do { + return try decoder.decode(Response.self, from: data) + } catch { + throw HermesAPIError.decoding(error) } - return (data, httpResponse) + } + + private func send(path: String, method: String, body: Body) async throws -> Response { + let encodedBody = try encoder.encode(body) + let (data, _) = try await perform(path: path, method: method, body: encodedBody) + + do { + return try decoder.decode(Response.self, from: data) + } catch { + throw HermesAPIError.decoding(error) + } + } + + private func perform(path: String, method: String, body: Data? = nil) async throws -> (Data, HTTPURLResponse) { + let request = try makeRequest(path: path, method: method, body: body) + + do { + let (data, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw HermesAPIError.invalidResponse + } + + guard (200...299).contains(httpResponse.statusCode) else { + throw HermesAPIError.unexpectedStatus(httpResponse.statusCode, data) + } + + return (data, httpResponse) + } catch let error as HermesAPIError { + throw error + } catch { + throw HermesAPIError.transport(error) + } + } + + private func makeRequest(path: String, method: String, body: Data? = nil) throws -> URLRequest { + let normalizedPath = path.hasPrefix("/") ? String(path.dropFirst()) : path + guard let url = URL(string: normalizedPath, relativeTo: environment.baseURL)?.absoluteURL else { + throw HermesAPIError.invalidURL(path) + } + + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let body { + request.httpBody = body + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + + return request } } diff --git a/mobile/ios-app/Core/Networking/APIModels.swift b/mobile/ios-app/Core/Networking/APIModels.swift new file mode 100644 index 0000000..b7a44eb --- /dev/null +++ b/mobile/ios-app/Core/Networking/APIModels.swift @@ -0,0 +1,144 @@ +import Foundation + +struct HermesSessionStartRequest: Codable { + var externalRef: String? + var localeCode: String? + var devicePlatform: String? + var deviceModel: String? + var osVersion: String? + var appVersion: String? + var experimentVariant: String? +} + +struct HermesSessionResponse: Codable { + var sessionId: UUID + var userId: UUID + var startedAt: Date + var endedAt: Date? + var experimentVariant: String + var appVersion: String + var deviceModel: String? + var osVersion: String? + var localeCode: String + var devicePlatform: String +} + +struct HermesEvent: Codable { + var id: UUID + var sportType: String + var sourceRef: String + var titleEn: String + var titleSv: String + var status: String + var previewStartMs: Int + var previewEndMs: Int + var revealStartMs: Int + var revealEndMs: Int + var lockAt: Date + var settleAt: Date +} + +struct HermesEventMedia: Codable { + var id: UUID + var eventId: UUID + var mediaType: String + var hlsMasterURL: URL + var posterURL: URL? + var durationMs: Int + var previewStartMs: Int + var previewEndMs: Int + var revealStartMs: Int + var revealEndMs: Int +} + +struct HermesOutcome: Codable { + var id: UUID + var marketId: UUID + var outcomeCode: String + var labelKey: String + var sortOrder: Int +} + +struct HermesMarket: Codable { + var id: UUID + var eventId: UUID + var questionKey: String + var marketType: String + var status: String + var lockAt: Date + var settlementRuleKey: String + var outcomes: [HermesOutcome] +} + +struct HermesOutcomeOdds: Codable { + var id: UUID + var oddsVersionId: UUID + var outcomeId: UUID + var decimalOdds: Double + var fractionalNum: Int + var fractionalDen: Int +} + +struct HermesOddsVersion: Codable { + var id: UUID + var marketId: UUID + var versionNo: Int + var createdAt: Date + var isCurrent: Bool + var odds: [HermesOutcomeOdds] +} + +struct HermesEventManifest: Codable { + var event: HermesEvent + var media: [HermesEventMedia] + var markets: [HermesMarket] +} + +struct HermesBetIntentRequest: Codable { + var sessionId: UUID + var eventId: UUID + var marketId: UUID + var outcomeId: UUID + var idempotencyKey: String + var clientSentAt: Date +} + +struct HermesBetIntentResponse: Codable { + var id: UUID + var accepted: Bool + var acceptanceCode: String + var acceptedOddsVersionId: UUID? + var serverReceivedAt: Date +} + +struct HermesSettlement: Codable { + var id: UUID + var marketId: UUID + var settledAt: Date + var winningOutcomeId: UUID +} + +struct HermesAnalyticsAttributeInput: Codable { + var key: String + var value: String +} + +struct HermesAnalyticsEventInput: Codable { + var eventName: String + var occurredAt: Date + var attributes: [HermesAnalyticsAttributeInput]? +} + +struct HermesAnalyticsBatchRequest: Codable { + var events: [HermesAnalyticsEventInput] +} + +struct HermesExperimentConfig: Codable { + var variant: String + var featureFlags: [String: Bool] +} + +struct HermesLocalizationBundle: Codable { + var localeCode: String + var values: [String: String] +} diff --git a/mobile/ios-app/Features/Feed/FeedView.swift b/mobile/ios-app/Features/Feed/FeedView.swift index 66a2d86..af7e236 100644 --- a/mobile/ios-app/Features/Feed/FeedView.swift +++ b/mobile/ios-app/Features/Feed/FeedView.swift @@ -1,15 +1,50 @@ import SwiftUI struct FeedView: View { + @EnvironmentObject private var localization: LocalizationStore + var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Next round") - .font(.headline) - Text("A new clip is ready for review.") - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) { + HermesSectionHeader( + title: localization.string(for: "feed.title"), + subtitle: localization.string(for: "feed.subtitle") + ) + + ZStack(alignment: .bottomLeading) { + RoundedRectangle(cornerRadius: HermesTheme.cornerRadius, style: .continuous) + .fill( + LinearGradient( + colors: [HermesTheme.surfaceElevated, HermesTheme.background], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(height: 220) + + VStack(alignment: .leading, spacing: 8) { + Text(localization.string(for: "feed.hero_title")) + .font(.title2.weight(.bold)) + .foregroundStyle(HermesTheme.textPrimary) + + Text(localization.string(for: "feed.hero_subtitle")) + .font(.callout) + .foregroundStyle(HermesTheme.textSecondary) + .frame(maxWidth: 260, alignment: .leading) + } + .padding(HermesTheme.contentPadding) + } + + HStack(spacing: 12) { + HermesMetricPill(label: localization.string(for: "feed.lock_label"), value: "01:42") + HermesMetricPill(label: localization.string(for: "feed.odds_label"), value: "1.85 / 2.05") + } + + Button { + } label: { + Text(localization.string(for: "feed.cta")) + } + .buttonStyle(HermesPrimaryButtonStyle()) } - .padding() - .background(HermesTheme.surface) - .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .hermesCard(elevated: true) } } diff --git a/mobile/ios-app/Features/Onboarding/OnboardingView.swift b/mobile/ios-app/Features/Onboarding/OnboardingView.swift index 944e12d..a765de3 100644 --- a/mobile/ios-app/Features/Onboarding/OnboardingView.swift +++ b/mobile/ios-app/Features/Onboarding/OnboardingView.swift @@ -1,18 +1,34 @@ import SwiftUI struct OnboardingView: View { + @EnvironmentObject private var localization: LocalizationStore + var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Study intro") - .font(.headline) - Text("Watch the clip, make your choice before lock, then see the reveal.") - .foregroundStyle(.secondary) - Button("Start session") {} - .buttonStyle(.borderedProminent) + VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) { + HermesSectionHeader( + title: localization.string(for: "onboarding.title"), + subtitle: localization.string(for: "onboarding.subtitle") + ) + + VStack(alignment: .leading, spacing: 12) { + Label { + Text(localization.string(for: "onboarding.consent_body")) + } icon: { + Image(systemName: "checkmark.shield.fill") + .foregroundStyle(HermesTheme.accent) + } + + Text(localization.string(for: "onboarding.consent_note")) + .font(.callout) + .foregroundStyle(HermesTheme.textSecondary) + } + + Button { + } label: { + Text(localization.string(for: "onboarding.start_session")) + } + .buttonStyle(HermesPrimaryButtonStyle()) } - .padding() - .background(HermesTheme.surface) - .foregroundStyle(HermesTheme.textPrimary) - .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .hermesCard(elevated: true) } } diff --git a/mobile/ios-app/Features/Round/RoundView.swift b/mobile/ios-app/Features/Round/RoundView.swift index 908087a..2bd7aca 100644 --- a/mobile/ios-app/Features/Round/RoundView.swift +++ b/mobile/ios-app/Features/Round/RoundView.swift @@ -1,8 +1,105 @@ import SwiftUI struct RoundView: View { + @EnvironmentObject private var localization: LocalizationStore + @State private var selectedOutcomeID = "home" + + private struct OutcomeOption: Identifiable { + let id: String + let label: String + let odds: String + } + + private var outcomeOptions: [OutcomeOption] { + [ + OutcomeOption(id: "home", label: localization.string(for: "round.home"), odds: "1.85"), + OutcomeOption(id: "away", label: localization.string(for: "round.away"), odds: "2.05"), + ] + } + var body: some View { - Text("Round scaffold") - .padding() + VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) { + HermesSectionHeader( + title: localization.string(for: "round.title"), + subtitle: localization.string(for: "round.subtitle") + ) + + 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) + + Text(localization.string(for: "round.video_placeholder")) + .font(.caption.weight(.semibold)) + .foregroundStyle(HermesTheme.textSecondary) + .padding(12) + } + + 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()) + } + } + .hermesCard(elevated: true) + } + + 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) + ) + .clipShape(RoundedRectangle(cornerRadius: HermesTheme.insetRadius, style: .continuous)) + } + .buttonStyle(.plain) } } diff --git a/mobile/ios-app/Resources/Localization/en.lproj/Localizable.strings b/mobile/ios-app/Resources/Localization/en.lproj/Localizable.strings index b024e2e..dd4c79f 100644 --- a/mobile/ios-app/Resources/Localization/en.lproj/Localizable.strings +++ b/mobile/ios-app/Resources/Localization/en.lproj/Localizable.strings @@ -1,4 +1,25 @@ "app.name" = "Hermes"; +"app.subtitle" = "Native study app prototype"; "onboarding.title" = "Study intro"; +"onboarding.subtitle" = "Watch the clip, decide before lock, then see the reveal."; +"onboarding.consent_body" = "This prototype is for research and does not use real money."; +"onboarding.consent_note" = "You can switch languages at any time."; "onboarding.start_session" = "Start session"; +"feed.title" = "Preview feed"; +"feed.subtitle" = "The next round is ready for review."; +"feed.hero_title" = "Late winner chance"; +"feed.hero_subtitle" = "A short preview leads into a single, clear choice."; +"feed.lock_label" = "Locks in"; +"feed.odds_label" = "Live odds"; +"feed.cta" = "Watch preview"; "feed.next_round_title" = "Next round"; +"round.title" = "Active round"; +"round.subtitle" = "Make your choice before lock, then wait for the reveal."; +"round.video_placeholder" = "Video preview"; +"round.countdown_label" = "Lock in"; +"round.odds_label" = "Odds"; +"round.selection_prompt" = "Choose an outcome."; +"round.primary_cta" = "Confirm selection"; +"round.locked_label" = "Locked"; +"round.home" = "Home"; +"round.away" = "Away"; diff --git a/mobile/ios-app/Resources/Localization/sv.lproj/Localizable.strings b/mobile/ios-app/Resources/Localization/sv.lproj/Localizable.strings index ab6e136..24f51ce 100644 --- a/mobile/ios-app/Resources/Localization/sv.lproj/Localizable.strings +++ b/mobile/ios-app/Resources/Localization/sv.lproj/Localizable.strings @@ -1,4 +1,25 @@ "app.name" = "Hermes"; +"app.subtitle" = "Native prototype för studien"; "onboarding.title" = "Studieintro"; +"onboarding.subtitle" = "Titta på klippet, välj före låsning och se sedan avslöjandet."; +"onboarding.consent_body" = "Den här prototypen är för forskning och använder inga riktiga pengar."; +"onboarding.consent_note" = "Du kan byta språk när som helst."; "onboarding.start_session" = "Starta session"; +"feed.title" = "Förhandsflöde"; +"feed.subtitle" = "Nästa runda är redo att granskas."; +"feed.hero_title" = "Möjlighet till segermål"; +"feed.hero_subtitle" = "En kort förhandsvisning leder till ett tydligt val."; +"feed.lock_label" = "Låses om"; +"feed.odds_label" = "Liveodds"; +"feed.cta" = "Titta på förhandsklipp"; "feed.next_round_title" = "Nästa runda"; +"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.countdown_label" = "Låsning om"; +"round.odds_label" = "Odds"; +"round.selection_prompt" = "Välj ett utfall."; +"round.primary_cta" = "Bekräfta valet"; +"round.locked_label" = "Låst"; +"round.home" = "Hemma"; +"round.away" = "Borta";