build iOS study scaffold

This commit is contained in:
2026-04-09 15:39:32 +02:00
parent 87f152a232
commit 5c0aa9542a
12 changed files with 723 additions and 46 deletions
+127 -3
View File
@@ -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))
}
}
@@ -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
}
}
@@ -1,3 +1,4 @@
import Combine
import Foundation
final class PlayerCoordinator: ObservableObject {
+132 -6
View File
@@ -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<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]
}