scaffolding hermes flow and audit logging

This commit is contained in:
2026-04-09 18:54:10 +02:00
parent e401b6dbab
commit cf5316a2c1
59 changed files with 1830 additions and 593 deletions
+2 -2
View File
@@ -6,11 +6,11 @@ plugins {
}
android {
namespace = "com.hermes.study"
namespace = "com.hermes.app"
compileSdk = 35
defaultConfig {
applicationId = "com.hermes.study"
applicationId = "com.hermes.app"
minSdk = 26
targetSdk = 35
versionCode = 1
@@ -1,4 +1,4 @@
package com.hermes.study
package com.hermes.app
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -21,21 +21,22 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.foundation.layout.width
import com.hermes.study.R
import com.hermes.study.core.designsystem.HermesPalette
import com.hermes.study.core.designsystem.HermesSecondaryButton
import com.hermes.study.core.designsystem.HermesStudyTheme
import com.hermes.study.feature.feed.FeedScreen
import com.hermes.study.feature.feed.FeedViewModel
import com.hermes.study.feature.session.SessionView
import com.hermes.study.feature.session.SessionViewModel
import com.hermes.study.feature.settings.SettingsView
import com.hermes.study.feature.round.RoundScreen
import com.hermes.study.feature.round.RoundViewModel
import com.hermes.app.R
import com.hermes.app.core.designsystem.HermesPalette
import com.hermes.app.core.designsystem.HermesSecondaryButton
import com.hermes.app.core.designsystem.HermesTheme
import com.hermes.app.core.localization.HermesLocalizationStore
import com.hermes.app.feature.feed.FeedScreen
import com.hermes.app.feature.feed.FeedViewModel
import com.hermes.app.feature.session.SessionView
import com.hermes.app.feature.session.SessionViewModel
import com.hermes.app.feature.settings.SettingsView
import com.hermes.app.feature.round.RoundScreen
import com.hermes.app.feature.round.RoundViewModel
import androidx.compose.material3.ExperimentalMaterial3Api
@Composable
fun HermesStudyApp(container: HermesAppContainer) {
fun HermesApp(container: HermesAppContainer) {
val feedViewModel: FeedViewModel = viewModel(factory = container.feedViewModelFactory())
val roundViewModel: RoundViewModel = viewModel(factory = container.roundViewModelFactory())
val sessionViewModel: SessionViewModel = viewModel(factory = container.sessionViewModelFactory())
@@ -53,6 +54,7 @@ fun HermesStudyApp(container: HermesAppContainer) {
HermesAppBar(
title = container.localizationStore.string(localeCode, R.string.app_name),
localeCode = localeCode,
localizationStore = container.localizationStore,
onLocaleSelected = container.localizationStore::setLocale,
)
},
@@ -105,14 +107,23 @@ fun HermesStudyApp(container: HermesAppContainer) {
private fun HermesAppBar(
title: String,
localeCode: String,
localizationStore: HermesLocalizationStore,
onLocaleSelected: (String) -> Unit,
) {
androidx.compose.material3.CenterAlignedTopAppBar(
title = { Text(text = title) },
actions = {
HermesSecondaryButton(text = "EN", selected = localeCode == "en", onClick = { onLocaleSelected("en") })
HermesSecondaryButton(
text = localizationStore.localeName("en", localeCode),
selected = localeCode == "en",
onClick = { onLocaleSelected("en") },
)
androidx.compose.foundation.layout.Spacer(modifier = Modifier.width(8.dp))
HermesSecondaryButton(text = "SV", selected = localeCode == "sv", onClick = { onLocaleSelected("sv") })
HermesSecondaryButton(
text = localizationStore.localeName("sv", localeCode),
selected = localeCode == "sv",
onClick = { onLocaleSelected("sv") },
)
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = HermesPalette.colors.background
@@ -0,0 +1,101 @@
package com.hermes.app
import android.content.Context
import android.os.Build
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.hermes.app.core.analytics.HermesAnalyticsTracker
import com.hermes.app.core.localization.HermesLocalizationStore
import com.hermes.app.core.media.HermesPlayerCoordinator
import com.hermes.app.core.network.HermesApiClient
import com.hermes.app.data.HermesRepository
import com.hermes.app.domain.HermesSessionStartRequest
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.HttpUrl.Companion.toHttpUrl
class HermesAppContainer(context: Context) {
val localizationStore = HermesLocalizationStore(context.applicationContext)
val analyticsTracker = HermesAnalyticsTracker()
val apiClient = HermesApiClient(BuildConfig.API_BASE_URL.toHttpUrl())
val repository = HermesRepository(apiClient)
val playerCoordinator = HermesPlayerCoordinator(context.applicationContext)
private val analyticsScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val analyticsFlushMutex = Mutex()
private val analyticsSessionJob: Job
private val analyticsTickerJob: Job
init {
analyticsSessionJob = analyticsScope.launch {
repository.currentSession.filterNotNull().collect {
flushAnalytics()
}
}
analyticsTickerJob = analyticsScope.launch {
while (isActive) {
delay(5_000)
flushAnalytics()
}
}
}
fun feedViewModelFactory(): ViewModelProvider.Factory = HermesViewModelFactory {
com.hermes.app.feature.feed.FeedViewModel(repository, localizationStore, analyticsTracker)
}
fun roundViewModelFactory(): ViewModelProvider.Factory = HermesViewModelFactory {
com.hermes.app.feature.round.RoundViewModel(repository, localizationStore, analyticsTracker, playerCoordinator)
}
fun sessionViewModelFactory(): ViewModelProvider.Factory = HermesViewModelFactory {
com.hermes.app.feature.session.SessionViewModel(
repository = repository,
localizationStore = localizationStore,
analyticsTracker = analyticsTracker,
sessionRequestFactory = ::buildSessionStartRequest,
)
}
private fun buildSessionStartRequest(localeCode: String): HermesSessionStartRequest {
return HermesSessionStartRequest(
localeCode = localeCode,
devicePlatform = "android",
deviceModel = Build.MODEL,
osVersion = Build.VERSION.RELEASE,
appVersion = BuildConfig.VERSION_NAME,
)
}
suspend fun flushAnalytics() {
analyticsFlushMutex.withLock {
if (repository.currentSession.value == null) {
return
}
val events = analyticsTracker.pendingEventsSnapshot()
if (events.isEmpty()) {
return
}
runCatching {
repository.submitAnalyticsBatch(analyticsTracker.toBatchRequest(events))
}.onSuccess {
analyticsTracker.markDelivered(events.map { it.id }.toSet())
}
}
}
}
class HermesViewModelFactory<T : ViewModel>(private val creator: () -> T) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = creator() as T
}
@@ -1,4 +1,4 @@
package com.hermes.study
package com.hermes.app
import android.app.Application
@@ -1,9 +1,9 @@
package com.hermes.study
package com.hermes.app
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import com.hermes.study.core.designsystem.HermesStudyTheme
import com.hermes.app.core.designsystem.HermesTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -12,8 +12,8 @@ class MainActivity : ComponentActivity() {
val app = application as HermesApplication
setContent {
HermesStudyTheme {
HermesStudyApp(app.container)
HermesTheme {
HermesApp(app.container)
}
}
}
@@ -0,0 +1,58 @@
package com.hermes.app.core.analytics
import android.util.Log
import com.hermes.app.domain.HermesAnalyticsAttributeInput
import com.hermes.app.domain.HermesAnalyticsBatchRequest
import com.hermes.app.domain.HermesAnalyticsEventInput
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import java.util.UUID
data class HermesTrackedEvent(
val id: UUID,
val name: String,
val attributes: Map<String, String>,
val timestamp: Instant,
)
class HermesAnalyticsTracker {
private val eventsFlow = MutableSharedFlow<HermesTrackedEvent>(replay = 64, extraBufferCapacity = 64)
private val pendingEvents = mutableListOf<HermesTrackedEvent>()
val events: SharedFlow<HermesTrackedEvent> = eventsFlow.asSharedFlow()
@Synchronized
fun track(name: String, attributes: Map<String, String> = emptyMap()) {
val event = HermesTrackedEvent(UUID.randomUUID(), name, attributes, Clock.System.now())
pendingEvents += event
eventsFlow.tryEmit(event)
Log.d("HermesAnalytics", "${event.name} ${event.attributes}")
}
@Synchronized
fun pendingEventsSnapshot(): List<HermesTrackedEvent> {
return pendingEvents.toList()
}
@Synchronized
fun markDelivered(deliveredIds: Set<UUID>) {
pendingEvents.removeAll { it.id in deliveredIds }
}
fun toBatchRequest(events: List<HermesTrackedEvent>): HermesAnalyticsBatchRequest {
return HermesAnalyticsBatchRequest(
events = events.map { event ->
HermesAnalyticsEventInput(
eventName = event.name,
occurredAt = event.timestamp,
attributes = event.attributes.map { (key, value) ->
HermesAnalyticsAttributeInput(key = key, value = value)
},
)
},
)
}
}
@@ -1,4 +1,4 @@
package com.hermes.study.core.designsystem
package com.hermes.app.core.designsystem
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -57,7 +57,7 @@ private val HermesShapes = androidx.compose.material3.Shapes(
)
@Composable
fun HermesStudyTheme(content: @Composable () -> Unit) {
fun HermesTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = HermesColorScheme,
typography = MaterialTheme.typography,
@@ -1,8 +1,8 @@
package com.hermes.study.core.errors
package com.hermes.app.core.errors
import com.hermes.study.R
import com.hermes.study.core.localization.HermesLocalizationStore
import com.hermes.study.core.network.HermesApiException
import com.hermes.app.R
import com.hermes.app.core.localization.HermesLocalizationStore
import com.hermes.app.core.network.HermesApiException
import java.io.IOException
import kotlinx.coroutines.CancellationException
@@ -1,4 +1,4 @@
package com.hermes.study.core.gestures
package com.hermes.app.core.gestures
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.ui.Modifier
@@ -1,4 +1,4 @@
package com.hermes.study.core.haptics
package com.hermes.app.core.haptics
import android.view.HapticFeedbackConstants
import android.view.View
@@ -1,7 +1,8 @@
package com.hermes.study.core.localization
package com.hermes.app.core.localization
import android.content.Context
import android.content.res.Configuration
import com.hermes.app.R
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -21,6 +22,15 @@ class HermesLocalizationStore(private val appContext: Context) {
fun string(localeCode: String, resId: Int): String = localizedContext(normalize(localeCode)).getString(resId)
fun localeName(targetLocaleCode: String, displayLocaleCode: String = localeCode.value): String {
val resId = when (normalize(targetLocaleCode)) {
"sv" -> R.string.locale_swedish
else -> R.string.locale_english
}
return string(displayLocaleCode, resId)
}
private fun localizedContext(localeCode: String): Context {
val configuration = Configuration(appContext.resources.configuration)
configuration.setLocale(Locale.forLanguageTag(localeCode))
@@ -1,4 +1,4 @@
package com.hermes.study.core.media
package com.hermes.app.core.media
import android.content.Context
import android.net.Uri
@@ -1,4 +1,4 @@
package com.hermes.study.core.media
package com.hermes.app.core.media
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -7,7 +7,7 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.ui.PlayerView
@Composable
fun StudyVideoPlayerView(
fun HermesVideoPlayerView(
coordinator: HermesPlayerCoordinator,
modifier: Modifier = Modifier,
) {
@@ -1,16 +1,18 @@
package com.hermes.study.core.network
package com.hermes.app.core.network
import com.hermes.study.domain.HermesAnalyticsBatchRequest
import com.hermes.study.domain.HermesBetIntentRequest
import com.hermes.study.domain.HermesBetIntentResponse
import com.hermes.study.domain.HermesEvent
import com.hermes.study.domain.HermesExperimentConfig
import com.hermes.study.domain.HermesLocalizationBundle
import com.hermes.study.domain.HermesMarket
import com.hermes.study.domain.HermesOddsVersion
import com.hermes.study.domain.HermesSessionResponse
import com.hermes.study.domain.HermesSessionStartRequest
import com.hermes.study.domain.HermesSettlement
import com.hermes.app.domain.HermesAnalyticsBatchRequest
import com.hermes.app.domain.HermesBetIntentRequest
import com.hermes.app.domain.HermesBetIntentResponse
import com.hermes.app.domain.HermesHealthResponse
import com.hermes.app.domain.HermesEvent
import com.hermes.app.domain.HermesEventManifest
import com.hermes.app.domain.HermesExperimentConfig
import com.hermes.app.domain.HermesLocalizationBundle
import com.hermes.app.domain.HermesMarket
import com.hermes.app.domain.HermesOddsVersion
import com.hermes.app.domain.HermesSessionResponse
import com.hermes.app.domain.HermesSessionStartRequest
import com.hermes.app.domain.HermesSettlement
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
@@ -37,13 +39,15 @@ class HermesApiClient(
suspend fun startSession(request: HermesSessionStartRequest): HermesSessionResponse = post("api/v1/session/start", request)
suspend fun health(): HermesHealthResponse = get("health")
suspend fun endSession(): HermesSessionResponse = post("api/v1/session/end")
suspend fun currentSession(): HermesSessionResponse = get("api/v1/session/me")
suspend fun nextEvent(): HermesEvent = get("api/v1/feed/next")
suspend fun eventManifest(eventId: String): com.hermes.study.domain.HermesEventManifest = get("api/v1/events/$eventId/manifest")
suspend fun eventManifest(eventId: String): HermesEventManifest = get("api/v1/events/$eventId/manifest")
suspend fun markets(eventId: String): List<HermesMarket> = get("api/v1/events/$eventId/markets")
@@ -1,3 +1,3 @@
package com.hermes.study.core.network
package com.hermes.app.core.network
// Placeholder for future API-specific mapping helpers.
@@ -1,17 +1,19 @@
package com.hermes.study.data
package com.hermes.app.data
import com.hermes.study.core.network.HermesApiClient
import com.hermes.study.domain.HermesAnalyticsBatchRequest
import com.hermes.study.domain.HermesBetIntentRequest
import com.hermes.study.domain.HermesBetIntentResponse
import com.hermes.study.domain.HermesExperimentConfig
import com.hermes.study.domain.HermesLocalizationBundle
import com.hermes.study.domain.HermesMarket
import com.hermes.study.domain.HermesOddsVersion
import com.hermes.study.domain.HermesSessionResponse
import com.hermes.study.domain.HermesSessionStartRequest
import com.hermes.study.domain.HermesSettlement
import com.hermes.study.domain.StudyRound
import com.hermes.app.core.network.HermesApiClient
import com.hermes.app.domain.HermesAnalyticsBatchRequest
import com.hermes.app.domain.HermesBetIntentRequest
import com.hermes.app.domain.HermesBetIntentResponse
import com.hermes.app.domain.HermesExperimentConfig
import com.hermes.app.domain.HermesLocalizationBundle
import com.hermes.app.domain.HermesMarket
import com.hermes.app.domain.HermesOddsVersion
import com.hermes.app.domain.HermesRound
import com.hermes.app.domain.HermesSessionResponse
import com.hermes.app.domain.HermesSessionStartRequest
import com.hermes.app.domain.HermesSettlement
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -22,18 +24,20 @@ import kotlinx.coroutines.sync.withLock
class HermesRepository(private val apiClient: HermesApiClient) {
private val sessionMutex = Mutex()
private val _currentSession = MutableStateFlow<HermesSessionResponse?>(null)
private val _currentRound = MutableStateFlow<StudyRound?>(null)
private val _currentRound = MutableStateFlow<HermesRound?>(null)
private val _isLoading = MutableStateFlow(true)
private val _errorCause = MutableStateFlow<Throwable?>(null)
private val _serverClockOffsetMs = MutableStateFlow<Long?>(null)
val currentSession: StateFlow<HermesSessionResponse?> = _currentSession.asStateFlow()
val currentRound: StateFlow<StudyRound?> = _currentRound.asStateFlow()
val currentRound: StateFlow<HermesRound?> = _currentRound.asStateFlow()
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
val errorCause: StateFlow<Throwable?> = _errorCause.asStateFlow()
val serverClockOffsetMs: StateFlow<Long?> = _serverClockOffsetMs.asStateFlow()
fun observeRound(): Flow<StudyRound?> = currentRound
fun observeRound(): Flow<HermesRound?> = currentRound
fun observeFeed(): Flow<StudyRound?> = currentRound
fun observeFeed(): Flow<HermesRound?> = currentRound
suspend fun bootstrap(request: HermesSessionStartRequest): HermesSessionResponse {
return sessionMutex.withLock {
@@ -41,6 +45,11 @@ class HermesRepository(private val apiClient: HermesApiClient) {
_errorCause.value = null
try {
try {
syncClock()
} catch (_: Throwable) {
// Clock sync is best-effort.
}
val session = _currentSession.value ?: startSession(request)
if (_currentRound.value == null) {
_currentRound.value = loadRoundFromNetwork()
@@ -56,12 +65,17 @@ class HermesRepository(private val apiClient: HermesApiClient) {
}
}
suspend fun refreshRoundFromNetwork(): StudyRound {
suspend fun refreshRoundFromNetwork(): HermesRound {
return sessionMutex.withLock {
_isLoading.value = true
_errorCause.value = null
try {
try {
syncClock()
} catch (_: Throwable) {
// Clock sync is best-effort.
}
val round = loadRoundFromNetwork()
_currentRound.value = round
round
@@ -110,7 +124,17 @@ class HermesRepository(private val apiClient: HermesApiClient) {
apiClient.submitAnalyticsBatch(request)
}
private suspend fun loadRoundFromNetwork(): StudyRound {
fun serverNow(): Instant {
val offsetMs = _serverClockOffsetMs.value ?: 0L
return Instant.fromEpochMilliseconds(Clock.System.now().toEpochMilliseconds() + offsetMs)
}
private suspend fun syncClock() {
val health = apiClient.health()
_serverClockOffsetMs.value = health.serverTime.toEpochMilliseconds() - Clock.System.now().toEpochMilliseconds()
}
private suspend fun loadRoundFromNetwork(): HermesRound {
val event = apiClient.nextEvent()
val manifest = apiClient.eventManifest(event.id)
val media = manifest.media.firstOrNull { it.mediaType == "hls_main" } ?: manifest.media.firstOrNull()
@@ -120,7 +144,7 @@ class HermesRepository(private val apiClient: HermesApiClient) {
val oddsVersion = apiClient.currentOdds(market.id)
val settlement = apiClient.settlement(event.id)
return StudyRound(
return HermesRound(
event = event,
media = media,
market = market,
@@ -1,4 +1,4 @@
package com.hermes.study.domain
package com.hermes.app.domain
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
@@ -28,6 +28,18 @@ data class HermesSessionResponse(
val devicePlatform: String,
)
@Serializable
data class HermesHealthResponse(
val status: String,
val serviceName: String,
val environment: String,
val version: String,
val uptimeMs: Long,
val serverTime: Instant,
val databaseReady: Boolean,
val redisReady: Boolean,
)
@Serializable
data class HermesEvent(
val id: String,
@@ -163,7 +175,7 @@ data class HermesLocalizationBundle(
val values: Map<String, String>,
)
data class StudyRound(
data class HermesRound(
val event: HermesEvent,
val media: HermesEventMedia,
val market: HermesMarket,
@@ -1,4 +1,4 @@
package com.hermes.study.feature.feed
package com.hermes.app.feature.feed
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -17,11 +17,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.unit.dp
import com.hermes.study.core.designsystem.HermesCard
import com.hermes.study.core.designsystem.HermesColors
import com.hermes.study.core.designsystem.HermesMetricChip
import com.hermes.study.core.designsystem.HermesPrimaryButton
import com.hermes.study.core.designsystem.HermesSectionHeader
import com.hermes.app.core.designsystem.HermesCard
import com.hermes.app.core.designsystem.HermesColors
import com.hermes.app.core.designsystem.HermesMetricChip
import com.hermes.app.core.designsystem.HermesPrimaryButton
import com.hermes.app.core.designsystem.HermesSectionHeader
@Composable
fun FeedScreen(
@@ -1,13 +1,13 @@
package com.hermes.study.feature.feed
package com.hermes.app.feature.feed
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.hermes.study.R
import com.hermes.study.core.analytics.HermesAnalyticsTracker
import com.hermes.study.core.errors.mapUserFacingError
import com.hermes.study.core.localization.HermesLocalizationStore
import com.hermes.study.data.HermesRepository
import com.hermes.study.domain.StudyRound
import com.hermes.app.R
import com.hermes.app.core.analytics.HermesAnalyticsTracker
import com.hermes.app.core.errors.mapUserFacingError
import com.hermes.app.core.localization.HermesLocalizationStore
import com.hermes.app.data.HermesRepository
import com.hermes.app.domain.HermesRound
import java.util.Locale
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.SharingStarted
@@ -15,7 +15,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
data class FeedUiState(
@@ -35,7 +34,7 @@ data class FeedUiState(
)
class FeedViewModel(
repository: HermesRepository,
private val repository: HermesRepository,
private val localizationStore: HermesLocalizationStore,
private val analyticsTracker: HermesAnalyticsTracker,
) : ViewModel() {
@@ -43,7 +42,7 @@ class FeedViewModel(
private val localeFlow = localizationStore.localeCode
private val nowFlow = flow {
while (true) {
emit(Clock.System.now())
emit(repository.serverNow())
delay(1_000)
}
}
@@ -58,7 +57,7 @@ class FeedViewModel(
localeCode = localeFlow.value,
isLoading = repository.isLoading.value,
errorCause = repository.errorCause.value,
now = Clock.System.now(),
now = repository.serverNow(),
),
)
@@ -79,7 +78,7 @@ class FeedViewModel(
)
}
private fun buildUiState(round: StudyRound?, localeCode: String, isLoading: Boolean, errorCause: Throwable?, now: Instant): FeedUiState {
private fun buildUiState(round: HermesRound?, localeCode: String, isLoading: Boolean, errorCause: Throwable?, now: Instant): FeedUiState {
val bannerMessage = mapUserFacingError(localizationStore, localeCode, errorCause)
val hasRound = round != null
val showLoading = isLoading && !hasRound
@@ -110,7 +109,7 @@ class FeedViewModel(
return localizationStore.string(localeCode, resId)
}
private fun localizedEventTitle(round: StudyRound, localeCode: String): String {
private fun localizedEventTitle(round: HermesRound, localeCode: String): String {
return if (localeCode == "sv") round.event.titleSv else round.event.titleEn
}
@@ -122,7 +121,7 @@ class FeedViewModel(
return String.format(Locale.US, "%02d:%02d", minutes, seconds)
}
private fun formatOdds(round: StudyRound): String {
private fun formatOdds(round: HermesRound): String {
val oddsByOutcomeId = round.oddsVersion.odds.associateBy { it.outcomeId }
return round.market.outcomes
.sortedBy { it.sortOrder }
@@ -1,4 +1,4 @@
package com.hermes.study.feature.result
package com.hermes.app.feature.result
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -14,10 +14,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.hermes.study.core.designsystem.HermesColors
import com.hermes.study.core.designsystem.HermesMetricChip
import com.hermes.study.core.designsystem.HermesPrimaryButton
import com.hermes.study.core.designsystem.HermesSectionHeader
import com.hermes.app.core.designsystem.HermesColors
import com.hermes.app.core.designsystem.HermesMetricChip
import com.hermes.app.core.designsystem.HermesPrimaryButton
import com.hermes.app.core.designsystem.HermesSectionHeader
@Composable
fun ResultPanel(
@@ -1,4 +1,4 @@
package com.hermes.study.feature.reveal
package com.hermes.app.feature.reveal
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -6,12 +6,12 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.hermes.study.core.designsystem.HermesMetricChip
import com.hermes.study.core.designsystem.HermesPrimaryButton
import com.hermes.study.core.designsystem.HermesSectionHeader
import com.hermes.app.core.designsystem.HermesMetricChip
import com.hermes.app.core.designsystem.HermesPrimaryButton
import com.hermes.app.core.designsystem.HermesSectionHeader
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import com.hermes.study.core.designsystem.HermesColors
import com.hermes.app.core.designsystem.HermesColors
@Composable
fun RevealPanel(
@@ -1,4 +1,4 @@
package com.hermes.study.feature.round
package com.hermes.app.feature.round
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -18,16 +18,16 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.hermes.study.core.designsystem.HermesColors
import com.hermes.study.core.designsystem.HermesCountdownBadge
import com.hermes.study.core.designsystem.HermesCard
import com.hermes.study.core.designsystem.HermesMetricChip
import com.hermes.study.core.media.HermesPlayerCoordinator
import com.hermes.study.core.media.StudyVideoPlayerView
import com.hermes.study.feature.reveal.RevealPanel
import com.hermes.study.feature.result.ResultPanel
import com.hermes.study.feature.selection.SelectionPanel
import com.hermes.study.core.designsystem.HermesPrimaryButton
import com.hermes.app.core.designsystem.HermesColors
import com.hermes.app.core.designsystem.HermesCountdownBadge
import com.hermes.app.core.designsystem.HermesCard
import com.hermes.app.core.designsystem.HermesMetricChip
import com.hermes.app.core.media.HermesPlayerCoordinator
import com.hermes.app.core.media.HermesVideoPlayerView
import com.hermes.app.feature.reveal.RevealPanel
import com.hermes.app.feature.result.ResultPanel
import com.hermes.app.feature.selection.SelectionPanel
import com.hermes.app.core.designsystem.HermesPrimaryButton
@Composable
fun RoundScreen(
@@ -42,7 +42,7 @@ fun RoundScreen(
) {
HermesCard(modifier = modifier, elevated = true) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
com.hermes.study.core.designsystem.HermesSectionHeader(title = uiState.title, subtitle = uiState.subtitle)
com.hermes.app.core.designsystem.HermesSectionHeader(title = uiState.title, subtitle = uiState.subtitle)
when {
uiState.isLoading && !uiState.hasRound -> RoundLoadingState(
@@ -67,7 +67,7 @@ fun RoundScreen(
.height(220.dp),
) {
if (uiState.hasRound) {
StudyVideoPlayerView(
HermesVideoPlayerView(
coordinator = playerCoordinator,
modifier = Modifier.fillMaxSize(),
)
@@ -1,17 +1,17 @@
package com.hermes.study.feature.round
package com.hermes.app.feature.round
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.hermes.study.R
import com.hermes.study.core.analytics.HermesAnalyticsTracker
import com.hermes.study.core.errors.mapUserFacingError
import com.hermes.study.core.localization.HermesLocalizationStore
import com.hermes.study.core.media.HermesPlayerCoordinator
import com.hermes.study.data.HermesRepository
import com.hermes.study.domain.HermesOutcome
import com.hermes.study.domain.HermesBetIntentRequest
import com.hermes.study.domain.StudyRound
import com.hermes.study.feature.selection.SelectionOptionUi
import com.hermes.app.R
import com.hermes.app.core.analytics.HermesAnalyticsTracker
import com.hermes.app.core.errors.mapUserFacingError
import com.hermes.app.core.localization.HermesLocalizationStore
import com.hermes.app.core.media.HermesPlayerCoordinator
import com.hermes.app.data.HermesRepository
import com.hermes.app.domain.HermesOutcome
import com.hermes.app.domain.HermesBetIntentRequest
import com.hermes.app.domain.HermesRound
import com.hermes.app.feature.selection.SelectionOptionUi
import java.util.Locale
import java.util.UUID
import kotlinx.coroutines.Job
@@ -74,7 +74,7 @@ data class RoundUiState(
)
private data class RoundUiInputs(
val round: StudyRound?,
val round: HermesRound?,
val localeCode: String,
val phase: RoundPhase,
val selectedOutcomeId: String?,
@@ -98,7 +98,7 @@ class RoundViewModel(
private val isSubmittingSelectionFlow = MutableStateFlow(false)
private val nowFlow = flow {
while (true) {
emit(Clock.System.now())
emit(repository.serverNow())
delay(1_000)
}
}
@@ -128,7 +128,7 @@ class RoundViewModel(
}.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = buildUiState(
initialValue = buildUiState(
RoundUiInputs(
round = roundFlow.value,
localeCode = localeFlow.value,
@@ -139,7 +139,7 @@ class RoundViewModel(
actionMessage = actionMessageFlow.value,
isSubmitting = isSubmittingSelectionFlow.value,
),
Clock.System.now(),
repository.serverNow(),
),
)
@@ -160,7 +160,7 @@ class RoundViewModel(
return
}
if (isTimerLocked(round, Clock.System.now())) {
if (isTimerLocked(round, repository.serverNow())) {
return
}
@@ -182,7 +182,7 @@ class RoundViewModel(
return
}
if (isTimerLocked(round, Clock.System.now())) {
if (isTimerLocked(round, repository.serverNow())) {
return
}
@@ -280,7 +280,7 @@ class RoundViewModel(
}
}
private fun startPreview(round: StudyRound) {
private fun startPreview(round: HermesRound) {
transitionJob?.cancel()
phaseFlow.value = RoundPhase.PREVIEW
selectedOutcomeIdFlow.value = null
@@ -368,7 +368,7 @@ class RoundViewModel(
return localizationStore.string(localeCode, resId)
}
private fun selectionOptions(round: StudyRound, localeCode: String): List<SelectionOptionUi> {
private fun selectionOptions(round: HermesRound, localeCode: String): List<SelectionOptionUi> {
val oddsByOutcomeId = round.oddsVersion.odds.associateBy { it.outcomeId }
return round.market.outcomes
@@ -391,7 +391,7 @@ class RoundViewModel(
}
}
private fun resolveSelectedOutcomeTitle(round: StudyRound, localeCode: String, selectedOutcomeId: String?): String {
private fun resolveSelectedOutcomeTitle(round: HermesRound, localeCode: String, selectedOutcomeId: String?): String {
if (selectedOutcomeId == null) {
return localized(localeCode, R.string.round_selection_prompt)
}
@@ -400,12 +400,12 @@ class RoundViewModel(
?: localized(localeCode, R.string.round_selection_prompt)
}
private fun resolveWinningOutcomeTitle(round: StudyRound, localeCode: String): String {
private fun resolveWinningOutcomeTitle(round: HermesRound, localeCode: String): String {
return round.market.outcomes.firstOrNull { it.id == round.settlement.winningOutcomeId }?.let { outcomeTitle(localeCode, it) }
?: localized(localeCode, R.string.round_selection_prompt)
}
private fun oddsSummary(round: StudyRound): String {
private fun oddsSummary(round: HermesRound): String {
val oddsByOutcomeId = round.oddsVersion.odds.associateBy { it.outcomeId }
return round.market.outcomes
.sortedBy { it.sortOrder }
@@ -436,7 +436,7 @@ class RoundViewModel(
}
}
private fun isTimerLocked(round: StudyRound?, now: Instant): Boolean {
private fun isTimerLocked(round: HermesRound?, now: Instant): Boolean {
return round != null && now.toEpochMilliseconds() >= round.event.lockAt.toEpochMilliseconds()
}
@@ -444,7 +444,7 @@ class RoundViewModel(
return mapOf("screen_name" to "round", "outcome_id" to outcomeId)
}
private fun roundAnalyticsAttributes(round: StudyRound): Map<String, String> {
private fun roundAnalyticsAttributes(round: HermesRound): Map<String, String> {
return mapOf(
"screen_name" to "round",
"event_id" to round.event.id,
@@ -1,4 +1,4 @@
package com.hermes.study.feature.selection
package com.hermes.app.feature.selection
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -23,8 +23,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.hermes.study.core.designsystem.HermesColors
import com.hermes.study.core.designsystem.HermesPrimaryButton
import com.hermes.app.core.designsystem.HermesColors
import com.hermes.app.core.designsystem.HermesPrimaryButton
data class SelectionOptionUi(
val id: String,
@@ -1,4 +1,4 @@
package com.hermes.study.feature.session
package com.hermes.app.feature.session
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -7,14 +7,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.hermes.study.R
import com.hermes.study.core.designsystem.HermesCard
import com.hermes.app.R
import com.hermes.app.core.designsystem.HermesCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import com.hermes.study.core.designsystem.HermesColors
import com.hermes.study.core.designsystem.HermesPrimaryButton
import com.hermes.study.core.designsystem.HermesSectionHeader
import com.hermes.study.core.localization.HermesLocalizationStore
import com.hermes.app.core.designsystem.HermesColors
import com.hermes.app.core.designsystem.HermesPrimaryButton
import com.hermes.app.core.designsystem.HermesSectionHeader
import com.hermes.app.core.localization.HermesLocalizationStore
@Composable
fun SessionView(
@@ -64,7 +64,7 @@ fun SessionView(
)
SessionRow(
label = localizationStore.string(uiState.localeCode, R.string.session_locale_label),
value = uiState.localeCode.uppercase(),
value = localizationStore.localeName(uiState.localeCode),
)
SessionRow(
label = localizationStore.string(uiState.localeCode, R.string.session_started_label),
@@ -1,13 +1,13 @@
package com.hermes.study.feature.session
package com.hermes.app.feature.session
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.hermes.study.R
import com.hermes.study.core.analytics.HermesAnalyticsTracker
import com.hermes.study.core.errors.mapUserFacingError
import com.hermes.study.core.localization.HermesLocalizationStore
import com.hermes.study.data.HermesRepository
import com.hermes.study.domain.HermesSessionStartRequest
import com.hermes.app.R
import com.hermes.app.core.analytics.HermesAnalyticsTracker
import com.hermes.app.core.errors.mapUserFacingError
import com.hermes.app.core.localization.HermesLocalizationStore
import com.hermes.app.data.HermesRepository
import com.hermes.app.domain.HermesSessionStartRequest
import java.util.Locale
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
@@ -1,4 +1,4 @@
package com.hermes.study.feature.settings
package com.hermes.app.feature.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -8,14 +8,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.hermes.study.R
import com.hermes.study.core.designsystem.HermesCard
import com.hermes.study.core.designsystem.HermesColors
import com.hermes.app.R
import com.hermes.app.core.designsystem.HermesCard
import com.hermes.app.core.designsystem.HermesColors
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import com.hermes.study.core.localization.HermesLocalizationStore
import com.hermes.study.core.designsystem.HermesSectionHeader
import java.util.Locale
import com.hermes.app.core.localization.HermesLocalizationStore
import com.hermes.app.core.designsystem.HermesSectionHeader
@Composable
fun SettingsView(
@@ -32,7 +31,7 @@ fun SettingsView(
SettingRow(
label = localizationStore.string(localeCode, R.string.settings_language),
value = localeCode.uppercase(Locale.US),
value = localizationStore.localeName(localeCode),
)
SettingRow(
label = localizationStore.string(localeCode, R.string.settings_haptics),
@@ -1,53 +0,0 @@
package com.hermes.study
import android.content.Context
import android.os.Build
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.hermes.study.core.analytics.HermesAnalyticsTracker
import com.hermes.study.core.localization.HermesLocalizationStore
import com.hermes.study.core.media.HermesPlayerCoordinator
import com.hermes.study.core.network.HermesApiClient
import com.hermes.study.data.HermesRepository
import com.hermes.study.domain.HermesSessionStartRequest
import okhttp3.HttpUrl.Companion.toHttpUrl
class HermesAppContainer(context: Context) {
val localizationStore = HermesLocalizationStore(context.applicationContext)
val analyticsTracker = HermesAnalyticsTracker()
val apiClient = HermesApiClient(BuildConfig.API_BASE_URL.toHttpUrl())
val repository = HermesRepository(apiClient)
val playerCoordinator = HermesPlayerCoordinator(context.applicationContext)
fun feedViewModelFactory(): ViewModelProvider.Factory = HermesViewModelFactory {
com.hermes.study.feature.feed.FeedViewModel(repository, localizationStore, analyticsTracker)
}
fun roundViewModelFactory(): ViewModelProvider.Factory = HermesViewModelFactory {
com.hermes.study.feature.round.RoundViewModel(repository, localizationStore, analyticsTracker, playerCoordinator)
}
fun sessionViewModelFactory(): ViewModelProvider.Factory = HermesViewModelFactory {
com.hermes.study.feature.session.SessionViewModel(
repository = repository,
localizationStore = localizationStore,
analyticsTracker = analyticsTracker,
sessionRequestFactory = ::buildSessionStartRequest,
)
}
private fun buildSessionStartRequest(localeCode: String): HermesSessionStartRequest {
return HermesSessionStartRequest(
localeCode = localeCode,
devicePlatform = "android",
deviceModel = Build.MODEL,
osVersion = Build.VERSION.RELEASE,
appVersion = BuildConfig.VERSION_NAME,
)
}
}
class HermesViewModelFactory<T : ViewModel>(private val creator: () -> T) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = creator() as T
}
@@ -1,26 +0,0 @@
package com.hermes.study.core.analytics
import android.util.Log
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
data class HermesTrackedEvent(
val name: String,
val attributes: Map<String, String>,
val timestamp: Instant,
)
class HermesAnalyticsTracker {
private val eventsFlow = MutableSharedFlow<HermesTrackedEvent>(extraBufferCapacity = 64)
val events: SharedFlow<HermesTrackedEvent> = eventsFlow.asSharedFlow()
fun track(name: String, attributes: Map<String, String> = emptyMap()) {
val event = HermesTrackedEvent(name, attributes, Clock.System.now())
eventsFlow.tryEmit(event)
Log.d("HermesAnalytics", "${event.name} ${event.attributes}")
}
}
@@ -1,124 +0,0 @@
package com.hermes.study.data
import com.hermes.study.domain.HermesAnalyticsAttributeInput
import com.hermes.study.domain.HermesAnalyticsBatchRequest
import com.hermes.study.domain.HermesAnalyticsEventInput
import com.hermes.study.domain.HermesBetIntentRequest
import com.hermes.study.domain.HermesBetIntentResponse
import com.hermes.study.domain.HermesEvent
import com.hermes.study.domain.HermesEventMedia
import com.hermes.study.domain.HermesExperimentConfig
import com.hermes.study.domain.HermesLocalizationBundle
import com.hermes.study.domain.HermesMarket
import com.hermes.study.domain.HermesOddsVersion
import com.hermes.study.domain.HermesOutcome
import com.hermes.study.domain.HermesOutcomeOdds
import com.hermes.study.domain.HermesSessionResponse
import com.hermes.study.domain.HermesSettlement
import com.hermes.study.domain.StudyRound
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
object SampleStudyData {
fun round(): StudyRound {
val now: Instant = Clock.System.now()
val lockAt = Instant.fromEpochMilliseconds(now.toEpochMilliseconds() + 47_000)
val settleAt = Instant.fromEpochMilliseconds(now.toEpochMilliseconds() + 92_000)
val event = HermesEvent(
id = "11111111-1111-1111-1111-111111111111",
sportType = "football",
sourceRef = "sample-event-001",
titleEn = "Late winner chance",
titleSv = "Möjlighet till segermål",
status = "prefetch_ready",
previewStartMs = 0,
previewEndMs = 45_000,
revealStartMs = 50_000,
revealEndMs = 90_000,
lockAt = lockAt,
settleAt = settleAt,
)
val media = HermesEventMedia(
id = "33333333-3333-3333-3333-333333333333",
eventId = event.id,
mediaType = "hls_main",
hlsMasterUrl = "https://cdn.example.com/hermes/sample-event/master.m3u8",
posterUrl = "https://cdn.example.com/hermes/sample-event/poster.jpg",
durationMs = 90_000,
previewStartMs = 0,
previewEndMs = 45_000,
revealStartMs = 50_000,
revealEndMs = 90_000,
)
val home = HermesOutcome(
id = "44444444-4444-4444-4444-444444444444",
marketId = "22222222-2222-2222-2222-222222222222",
outcomeCode = "home",
labelKey = "round.home",
sortOrder = 1,
)
val away = HermesOutcome(
id = "55555555-5555-5555-5555-555555555555",
marketId = "22222222-2222-2222-2222-222222222222",
outcomeCode = "away",
labelKey = "round.away",
sortOrder = 2,
)
val market = HermesMarket(
id = "22222222-2222-2222-2222-222222222222",
eventId = event.id,
questionKey = "market.sample.winner",
marketType = "winner",
status = "open",
lockAt = lockAt,
settlementRuleKey = "settle_on_match_winner",
outcomes = listOf(home, away),
)
val oddsVersion = HermesOddsVersion(
id = "66666666-6666-6666-6666-666666666666",
marketId = market.id,
versionNo = 1,
createdAt = now,
isCurrent = true,
odds = listOf(
HermesOutcomeOdds(
id = "77777777-7777-7777-7777-777777777777",
oddsVersionId = "66666666-6666-6666-6666-666666666666",
outcomeId = home.id,
decimalOdds = 1.85,
fractionalNum = 17,
fractionalDen = 20,
),
HermesOutcomeOdds(
id = "88888888-8888-8888-8888-888888888888",
oddsVersionId = "66666666-6666-6666-6666-666666666666",
outcomeId = away.id,
decimalOdds = 2.05,
fractionalNum = 21,
fractionalDen = 20,
),
),
)
val settlement = HermesSettlement(
id = "99999999-9999-9999-9999-999999999999",
marketId = market.id,
settledAt = settleAt,
winningOutcomeId = home.id,
)
return StudyRound(
event = event,
media = media,
market = market,
oddsVersion = oddsVersion,
settlement = settlement,
)
}
}
@@ -1,7 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Hermes</string>
<string name="app_subtitle">Native prototyp för studien</string>
<string name="app_subtitle">Native Hermes-prototyp</string>
<string name="locale_english">Engelska</string>
<string name="locale_swedish">Svenska</string>
<string name="common_continue">Fortsätt</string>
<string name="common_cancel">Avbryt</string>
<string name="common_close">Stäng</string>
@@ -11,7 +13,7 @@
<string name="errors_network">Nätverksfel. Kontrollera anslutningen.</string>
<string name="errors_playback">Videouppspelningen misslyckades.</string>
<string name="errors_session_expired">Sessionen har gått ut. Starta igen.</string>
<string name="onboarding_title">Studieintro</string>
<string name="onboarding_title">Välkommen till Hermes</string>
<string name="onboarding_subtitle">Titta på klippet, välj före låsning och se sedan avslöjandet.</string>
<string name="onboarding_consent_body">Den här prototypen är för forskning och använder inga riktiga pengar.</string>
<string name="onboarding_consent_note">Du kan byta språk när som helst.</string>
@@ -1,7 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Hermes</string>
<string name="app_subtitle">Native study app prototype</string>
<string name="app_subtitle">Native Hermes app prototype</string>
<string name="locale_english">English</string>
<string name="locale_swedish">Swedish</string>
<string name="common_continue">Continue</string>
<string name="common_cancel">Cancel</string>
<string name="common_close">Close</string>
@@ -11,7 +13,7 @@
<string name="errors_network">Network error. Check your connection.</string>
<string name="errors_playback">Video playback failed.</string>
<string name="errors_session_expired">Session expired. Please start again.</string>
<string name="onboarding_title">Study intro</string>
<string name="onboarding_title">Welcome to Hermes</string>
<string name="onboarding_subtitle">Watch the clip, decide before lock, then see the reveal.</string>
<string name="onboarding_consent_body">This prototype is for research and does not use real money.</string>
<string name="onboarding_consent_note">You can switch languages at any time.</string>
+45 -1
View File
@@ -1,15 +1,59 @@
import Foundation
import UIKit
import SwiftUI
@main
struct HermesApp: App {
@StateObject private var repository = HermesRepository(
apiClient: HermesAPIClient(
environment: APIEnvironment(baseURL: URL(string: "http://localhost:3000/")!)
)
)
@StateObject private var analytics = HermesAnalyticsClient()
@StateObject private var playerCoordinator = PlayerCoordinator()
@State private var isBootstrapping = false
var body: some Scene {
WindowGroup {
RootView()
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
}
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])
}
}
})
.preferredColorScheme(.dark)
.tint(HermesTheme.accent)
.environmentObject(analytics)
.environmentObject(repository)
.environmentObject(playerCoordinator)
}
}
}
@@ -0,0 +1,21 @@
import Foundation
func hermesUserFacingErrorMessage(localization: LocalizationStore, localeCode: String, error: Error?) -> String? {
guard let error else {
return nil
}
if error is CancellationError {
return nil
}
if error is URLError {
return localization.string(for: "errors.network", localeCode: localeCode)
}
if error is HermesAPIError {
return localization.string(for: "errors.generic", localeCode: localeCode)
}
return localization.string(for: "errors.generic", localeCode: localeCode)
}
+145
View File
@@ -0,0 +1,145 @@
import Combine
import Foundation
@MainActor
final class HermesRepository: ObservableObject {
@Published private(set) var currentSession: HermesSessionResponse?
@Published private(set) var currentRound: HermesRound?
@Published private(set) var isLoading = true
@Published private(set) var errorCause: Error?
@Published private(set) var serverClockOffset: TimeInterval?
private let apiClient: HermesAPIClient
init(apiClient: HermesAPIClient) {
self.apiClient = apiClient
}
func bootstrap(_ request: HermesSessionStartRequest) async throws -> HermesSessionResponse {
isLoading = true
errorCause = nil
do {
await syncClock()
let session: HermesSessionResponse
if let existingSession = currentSession {
session = existingSession
} else {
session = try await startSession(request)
}
if currentRound == nil {
currentRound = try await loadRoundFromNetwork()
}
isLoading = false
return session
} catch {
errorCause = error
isLoading = false
throw error
}
}
func refreshRoundFromNetwork() async throws -> HermesRound {
isLoading = true
errorCause = nil
do {
await syncClock()
let round = try await loadRoundFromNetwork()
currentRound = round
isLoading = false
return round
} catch {
errorCause = error
isLoading = false
throw error
}
}
func startSession(_ request: HermesSessionStartRequest) async throws -> HermesSessionResponse {
let session = try await apiClient.startSession(request)
currentSession = session
return session
}
func endSession() async throws -> HermesSessionResponse {
let session = try await apiClient.endSession()
currentSession = session
return session
}
func submitBetIntent(_ request: HermesBetIntentRequest) async throws -> HermesBetIntentResponse {
try await apiClient.submitBetIntent(request)
}
func currentOdds(marketID: UUID) async throws -> HermesOddsVersion {
try await apiClient.currentOdds(marketID: marketID)
}
func settlement(eventID: UUID) async throws -> HermesSettlement {
try await apiClient.settlement(eventID: eventID)
}
func experimentConfig() async throws -> HermesExperimentConfig {
try await apiClient.experimentConfig()
}
func localization(localeCode: String) async throws -> HermesLocalizationBundle {
try await apiClient.localization(localeCode: localeCode)
}
func submitAnalyticsBatch(_ payload: HermesAnalyticsBatchRequest) async throws {
try await apiClient.submitAnalyticsBatch(payload)
}
func serverNow() -> Date {
guard let serverClockOffset else {
return Date()
}
return Date().addingTimeInterval(serverClockOffset)
}
private func syncClock() async {
do {
let health = try await apiClient.health()
serverClockOffset = health.serverTime.timeIntervalSince(Date())
} catch {
return
}
}
private func loadRoundFromNetwork() async throws -> HermesRound {
let event = try await apiClient.nextEvent()
let manifest = try await apiClient.eventManifest(eventID: event.id)
guard let media = manifest.media.first(where: { $0.mediaType == "hls_main" }) ?? manifest.media.first else {
throw HermesAPIError.invalidResponse
}
let market: HermesMarket
if let manifestMarket = manifest.markets.first {
market = manifestMarket
} else {
let markets = try await apiClient.markets(eventID: event.id)
guard let fallbackMarket = markets.first else {
throw HermesAPIError.invalidResponse
}
market = fallbackMarket
}
let oddsVersion = try await apiClient.currentOdds(marketID: market.id)
let settlement = try await apiClient.settlement(eventID: event.id)
return HermesRound(
event: event,
media: media,
market: market,
oddsVersion: oddsVersion,
settlement: settlement
)
}
}
+18 -5
View File
@@ -3,15 +3,19 @@ import SwiftUI
struct RootView: View {
@StateObject private var localization = LocalizationStore()
@EnvironmentObject private var analytics: HermesAnalyticsClient
@EnvironmentObject private var repository: HermesRepository
let onStartSession: (String) -> Void
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
header
OnboardingView()
FeedView()
RoundView()
OnboardingView(onStartSession: { onStartSession(localization.localeCode) })
FeedView(onWatchPreview: {}, onRetry: { onStartSession(localization.localeCode) })
RoundView(onRetry: { onStartSession(localization.localeCode) })
SessionView(onRetry: { onStartSession(localization.localeCode) })
}
.padding(.horizontal, HermesTheme.screenPadding)
.padding(.vertical, 24)
@@ -25,6 +29,15 @@ struct RootView: View {
analytics.track("app_opened", attributes: ["screen_name": "home"])
analytics.track("screen_viewed", attributes: ["screen_name": "home"])
}
.task(id: localization.localeCode) {
onStartSession(localization.localeCode)
}
.task {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 5_000_000_000)
await analytics.flush(using: repository)
}
}
}
private var header: some View {
@@ -46,8 +59,8 @@ struct RootView: View {
private var localeToggle: some View {
HStack(spacing: 8) {
localeButton(title: "EN", localeCode: "en")
localeButton(title: "SV", localeCode: "sv")
localeButton(title: localization.localeName(for: "en"), localeCode: "en")
localeButton(title: localization.localeName(for: "sv"), localeCode: "sv")
}
}
@@ -25,4 +25,34 @@ final class HermesAnalyticsClient: ObservableObject, AnalyticsTracking {
)
)
}
func flush(using repository: HermesRepository) async {
guard repository.currentSession != nil else {
return
}
let pendingEvents = trackedEvents
guard !pendingEvents.isEmpty else {
return
}
do {
try await repository.submitAnalyticsBatch(
HermesAnalyticsBatchRequest(
events: pendingEvents.map { event in
HermesAnalyticsEventInput(
eventName: event.event,
occurredAt: event.timestamp,
attributes: event.attributes.map { HermesAnalyticsAttributeInput(key: $0.key, value: $0.value) }
)
}
)
)
let deliveredIds = Set(pendingEvents.map(\.id))
trackedEvents.removeAll { deliveredIds.contains($0.id) }
} catch {
return
}
}
}
@@ -36,6 +36,11 @@ final class LocalizationStore: ObservableObject {
return value
}
func localeName(for targetLocaleCode: String, displayLocaleCode: String? = nil) -> String {
let key = Self.normalize(targetLocaleCode) == "sv" ? "locale_swedish" : "locale_english"
return string(for: key, localeCode: displayLocaleCode ?? localeCode)
}
private func fallbackString(for key: String, localeCode: String) -> String {
guard localeCode != Self.fallbackLocaleCode else {
return key
@@ -1,7 +1,7 @@
import AVKit
import SwiftUI
struct StudyVideoPlayerView: View {
struct HermesVideoPlayerView: View {
@ObservedObject var coordinator: PlayerCoordinator
var body: some View {
@@ -9,13 +9,15 @@ final class PlayerCoordinator: ObservableObject {
@Published var isPlaying = false
@Published var playbackPositionMs: Int = 0
init(previewURL: URL = URL(string: "https://cdn.example.com/hermes/sample-event/master.m3u8")!) {
self.player = AVPlayer(url: previewURL)
init() {
self.player = AVPlayer()
self.player.actionAtItemEnd = .pause
}
func prepareForPreview() {
player.seek(to: .zero)
func prepareForPreview(url: URL, startTimeMs: Int = 0) {
player.replaceCurrentItem(with: AVPlayerItem(url: url))
let startTime = CMTime(seconds: Double(startTimeMs) / 1_000.0, preferredTimescale: 1_000)
player.seek(to: startTime)
player.play()
isPlaying = true
}
@@ -30,8 +32,7 @@ final class PlayerCoordinator: ObservableObject {
isPlaying = false
}
func restart() {
player.seek(to: .zero)
play()
func restart(url: URL, startTimeMs: Int = 0) {
prepareForPreview(url: url, startTimeMs: startTimeMs)
}
}
@@ -42,6 +42,10 @@ struct HermesAPIClient {
try await send(path: "api/v1/session/start", method: "POST", body: payload)
}
func health() async throws -> HermesHealthResponse {
try await send(path: "health")
}
func endSession() async throws -> HermesSessionResponse {
try await send(path: "api/v1/session/end", method: "POST")
}
@@ -23,6 +23,17 @@ struct HermesSessionResponse: Codable {
var devicePlatform: String
}
struct HermesHealthResponse: Codable {
var status: String
var serviceName: String
var environment: String
var version: String
var uptimeMs: Int
var serverTime: Date
var databaseReady: Bool
var redisReady: Bool
}
struct HermesEvent: Codable {
var id: UUID
var sportType: String
@@ -94,6 +105,22 @@ struct HermesEventManifest: Codable {
var markets: [HermesMarket]
}
struct HermesRound: Codable {
var event: HermesEvent
var media: HermesEventMedia
var market: HermesMarket
var oddsVersion: HermesOddsVersion
var settlement: HermesSettlement
}
struct HermesRound: Codable {
var event: HermesEvent
var media: HermesEventMedia
var market: HermesMarket
var oddsVersion: HermesOddsVersion
var settlement: HermesSettlement
}
struct HermesBetIntentRequest: Codable {
var sessionId: UUID
var eventId: UUID
+155 -37
View File
@@ -3,55 +3,173 @@ import SwiftUI
struct FeedView: View {
@EnvironmentObject private var localization: LocalizationStore
@EnvironmentObject private var analytics: HermesAnalyticsClient
@EnvironmentObject private var repository: HermesRepository
let onWatchPreview: () -> Void = {}
let onRetry: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) {
HermesSectionHeader(
title: localization.string(for: "feed.title"),
subtitle: localization.string(for: "feed.subtitle")
TimelineView(.periodic(from: Date(), by: 1)) { _ in
let round = repository.currentRound
let now = repository.serverNow()
let bannerMessage = hermesUserFacingErrorMessage(
localization: localization,
localeCode: localization.localeCode,
error: repository.errorCause
)
ZStack(alignment: .bottomLeading) {
RoundedRectangle(cornerRadius: HermesTheme.cornerRadius, style: .continuous)
.fill(
LinearGradient(
colors: [HermesTheme.surfaceElevated, HermesTheme.background],
startPoint: .topLeading,
endPoint: .bottomTrailing
VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) {
HermesSectionHeader(
title: localization.string(for: "feed.title"),
subtitle: localization.string(for: "feed.subtitle")
)
if round == nil {
if let bannerMessage {
feedErrorState(
message: bannerMessage,
retryText: localization.string(for: "common.retry"),
onRetry: onRetry
)
)
.frame(height: 220)
} else {
feedLoadingState(
title: localization.string(for: "common.loading"),
subtitle: localization.string(for: "feed.subtitle")
)
}
} else {
if let bannerMessage {
feedBanner(message: bannerMessage)
}
VStack(alignment: .leading, spacing: 8) {
Text(localization.string(for: "feed.hero_title"))
.font(.title2.weight(.bold))
.foregroundStyle(HermesTheme.textPrimary)
heroCard(round: round)
Text(localization.string(for: "feed.hero_subtitle"))
.font(.callout)
.foregroundStyle(HermesTheme.textSecondary)
.frame(maxWidth: 260, alignment: .leading)
HStack(spacing: 12) {
HermesMetricPill(
label: localization.string(for: "feed.lock_label"),
value: round.map { Self.countdownText(for: $0.event.lockAt.timeIntervalSince(now)) } ?? "--:--"
)
HermesMetricPill(
label: localization.string(for: "feed.odds_label"),
value: round.map { Self.formatOdds($0) } ?? "--"
)
}
Button {
analytics.track("next_round_requested", attributes: ["screen_name": "feed"])
analytics.track("cta_pressed", attributes: ["screen_name": "feed", "action": "watch_preview"])
onWatchPreview()
} label: {
Text(localization.string(for: "feed.cta"))
}
.buttonStyle(HermesPrimaryButtonStyle())
.disabled(round == nil)
}
.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")
.onAppear {
analytics.track("feed_viewed", attributes: ["screen_name": "feed"])
analytics.track("screen_viewed", attributes: ["screen_name": "feed"])
}
Button {
analytics.track("next_round_requested", attributes: ["screen_name": "feed"])
analytics.track("cta_pressed", attributes: ["screen_name": "feed", "action": "watch_preview"])
} label: {
Text(localization.string(for: "feed.cta"))
}
.buttonStyle(HermesPrimaryButtonStyle())
}
.hermesCard(elevated: true)
.onAppear {
analytics.track("feed_viewed", attributes: ["screen_name": "feed"])
analytics.track("screen_viewed", attributes: ["screen_name": "feed"])
}
@ViewBuilder
private func heroCard(round: HermesRound?) -> some View {
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(round.map { localizedEventTitle($0) } ?? localization.string(for: "feed.hero_title"))
.font(.title2.weight(.bold))
.foregroundStyle(HermesTheme.textPrimary)
Text(round.map { localization.string(for: "feed.hero_subtitle") } ?? localization.string(for: "feed.hero_subtitle"))
.font(.callout)
.foregroundStyle(HermesTheme.textSecondary)
.frame(maxWidth: 260, alignment: .leading)
}
.padding(HermesTheme.contentPadding)
}
}
@ViewBuilder
private func feedLoadingState(title: String, subtitle: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.title2.weight(.bold))
.foregroundStyle(HermesTheme.textPrimary)
Text(subtitle)
.font(.callout)
.foregroundStyle(HermesTheme.textSecondary)
}
.padding(HermesTheme.contentPadding)
.frame(maxWidth: .infinity, minHeight: 220, alignment: .center)
.background(
RoundedRectangle(cornerRadius: HermesTheme.cornerRadius, style: .continuous)
.fill(
LinearGradient(
colors: [HermesTheme.surfaceElevated, HermesTheme.background],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
)
}
@ViewBuilder
private func feedErrorState(message: String, retryText: String, onRetry: @escaping () -> Void) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text(message)
.font(.callout)
.foregroundStyle(HermesTheme.warning)
Button {
onRetry()
} label: {
Text(retryText)
}
.buttonStyle(HermesSecondaryButtonStyle())
}
}
@ViewBuilder
private func feedBanner(message: String) -> some View {
Text(message)
.font(.callout)
.foregroundStyle(HermesTheme.warning)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(HermesTheme.warning.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: HermesTheme.insetRadius, style: .continuous))
}
private func localizedEventTitle(_ round: HermesRound) -> String {
localization.localeCode == "sv" ? round.event.titleSv : round.event.titleEn
}
private static func countdownText(for remaining: TimeInterval) -> String {
let totalSeconds = max(Int(remaining.rounded(.down)), 0)
let minutes = totalSeconds / 60
let seconds = totalSeconds % 60
return String(format: "%02d:%02d", minutes, seconds)
}
private static func formatOdds(_ round: HermesRound) -> String {
let oddsByOutcomeId = Dictionary(uniqueKeysWithValues: round.oddsVersion.odds.map { ($0.outcomeId, $0) })
return round.market.outcomes
.sorted(by: { $0.sortOrder < $1.sortOrder })
.compactMap { oddsByOutcomeId[$0.id]?.decimalOdds }
.map { String(format: "%.2f", $0) }
.joined(separator: " / ")
}
}
@@ -4,6 +4,8 @@ struct OnboardingView: View {
@EnvironmentObject private var localization: LocalizationStore
@EnvironmentObject private var analytics: HermesAnalyticsClient
let onStartSession: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) {
HermesSectionHeader(
@@ -27,6 +29,7 @@ struct OnboardingView: View {
Button {
analytics.track("consent_accepted", attributes: ["screen_name": "onboarding"])
analytics.track("cta_pressed", attributes: ["screen_name": "onboarding", "action": "start_session"])
onStartSession()
} label: {
Text(localization.string(for: "onboarding.start_session"))
}
@@ -13,8 +13,6 @@ struct ResultView: View {
let nextRoundTitle: String
let onNextRound: () -> Void
@EnvironmentObject private var analytics: HermesAnalyticsClient
var body: some View {
VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) {
HermesSectionHeader(title: title, subtitle: subtitle)
@@ -33,16 +31,11 @@ struct ResultView: View {
}
Button {
analytics.track("next_round_requested", attributes: ["screen_name": "result"])
onNextRound()
} label: {
Text(nextRoundTitle)
}
.buttonStyle(HermesPrimaryButtonStyle())
}
.onAppear {
analytics.track("screen_viewed", attributes: ["screen_name": "result"])
analytics.track("result_viewed", attributes: ["screen_name": "result", "selection": selectionValue, "outcome": outcomeValue])
}
}
}
@@ -9,8 +9,6 @@ struct RevealView: View {
let continueTitle: String
let onContinue: () -> Void
@EnvironmentObject private var analytics: HermesAnalyticsClient
var body: some View {
VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) {
HermesSectionHeader(title: title, subtitle: subtitle)
@@ -24,16 +22,11 @@ struct RevealView: View {
}
Button {
analytics.track("reveal_completed", attributes: ["screen_name": "reveal", "selection": selectionValue])
onContinue()
} label: {
Text(continueTitle)
}
.buttonStyle(HermesPrimaryButtonStyle())
}
.onAppear {
analytics.track("screen_viewed", attributes: ["screen_name": "reveal"])
analytics.track("reveal_started", attributes: ["screen_name": "reveal", "selection": selectionValue])
}
}
}
+326 -135
View File
@@ -3,15 +3,17 @@ import SwiftUI
struct RoundView: View {
@EnvironmentObject private var localization: LocalizationStore
@EnvironmentObject private var analytics: HermesAnalyticsClient
@EnvironmentObject private var repository: HermesRepository
@EnvironmentObject private var playerCoordinator: PlayerCoordinator
let onRetry: () -> Void
@StateObject private var playerCoordinator = PlayerCoordinator()
@State private var phase: Phase = .preview
@State private var selectedOutcomeID: String? = nil
@State private var lockAt: Date = Date().addingTimeInterval(47)
@State private var lockAt: Date = .now
@State private var transitionTask: Task<Void, Never>?
private let previewDuration: TimeInterval = 47
private let winningOutcomeID = "home"
@State private var actionMessage: String?
@State private var isSubmitting = false
private enum Phase {
case preview
@@ -20,40 +22,19 @@ struct RoundView: View {
case result
}
private var selectionOptions: [SelectionOption] {
[
SelectionOption(
id: "home",
title: localization.string(for: "round.home"),
subtitle: localization.string(for: "round.selection_prompt"),
odds: "1.85"
),
SelectionOption(
id: "away",
title: localization.string(for: "round.away"),
subtitle: localization.string(for: "round.selection_prompt"),
odds: "2.05"
),
]
}
private var selectedOutcome: SelectionOption? {
guard let selectedOutcomeID else {
return nil
}
return selectionOptions.first { $0.id == selectedOutcomeID }
}
private var winningOutcome: SelectionOption {
selectionOptions.first { $0.id == winningOutcomeID } ?? selectionOptions[0]
}
var body: some View {
TimelineView(.periodic(from: Date(), by: 1)) { context in
let remaining = max(lockAt.timeIntervalSince(context.date), 0)
let timerLocked = remaining <= 0
TimelineView(.periodic(from: Date(), by: 1)) { _ in
let round = repository.currentRound
let now = repository.serverNow()
let hasRound = round != nil
let remaining = max(lockAt.timeIntervalSince(now), 0)
let timerLocked = round != nil && remaining <= 0
let countdownText = Self.countdownText(for: remaining)
let bannerMessage = actionMessage ?? hermesUserFacingErrorMessage(
localization: localization,
localeCode: localization.localeCode,
error: repository.errorCause
)
VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) {
HermesSectionHeader(
@@ -61,15 +42,87 @@ struct RoundView: View {
subtitle: localization.string(for: "round.subtitle")
)
videoSection(countdownText: countdownText, remaining: remaining, isTimerLocked: timerLocked)
if !hasRound {
if let bannerMessage {
roundErrorState(
message: bannerMessage,
retryText: localization.string(for: "common.retry"),
onRetry: onRetry
)
} else {
roundLoadingState(
title: localization.string(for: "common.loading"),
subtitle: localization.string(for: "round.subtitle")
)
}
} else {
if let bannerMessage {
roundBanner(message: bannerMessage)
}
phaseContent(isTimerLocked: timerLocked)
videoSection(
round: round,
countdownText: countdownText,
remaining: remaining,
isTimerLocked: timerLocked
)
if let round {
switch phase {
case .preview, .locked:
SelectionView(
statusText: phase == .preview && !timerLocked ? localization.string(for: "round.selection_prompt") : localization.string(for: "round.locked_label"),
options: selectionOptions(for: round),
selectedOptionID: selectedOutcomeID,
isLocked: phase != .preview || timerLocked || isSubmitting,
confirmTitle: localization.string(for: "round.primary_cta"),
onSelect: handleSelection,
onConfirm: confirmSelection
)
case .reveal:
RevealView(
title: localization.string(for: "reveal.title"),
subtitle: localization.string(for: "reveal.subtitle"),
statusText: localization.string(for: "reveal.status"),
selectionLabel: localization.string(for: "result.selection_label"),
selectionValue: selectedOutcomeTitle(for: round),
continueTitle: localization.string(for: "reveal.cta"),
onContinue: showResult
)
case .result:
ResultView(
title: localization.string(for: "result.title"),
subtitle: localization.string(for: "result.subtitle"),
selectionLabel: localization.string(for: "result.selection_label"),
selectionValue: selectedOutcomeTitle(for: round),
outcomeLabel: localization.string(for: "result.outcome_label"),
outcomeValue: winningOutcomeTitle(for: round),
didWin: selectedOutcomeID == round.settlement.winningOutcomeId.uuidString,
winLabel: localization.string(for: "result.win"),
loseLabel: localization.string(for: "result.lose"),
nextRoundTitle: localization.string(for: "result.next_round"),
onNextRound: nextRound
)
}
}
}
}
.onAppear {
if let round {
startPreview(round)
}
}
.onChange(of: round?.event.id) { _, newValue in
guard newValue != nil, let round else {
return
}
startPreview(round)
}
}
.hermesCard(elevated: true)
.onAppear {
startPreview()
}
.onDisappear {
transitionTask?.cancel()
playerCoordinator.pause()
@@ -77,65 +130,36 @@ struct RoundView: View {
}
@ViewBuilder
private func phaseContent(isTimerLocked: Bool) -> some View {
switch phase {
case .preview, .locked:
SelectionView(
statusText: isTimerLocked || phase != .preview ? localization.string(for: "round.locked_label") : localization.string(for: "round.selection_prompt"),
options: selectionOptions,
selectedOptionID: selectedOutcomeID,
isLocked: isTimerLocked || phase != .preview,
confirmTitle: localization.string(for: "round.primary_cta"),
onSelect: handleSelection,
onConfirm: confirmSelection
)
case .reveal:
RevealView(
title: localization.string(for: "reveal.title"),
subtitle: localization.string(for: "reveal.subtitle"),
statusText: localization.string(for: "reveal.status"),
selectionLabel: localization.string(for: "result.selection_label"),
selectionValue: selectedOutcome?.title ?? localization.string(for: "round.selection_prompt"),
continueTitle: localization.string(for: "reveal.cta"),
onContinue: showResult
)
case .result:
ResultView(
title: localization.string(for: "result.title"),
subtitle: localization.string(for: "result.subtitle"),
selectionLabel: localization.string(for: "result.selection_label"),
selectionValue: selectedOutcome?.title ?? localization.string(for: "round.selection_prompt"),
outcomeLabel: localization.string(for: "result.outcome_label"),
outcomeValue: winningOutcome.title,
didWin: selectedOutcomeID == winningOutcomeID,
winLabel: localization.string(for: "result.win"),
loseLabel: localization.string(for: "result.lose"),
nextRoundTitle: localization.string(for: "result.next_round"),
onNextRound: resetRound
)
}
}
@ViewBuilder
private func videoSection(countdownText: String, remaining: TimeInterval, isTimerLocked: Bool) -> some View {
private func videoSection(round: HermesRound?, countdownText: String, remaining: TimeInterval, isTimerLocked: Bool) -> some View {
ZStack(alignment: .topTrailing) {
StudyVideoPlayerView(coordinator: playerCoordinator)
RoundedRectangle(cornerRadius: HermesTheme.cornerRadius, style: .continuous)
.fill(HermesTheme.surfaceElevated)
.frame(height: 220)
VStack(alignment: .trailing, spacing: 10) {
HermesCountdownBadge(
label: localization.string(for: "round.countdown_label"),
value: countdownText,
warning: !isTimerLocked && remaining <= 10
)
HermesMetricPill(
label: localization.string(for: "round.odds_label"),
value: "1.85 / 2.05"
)
if let round {
HermesVideoPlayerView(coordinator: playerCoordinator)
} else {
Text(localization.string(for: "round.video_placeholder"))
.font(.headline.weight(.semibold))
.foregroundStyle(HermesTheme.textSecondary)
}
if let round {
VStack(alignment: .trailing, spacing: 10) {
HermesCountdownBadge(
label: localization.string(for: "round.countdown_label"),
value: countdownText,
warning: !isTimerLocked && remaining <= 10
)
HermesMetricPill(
label: localization.string(for: "round.odds_label"),
value: formatOdds(round)
)
.frame(maxWidth: 160)
}
.padding(12)
}
.padding(12)
Text(phaseLabel(isTimerLocked: isTimerLocked))
.font(.caption.weight(.bold))
@@ -147,35 +171,25 @@ struct RoundView: View {
.padding(12)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
}
.clipShape(RoundedRectangle(cornerRadius: HermesTheme.cornerRadius, style: .continuous))
}
private func phaseLabel(isTimerLocked: Bool) -> String {
switch phase {
case .preview:
return isTimerLocked ? localization.string(for: "round.locked_label") : localization.string(for: "round.preview_label")
case .locked:
return localization.string(for: "round.locked_label")
case .reveal:
return localization.string(for: "reveal.title")
case .result:
return localization.string(for: "result.title")
}
}
private func startPreview() {
private func startPreview(_ round: HermesRound) {
transitionTask?.cancel()
phase = .preview
lockAt = Date().addingTimeInterval(previewDuration)
selectedOutcomeID = nil
playerCoordinator.restart()
actionMessage = nil
isSubmitting = false
lockAt = round.event.lockAt
playerCoordinator.prepareForPreview(url: round.media.hlsMasterUrl, startTimeMs: round.media.previewStartMs)
analytics.track("round_loaded", attributes: ["screen_name": "round"])
analytics.track("preview_started", attributes: ["screen_name": "round"])
analytics.track("round_loaded", attributes: roundAnalyticsAttributes(round))
analytics.track("preview_started", attributes: roundAnalyticsAttributes(round))
analytics.track("screen_viewed", attributes: ["screen_name": "round"])
}
private func handleSelection(_ option: SelectionOption) {
guard phase == .preview else {
guard phase == .preview, !isSubmitting else {
return
}
@@ -185,39 +199,204 @@ struct RoundView: View {
}
private func confirmSelection() {
guard phase == .preview, let selectedOutcomeID else {
guard phase == .preview, !isSubmitting, let round = repository.currentRound else {
return
}
analytics.track("selection_submitted", attributes: ["screen_name": "round", "outcome_id": selectedOutcomeID])
analytics.track("selection_accepted", attributes: ["screen_name": "round", "outcome_id": selectedOutcomeID])
analytics.track("market_locked", attributes: ["screen_name": "round", "lock_reason": "manual_selection"])
guard let selectedOutcomeID, let session = repository.currentSession else {
actionMessage = localization.string(for: "errors.session_expired")
return
}
phase = .locked
playerCoordinator.pause()
guard let outcomeID = UUID(uuidString: selectedOutcomeID) ?? round.market.outcomes.first?.id else {
actionMessage = localization.string(for: "errors.generic")
return
}
transitionTask?.cancel()
transitionTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: 750_000_000)
guard !Task.isCancelled else {
return
if repository.serverNow() >= round.event.lockAt {
return
}
isSubmitting = true
actionMessage = nil
Task { @MainActor in
do {
let request = HermesBetIntentRequest(
sessionId: session.sessionId,
eventId: round.event.id,
marketId: round.market.id,
outcomeId: outcomeID,
idempotencyKey: UUID().uuidString,
clientSentAt: Date()
)
let response = try await repository.submitBetIntent(request)
guard response.accepted else {
actionMessage = localization.string(for: "errors.generic")
phase = .preview
isSubmitting = false
return
}
analytics.track("selection_submitted", attributes: baseSelectionAttributes(selectedOutcomeID))
analytics.track("selection_accepted", attributes: baseSelectionAttributes(selectedOutcomeID))
analytics.track("market_locked", attributes: baseSelectionAttributes(selectedOutcomeID).merging(["lock_reason": "manual_selection"]) { _, new in new })
phase = .locked
playerCoordinator.pause()
transitionTask?.cancel()
transitionTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: 750_000_000)
guard !Task.isCancelled, phase == .locked else {
return
}
phase = .reveal
analytics.track("reveal_started", attributes: roundAnalyticsAttributes(round).merging(baseSelectionAttributes(selectedOutcomeID)) { _, new in new })
}
} catch {
actionMessage = hermesUserFacingErrorMessage(localization: localization, localeCode: localization.localeCode, error: error) ?? localization.string(for: "errors.generic")
phase = .preview
}
phase = .reveal
isSubmitting = false
}
}
private func showResult() {
analytics.track("reveal_completed", attributes: ["screen_name": "round"])
guard let round = repository.currentRound, let selectedOutcomeID else {
return
}
analytics.track("reveal_completed", attributes: roundAnalyticsAttributes(round).merging(baseSelectionAttributes(selectedOutcomeID)) { _, new in new })
phase = .result
analytics.track(
"result_viewed",
attributes: roundAnalyticsAttributes(round)
.merging(baseSelectionAttributes(selectedOutcomeID)) { _, new in new }
.merging(["outcome": winningOutcomeTitle(for: round)]) { _, new in new }
)
}
private func resetRound() {
private func nextRound() {
analytics.track("next_round_requested", attributes: ["screen_name": "result"])
transitionTask?.cancel()
selectedOutcomeID = nil
phase = .preview
lockAt = Date().addingTimeInterval(previewDuration)
playerCoordinator.restart()
actionMessage = nil
Task { @MainActor in
do {
_ = try await repository.refreshRoundFromNetwork()
} catch {
actionMessage = hermesUserFacingErrorMessage(localization: localization, localeCode: localization.localeCode, error: error) ?? localization.string(for: "errors.generic")
}
}
}
private func roundLoadingState(title: String, subtitle: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.headline.weight(.semibold))
.foregroundStyle(HermesTheme.textPrimary)
Text(subtitle)
.font(.callout)
.foregroundStyle(HermesTheme.textSecondary)
}
}
private func roundErrorState(message: String, retryText: String, onRetry: @escaping () -> Void) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text(message)
.font(.callout)
.foregroundStyle(HermesTheme.warning)
Button {
onRetry()
} label: {
Text(retryText)
}
.buttonStyle(HermesSecondaryButtonStyle())
}
}
private func roundBanner(message: String) -> some View {
Text(message)
.font(.callout)
.foregroundStyle(HermesTheme.warning)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(HermesTheme.warning.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: HermesTheme.insetRadius, style: .continuous))
}
private func selectionOptions(for round: HermesRound) -> [SelectionOption] {
let oddsByOutcomeId = Dictionary(uniqueKeysWithValues: round.oddsVersion.odds.map { ($0.outcomeId, $0) })
return round.market.outcomes
.sorted(by: { $0.sortOrder < $1.sortOrder })
.map { outcome in
SelectionOption(
id: outcome.id.uuidString,
title: outcomeTitle(outcome),
subtitle: localization.string(for: "round.selection_prompt"),
odds: oddsByOutcomeId[outcome.id].map { String(format: "%.2f", $0.decimalOdds) } ?? "--"
)
}
}
private func outcomeTitle(_ outcome: HermesOutcome) -> String {
switch outcome.labelKey {
case "round.home":
return localization.string(for: "round.home")
case "round.away":
return localization.string(for: "round.away")
default:
return outcome.outcomeCode.capitalized
}
}
private func selectedOutcomeTitle(for round: HermesRound) -> String {
guard let selectedOutcomeID,
let outcome = round.market.outcomes.first(where: { $0.id.uuidString == selectedOutcomeID }) else {
return localization.string(for: "round.selection_prompt")
}
return outcomeTitle(outcome)
}
private func winningOutcomeTitle(for round: HermesRound) -> String {
guard let outcome = round.market.outcomes.first(where: { $0.id == round.settlement.winningOutcomeId }) else {
return localization.string(for: "round.selection_prompt")
}
return outcomeTitle(outcome)
}
private func formatOdds(_ round: HermesRound) -> String {
let oddsByOutcomeId = Dictionary(uniqueKeysWithValues: round.oddsVersion.odds.map { ($0.outcomeId, $0) })
return round.market.outcomes
.sorted(by: { $0.sortOrder < $1.sortOrder })
.compactMap { oddsByOutcomeId[$0.id]?.decimalOdds }
.map { String(format: "%.2f", $0) }
.joined(separator: " / ")
}
private func phaseLabel(isTimerLocked: Bool) -> String {
switch phase {
case .preview:
if isTimerLocked {
return localization.string(for: "round.locked_label")
}
return localization.string(for: "round.preview_label")
case .locked:
return localization.string(for: "round.locked_label")
case .reveal:
return localization.string(for: "reveal.title")
case .result:
return localization.string(for: "result.title")
}
}
private static func countdownText(for remaining: TimeInterval) -> String {
@@ -226,4 +405,16 @@ struct RoundView: View {
let seconds = totalSeconds % 60
return String(format: "%02d:%02d", minutes, seconds)
}
private func baseSelectionAttributes(_ outcomeId: String) -> [String: String] {
["screen_name": "round", "outcome_id": outcomeId]
}
private func roundAnalyticsAttributes(_ round: HermesRound) -> [String: String] {
[
"screen_name": "round",
"event_id": round.event.id.uuidString,
"market_id": round.market.id.uuidString,
]
}
}
@@ -1,7 +1,147 @@
import SwiftUI
struct SessionView: View {
@EnvironmentObject private var analytics: HermesAnalyticsClient
@EnvironmentObject private var localization: LocalizationStore
@EnvironmentObject private var repository: HermesRepository
let onRetry: () -> Void
var body: some View {
Text("Session scaffold")
let session = repository.currentSession
let bannerMessage = hermesUserFacingErrorMessage(
localization: localization,
localeCode: localization.localeCode,
error: repository.errorCause
)
let statusText: String
if session != nil {
statusText = localization.string(for: "session.status_ready")
} else if bannerMessage != nil {
statusText = localization.string(for: "session.status_error")
} else {
statusText = localization.string(for: "session.status_loading")
}
return HermesCard {
VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) {
HermesSectionHeader(
title: localization.string(for: "session.title"),
subtitle: localization.string(for: "session.subtitle")
)
if session == nil {
if let bannerMessage {
sessionErrorState(
message: bannerMessage,
retryText: localization.string(for: "common.retry"),
onRetry: onRetry
)
} else {
sessionLoadingState(
title: statusText,
subtitle: localization.string(for: "session.note")
)
}
} else {
sessionStatusBadge(text: statusText, warning: bannerMessage != nil)
if let bannerMessage {
sessionBanner(message: bannerMessage)
}
sessionRow(label: localization.string(for: "session.id_label"), value: session?.sessionId.uuidString ?? "--")
sessionRow(label: localization.string(for: "session.user_id_label"), value: session?.userId.uuidString ?? "--")
sessionRow(
label: localization.string(for: "session.locale_label"),
value: localization.localeName(for: session?.localeCode ?? localization.localeCode)
)
sessionRow(label: localization.string(for: "session.started_label"), value: session.map { Self.compactDateFormatter.string(from: $0.startedAt) } ?? "--")
sessionRow(label: localization.string(for: "session.variant_label"), value: session?.experimentVariant ?? "--")
sessionRow(label: localization.string(for: "session.app_version_label"), value: session?.appVersion ?? "--")
sessionRow(label: localization.string(for: "session.device_model_label"), value: session?.deviceModel ?? "--")
sessionRow(label: localization.string(for: "session.os_version_label"), value: session?.osVersion ?? "--")
Text(localization.string(for: "session.note"))
.font(.callout)
.foregroundStyle(HermesTheme.textSecondary)
}
}
}
.onAppear {
analytics.track("screen_viewed", attributes: ["screen_name": "session"])
}
}
@ViewBuilder
private func sessionLoadingState(title: String, subtitle: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.headline.weight(.semibold))
.foregroundStyle(HermesTheme.textPrimary)
Text(subtitle)
.font(.callout)
.foregroundStyle(HermesTheme.textSecondary)
}
}
@ViewBuilder
private func sessionErrorState(message: String, retryText: String, onRetry: @escaping () -> Void) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text(message)
.font(.callout)
.foregroundStyle(HermesTheme.warning)
Button {
onRetry()
} label: {
Text(retryText)
}
.buttonStyle(HermesSecondaryButtonStyle())
}
}
@ViewBuilder
private func sessionStatusBadge(text: String, warning: Bool) -> some View {
Text(text)
.font(.caption.weight(.semibold))
.foregroundStyle(warning ? HermesTheme.warning : HermesTheme.accent)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background((warning ? HermesTheme.warning : HermesTheme.accent).opacity(0.16))
.clipShape(Capsule())
}
@ViewBuilder
private func sessionBanner(message: String) -> some View {
Text(message)
.font(.callout)
.foregroundStyle(HermesTheme.warning)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(HermesTheme.warning.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: HermesTheme.insetRadius, style: .continuous))
}
@ViewBuilder
private func sessionRow(label: String, value: String) -> some View {
HStack {
Text(label)
.font(.callout)
.foregroundStyle(HermesTheme.textSecondary)
Spacer(minLength: 12)
Text(value)
.font(.callout.weight(.semibold))
.foregroundStyle(HermesTheme.textPrimary)
}
}
private static let compactDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = .current
formatter.dateFormat = "yyyy-MM-dd HH:mm"
return formatter
}()
}
+1 -1
View File
@@ -1,6 +1,6 @@
# iOS App
Native SwiftUI client scaffold for the Hermes study app.
Native SwiftUI client scaffold for the Hermes app.
Planned structure:
@@ -1,6 +1,13 @@
"app.name" = "Hermes";
"app.subtitle" = "Native study app prototype";
"onboarding.title" = "Study intro";
"app.subtitle" = "Native Hermes app prototype";
"locale_english" = "English";
"locale_swedish" = "Swedish";
"common.loading" = "Loading";
"common.retry" = "Retry";
"errors.generic" = "Please try again.";
"errors.network" = "Network error. Check your connection.";
"errors.session_expired" = "Session expired. Please start again.";
"onboarding.title" = "Welcome to Hermes";
"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.";
@@ -35,3 +42,17 @@
"result.win" = "Winning selection";
"result.lose" = "Not this time";
"result.next_round" = "Next round";
"session.title" = "Session";
"session.subtitle" = "Session sync and lifecycle controls.";
"session.note" = "Session state will appear here once the backend session is started.";
"session.status_loading" = "Starting session";
"session.status_ready" = "Session active";
"session.status_error" = "Session unavailable";
"session.id_label" = "Session ID";
"session.user_id_label" = "User ID";
"session.locale_label" = "Locale";
"session.started_label" = "Started";
"session.variant_label" = "Variant";
"session.app_version_label" = "App version";
"session.device_model_label" = "Device model";
"session.os_version_label" = "OS version";
@@ -1,6 +1,13 @@
"app.name" = "Hermes";
"app.subtitle" = "Native prototype för studien";
"onboarding.title" = "Studieintro";
"app.subtitle" = "Native Hermes-prototyp";
"locale_english" = "Engelska";
"locale_swedish" = "Svenska";
"common.loading" = "Laddar";
"common.retry" = "Försök igen";
"errors.generic" = "Försök igen.";
"errors.network" = "Nätverksfel. Kontrollera anslutningen.";
"errors.session_expired" = "Sessionen har gått ut. Starta igen.";
"onboarding.title" = "Välkommen till Hermes";
"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.";
@@ -35,3 +42,17 @@
"result.win" = "Vinnande val";
"result.lose" = "Inte denna gång";
"result.next_round" = "Nästa runda";
"session.title" = "Session";
"session.subtitle" = "Sessionssynk och livscykelkontroller.";
"session.note" = "Sessionsstatus visas här när backend-sessionen har startat.";
"session.status_loading" = "Startar session";
"session.status_ready" = "Session aktiv";
"session.status_error" = "Sessionen är otillgänglig";
"session.id_label" = "Sessions-ID";
"session.user_id_label" = "Användar-ID";
"session.locale_label" = "Språk";
"session.started_label" = "Startad";
"session.variant_label" = "Variant";
"session.app_version_label" = "Appversion";
"session.device_model_label" = "Enhetsmodell";
"session.os_version_label" = "OS-version";