redesign iOS round into fullscreen swipe flow
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user