build iOS study scaffold
This commit is contained in:
@@ -5,6 +5,8 @@ struct HermesApp: App {
|
|||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
RootView()
|
RootView()
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
.tint(HermesTheme.accent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,64 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct RootView: View {
|
struct RootView: View {
|
||||||
|
@StateObject private var localization = LocalizationStore()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
VStack(spacing: 16) {
|
ScrollView {
|
||||||
Text("Hermes")
|
VStack(alignment: .leading, spacing: 24) {
|
||||||
.font(.largeTitle.bold())
|
header
|
||||||
Text("Native study app scaffold")
|
OnboardingView()
|
||||||
.font(.body)
|
FeedView()
|
||||||
.foregroundStyle(.secondary)
|
RoundView()
|
||||||
OnboardingView()
|
}
|
||||||
|
.padding(.horizontal, HermesTheme.screenPadding)
|
||||||
|
.padding(.vertical, 24)
|
||||||
}
|
}
|
||||||
.padding()
|
.background(HermesTheme.background.ignoresSafeArea())
|
||||||
.navigationTitle("Hermes")
|
.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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,133 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
enum HermesTheme {
|
enum HermesTheme {
|
||||||
static let background = Color.black
|
static let background = Color(red: 0.04, green: 0.05, blue: 0.08)
|
||||||
static let surface = Color(red: 0.12, green: 0.12, blue: 0.14)
|
static let surface = Color(red: 0.11, green: 0.12, blue: 0.16)
|
||||||
static let accent = Color(red: 0.87, green: 0.74, blue: 0.34)
|
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 textPrimary = Color.white
|
||||||
static let textSecondary = Color.white.opacity(0.72)
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,62 @@
|
|||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
final class LocalizationStore {
|
@MainActor
|
||||||
|
final class LocalizationStore: ObservableObject {
|
||||||
|
@Published private(set) var localeCode: String
|
||||||
|
|
||||||
private let bundle: Bundle
|
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.bundle = bundle
|
||||||
|
self.localeCode = Self.normalize(localeCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
func string(for key: String, locale: String) -> String {
|
func setLocale(_ localeCode: String) {
|
||||||
guard let path = bundle.path(forResource: locale, ofType: "lproj"),
|
self.localeCode = Self.normalize(localeCode)
|
||||||
let localizedBundle = Bundle(path: path) else {
|
}
|
||||||
return bundle.localizedString(forKey: key, value: nil, table: nil)
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
final class PlayerCoordinator: ObservableObject {
|
final class PlayerCoordinator: ObservableObject {
|
||||||
|
|||||||
@@ -2,23 +2,149 @@ import Foundation
|
|||||||
|
|
||||||
struct APIEnvironment {
|
struct APIEnvironment {
|
||||||
let baseURL: URL
|
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 {
|
struct HermesAPIClient {
|
||||||
let environment: APIEnvironment
|
let environment: APIEnvironment
|
||||||
let session: URLSession
|
let session: URLSession
|
||||||
|
|
||||||
|
private let encoder: JSONEncoder
|
||||||
|
private let decoder: JSONDecoder
|
||||||
|
|
||||||
init(environment: APIEnvironment, session: URLSession = .shared) {
|
init(environment: APIEnvironment, session: URLSession = .shared) {
|
||||||
self.environment = environment
|
self.environment = environment
|
||||||
self.session = session
|
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) {
|
func startSession(_ payload: HermesSessionStartRequest) async throws -> HermesSessionResponse {
|
||||||
let url = environment.baseURL.appendingPathComponent(path)
|
try await send(path: "api/v1/session/start", method: "POST", body: payload)
|
||||||
let (data, response) = try await session.data(from: url)
|
}
|
||||||
guard let httpResponse = response as? HTTPURLResponse else {
|
|
||||||
throw URLError(.badServerResponse)
|
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<Response: Decodable>(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<Response: Decodable, Body: Encodable>(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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -1,15 +1,50 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct FeedView: View {
|
struct FeedView: View {
|
||||||
|
@EnvironmentObject private var localization: LocalizationStore
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) {
|
||||||
Text("Next round")
|
HermesSectionHeader(
|
||||||
.font(.headline)
|
title: localization.string(for: "feed.title"),
|
||||||
Text("A new clip is ready for review.")
|
subtitle: localization.string(for: "feed.subtitle")
|
||||||
.foregroundStyle(.secondary)
|
)
|
||||||
|
|
||||||
|
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()
|
.hermesCard(elevated: true)
|
||||||
.background(HermesTheme.surface)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,34 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct OnboardingView: View {
|
struct OnboardingView: View {
|
||||||
|
@EnvironmentObject private var localization: LocalizationStore
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) {
|
||||||
Text("Study intro")
|
HermesSectionHeader(
|
||||||
.font(.headline)
|
title: localization.string(for: "onboarding.title"),
|
||||||
Text("Watch the clip, make your choice before lock, then see the reveal.")
|
subtitle: localization.string(for: "onboarding.subtitle")
|
||||||
.foregroundStyle(.secondary)
|
)
|
||||||
Button("Start session") {}
|
|
||||||
.buttonStyle(.borderedProminent)
|
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()
|
.hermesCard(elevated: true)
|
||||||
.background(HermesTheme.surface)
|
|
||||||
.foregroundStyle(HermesTheme.textPrimary)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,105 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct RoundView: View {
|
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 {
|
var body: some View {
|
||||||
Text("Round scaffold")
|
VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) {
|
||||||
.padding()
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,25 @@
|
|||||||
"app.name" = "Hermes";
|
"app.name" = "Hermes";
|
||||||
|
"app.subtitle" = "Native study app prototype";
|
||||||
"onboarding.title" = "Study intro";
|
"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";
|
"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";
|
"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";
|
||||||
|
|||||||
@@ -1,4 +1,25 @@
|
|||||||
"app.name" = "Hermes";
|
"app.name" = "Hermes";
|
||||||
|
"app.subtitle" = "Native prototype för studien";
|
||||||
"onboarding.title" = "Studieintro";
|
"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";
|
"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";
|
"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";
|
||||||
|
|||||||
Reference in New Issue
Block a user