redesign iOS round into fullscreen swipe flow

This commit is contained in:
2026-04-10 10:23:15 +02:00
parent 2553845100
commit 7b466f0a34
12 changed files with 685 additions and 254 deletions
+54 -30
View File
@@ -12,43 +12,67 @@ struct HermesApp: App {
@StateObject private var analytics = HermesAnalyticsClient()
@StateObject private var playerCoordinator = PlayerCoordinator()
@State private var isBootstrapping = false
@State private var appMode: HermesAppMode = .demo
var body: some Scene {
WindowGroup {
RootView(onStartSession: { localeCode in
if repository.currentSession != nil, repository.currentRound != nil {
return
}
guard !isBootstrapping else {
return
}
isBootstrapping = true
let request = HermesSessionStartRequest(
localeCode: localeCode,
devicePlatform: "ios",
deviceModel: UIDevice.current.model,
osVersion: UIDevice.current.systemVersion,
appVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.1.0"
)
analytics.track("session_start_requested", attributes: ["screen_name": "session", "locale_code": localeCode])
Task { @MainActor in
defer {
isBootstrapping = false
RootView(
mode: appMode,
onModeSelected: { selectedMode in
guard appMode != selectedMode else {
return
}
do {
_ = try await repository.bootstrap(request)
analytics.track("session_started", attributes: ["screen_name": "session", "locale_code": localeCode])
await analytics.flush(using: repository)
} catch {
analytics.track("session_start_failed", attributes: ["screen_name": "session", "locale_code": localeCode])
appMode = selectedMode
repository.reset()
},
onStartSession: { localeCode, selectedMode in
if selectedMode == .demo {
Task { @MainActor in
repository.reset()
await repository.bootstrapMock(
localeCode: localeCode,
appVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.1.0"
)
analytics.track("session_started", attributes: ["screen_name": "session", "locale_code": localeCode, "mode": selectedMode.rawValue])
}
return
}
if repository.currentSession != nil, repository.currentRound != nil {
return
}
guard !isBootstrapping else {
return
}
isBootstrapping = true
let request = HermesSessionStartRequest(
localeCode: localeCode,
devicePlatform: "ios",
deviceModel: UIDevice.current.model,
osVersion: UIDevice.current.systemVersion,
appVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.1.0"
)
analytics.track("session_start_requested", attributes: ["screen_name": "session", "locale_code": localeCode, "mode": selectedMode.rawValue])
Task { @MainActor in
defer {
isBootstrapping = false
}
do {
_ = try await repository.bootstrap(request)
analytics.track("session_started", attributes: ["screen_name": "session", "locale_code": localeCode, "mode": selectedMode.rawValue])
await analytics.flush(using: repository)
} catch {
analytics.track("session_start_failed", attributes: ["screen_name": "session", "locale_code": localeCode, "mode": selectedMode.rawValue])
}
}
}
})
)
.preferredColorScheme(.dark)
.tint(HermesTheme.accent)
.environmentObject(analytics)
+177
View File
@@ -10,11 +10,40 @@ final class HermesRepository: ObservableObject {
@Published private(set) var serverClockOffset: TimeInterval?
private let apiClient: HermesAPIClient
private var mockRoundIndex = 0
init(apiClient: HermesAPIClient) {
self.apiClient = apiClient
}
func reset() {
currentSession = nil
currentRound = nil
isLoading = false
errorCause = nil
serverClockOffset = nil
mockRoundIndex = 0
}
func bootstrapMock(localeCode: String, appVersion: String) async {
isLoading = true
errorCause = nil
mockRoundIndex = 0
currentSession = MockHermesData.session(localeCode: localeCode, appVersion: appVersion)
currentRound = MockHermesData.round(index: mockRoundIndex, now: Date())
isLoading = false
}
func refreshMockRound() async throws -> HermesRound {
isLoading = true
errorCause = nil
mockRoundIndex = (mockRoundIndex + 1) % MockHermesData.roundCount
let round = MockHermesData.round(index: mockRoundIndex, now: Date())
currentRound = round
isLoading = false
return round
}
func bootstrap(_ request: HermesSessionStartRequest) async throws -> HermesSessionResponse {
isLoading = true
errorCause = nil
@@ -143,3 +172,151 @@ final class HermesRepository: ObservableObject {
)
}
}
private enum MockHermesData {
static let roundCount = 3
static func session(localeCode: String, appVersion: String) -> HermesSessionResponse {
HermesSessionResponse(
sessionId: UUID(uuidString: "00000000-0000-0000-0000-000000000001") ?? UUID(),
userId: UUID(uuidString: "00000000-0000-0000-0000-000000000002") ?? UUID(),
startedAt: Date(),
endedAt: nil,
experimentVariant: "modern",
appVersion: appVersion,
deviceModel: "Demo Device",
osVersion: "Demo OS",
localeCode: localeCode,
devicePlatform: "ios"
)
}
static func round(index: Int, now: Date) -> HermesRound {
let scenarios = [
scenario(
eventID: "10000000-0000-0000-0000-000000000001",
marketID: "20000000-0000-0000-0000-000000000001",
yesOutcomeID: "30000000-0000-0000-0000-000000000001",
noOutcomeID: "30000000-0000-0000-0000-000000000002",
promptKey: "event.question.score",
sourceRef: "mock-score",
winningOutcomeID: "30000000-0000-0000-0000-000000000001",
now: now
),
scenario(
eventID: "10000000-0000-0000-0000-000000000011",
marketID: "20000000-0000-0000-0000-000000000011",
yesOutcomeID: "30000000-0000-0000-0000-000000000011",
noOutcomeID: "30000000-0000-0000-0000-000000000012",
promptKey: "event.question.save",
sourceRef: "mock-save",
winningOutcomeID: "30000000-0000-0000-0000-000000000012",
now: now
),
scenario(
eventID: "10000000-0000-0000-0000-000000000021",
marketID: "20000000-0000-0000-0000-000000000021",
yesOutcomeID: "30000000-0000-0000-0000-000000000021",
noOutcomeID: "30000000-0000-0000-0000-000000000022",
promptKey: "event.question.convert",
sourceRef: "mock-convert",
winningOutcomeID: "30000000-0000-0000-0000-000000000021",
now: now
),
]
return scenarios[index % scenarios.count]
}
private static func scenario(
eventID: String,
marketID: String,
yesOutcomeID: String,
noOutcomeID: String,
promptKey: String,
sourceRef: String,
winningOutcomeID: String,
now: Date
) -> HermesRound {
let lockAt = now.addingTimeInterval(15)
let settleAt = lockAt.addingTimeInterval(6)
let mediaURL = URL(string: "https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_ts/master.m3u8")!
let event = HermesEvent(
id: UUID(uuidString: eventID) ?? UUID(),
sportType: "football",
sourceRef: sourceRef,
titleEn: promptKey,
titleSv: promptKey,
status: "active",
previewStartMs: 0,
previewEndMs: 12_000,
revealStartMs: 12_000,
revealEndMs: 18_000,
lockAt: lockAt,
settleAt: settleAt
)
let media = HermesEventMedia(
id: UUID(),
eventId: event.id,
mediaType: "hls_main",
hlsMasterUrl: mediaURL,
posterUrl: nil,
durationMs: 18_000,
previewStartMs: 0,
previewEndMs: 12_000,
revealStartMs: 12_000,
revealEndMs: 18_000
)
let marketUUID = UUID(uuidString: marketID) ?? UUID()
let market = HermesMarket(
id: marketUUID,
eventId: event.id,
questionKey: promptKey,
marketType: promptKey,
status: "open",
lockAt: lockAt,
settlementRuleKey: "demo",
outcomes: [
HermesOutcome(
id: UUID(uuidString: yesOutcomeID) ?? UUID(),
marketId: marketUUID,
outcomeCode: "yes",
labelKey: "round.yes",
sortOrder: 0
),
HermesOutcome(
id: UUID(uuidString: noOutcomeID) ?? UUID(),
marketId: marketUUID,
outcomeCode: "no",
labelKey: "round.no",
sortOrder: 1
),
]
)
let oddsVersionID = UUID()
let oddsVersion = HermesOddsVersion(
id: oddsVersionID,
marketId: market.id,
versionNo: 1,
createdAt: now,
isCurrent: true,
odds: [
HermesOutcomeOdds(id: UUID(), oddsVersionId: oddsVersionID, outcomeId: market.outcomes[0].id, decimalOdds: 1.82, fractionalNum: 91, fractionalDen: 50),
HermesOutcomeOdds(id: UUID(), oddsVersionId: oddsVersionID, outcomeId: market.outcomes[1].id, decimalOdds: 2.05, fractionalNum: 41, fractionalDen: 20),
]
)
let settlement = HermesSettlement(
id: UUID(),
marketId: market.id,
settledAt: settleAt,
winningOutcomeId: UUID(uuidString: winningOutcomeID) ?? market.outcomes[0].id
)
return HermesRound(event: event, media: media, market: market, oddsVersion: oddsVersion, settlement: settlement)
}
}
+57 -29
View File
@@ -1,37 +1,36 @@
import SwiftUI
enum HermesAppMode: String {
case demo
case live
}
struct RootView: View {
@StateObject private var localization = LocalizationStore()
@EnvironmentObject private var analytics: HermesAnalyticsClient
@EnvironmentObject private var repository: HermesRepository
let onStartSession: (String) -> Void
let mode: HermesAppMode
let onModeSelected: (HermesAppMode) -> Void
let onStartSession: (String, HermesAppMode) -> Void
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
header
OnboardingView(onStartSession: { onStartSession(localization.localeCode) })
FeedView(onRetry: { onStartSession(localization.localeCode) })
RoundView(onRetry: { onStartSession(localization.localeCode) })
SessionView(onRetry: { onStartSession(localization.localeCode) })
SettingsView()
}
.padding(.horizontal, HermesTheme.screenPadding)
.padding(.vertical, 24)
}
.background(HermesTheme.background.ignoresSafeArea())
.navigationTitle(localization.string(for: "app.name"))
.navigationBarTitleDisplayMode(.inline)
ZStack(alignment: .top) {
HermesTheme.background
.ignoresSafeArea()
RoundView(onRetry: { onStartSession(localization.localeCode, mode) })
.ignoresSafeArea()
topChrome
}
.environmentObject(localization)
.onAppear {
analytics.track("app_opened", attributes: ["screen_name": "home"])
analytics.track("screen_viewed", attributes: ["screen_name": "home"])
analytics.track("app_opened", attributes: ["screen_name": "round", "mode": mode.rawValue])
analytics.track("screen_viewed", attributes: ["screen_name": "round", "mode": mode.rawValue])
}
.task(id: localization.localeCode) {
onStartSession(localization.localeCode)
.task(id: "\(localization.localeCode)-\(mode.rawValue)") {
onStartSession(localization.localeCode, mode)
}
.task {
while !Task.isCancelled {
@@ -41,20 +40,32 @@ struct RootView: View {
}
}
private var header: some View {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 8) {
private var topChrome: some View {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 6) {
Text(localization.string(for: "app.name"))
.font(.largeTitle.bold())
.font(.headline.weight(.bold))
.foregroundStyle(HermesTheme.textPrimary)
Text(localization.string(for: "app.subtitle"))
.font(.callout)
Text(mode == .demo ? localization.string(for: "mode.demo" ) : localization.string(for: "mode.live"))
.font(.caption.weight(.semibold))
.foregroundStyle(HermesTheme.textSecondary)
}
Spacer(minLength: 12)
localeToggle
VStack(alignment: .trailing, spacing: 10) {
modeToggle
localeToggle
}
}
.padding(.horizontal, HermesTheme.screenPadding)
.padding(.top, 16)
}
private var modeToggle: some View {
HStack(spacing: 8) {
modeButton(title: localization.string(for: "mode.demo"), appMode: .demo)
modeButton(title: localization.string(for: "mode.live"), appMode: .live)
}
}
@@ -65,6 +76,23 @@ struct RootView: View {
}
}
private func modeButton(title: String, appMode: HermesAppMode) -> some View {
let isSelected = mode == appMode
return Button {
onModeSelected(appMode)
analytics.track("mode_changed", attributes: ["mode": appMode.rawValue])
} label: {
Text(title)
.font(.caption.weight(.bold))
.padding(.horizontal, 12)
.padding(.vertical, 8)
.foregroundStyle(isSelected ? HermesTheme.background : HermesTheme.textPrimary)
.background(isSelected ? HermesTheme.accent : HermesTheme.surfaceElevated.opacity(0.9))
.clipShape(Capsule())
}
}
private func localeButton(title: String, localeCode: String) -> some View {
let isSelected = localization.localeCode == localeCode
@@ -77,7 +105,7 @@ struct RootView: View {
.padding(.horizontal, 12)
.padding(.vertical, 8)
.foregroundStyle(isSelected ? HermesTheme.background : HermesTheme.textPrimary)
.background(isSelected ? HermesTheme.accent : HermesTheme.surfaceElevated)
.background(isSelected ? HermesTheme.accent : HermesTheme.surfaceElevated.opacity(0.9))
.clipShape(Capsule())
}
}