This commit is contained in:
2026-04-09 17:03:33 +02:00
parent 260f27728b
commit e401b6dbab
13 changed files with 906 additions and 200 deletions
@@ -1,9 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".HermesApplication"
android:allowBackup="true"
android:usesCleartextTraffic="true"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Hermes">
@@ -1,6 +1,7 @@
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
@@ -8,6 +9,7 @@ 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) {
@@ -24,6 +26,25 @@ class HermesAppContainer(context: Context) {
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 {
@@ -14,6 +14,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@@ -27,6 +28,7 @@ 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
@@ -36,10 +38,16 @@ import androidx.compose.material3.ExperimentalMaterial3Api
fun HermesStudyApp(container: HermesAppContainer) {
val feedViewModel: FeedViewModel = viewModel(factory = container.feedViewModelFactory())
val roundViewModel: RoundViewModel = viewModel(factory = container.roundViewModelFactory())
val sessionViewModel: SessionViewModel = viewModel(factory = container.sessionViewModelFactory())
val localeCode by container.localizationStore.localeCode.collectAsStateWithLifecycle()
val sessionState by sessionViewModel.uiState.collectAsStateWithLifecycle()
val feedState by feedViewModel.uiState.collectAsStateWithLifecycle()
val roundState by roundViewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(localeCode) {
sessionViewModel.bootstrap(localeCode)
}
Scaffold(
topBar = {
HermesAppBar(
@@ -60,7 +68,8 @@ fun HermesStudyApp(container: HermesAppContainer) {
item {
FeedScreen(
uiState = feedState,
onWatchPreview = feedViewModel::onWatchPreview
onWatchPreview = feedViewModel::onWatchPreview,
onRetry = sessionViewModel::retryBootstrap,
)
}
item {
@@ -70,13 +79,15 @@ fun HermesStudyApp(container: HermesAppContainer) {
onSelectOutcome = roundViewModel::onSelectOutcome,
onConfirmSelection = roundViewModel::onConfirmSelection,
onRevealComplete = roundViewModel::onRevealAcknowledged,
onNextRound = roundViewModel::onNextRound
onNextRound = roundViewModel::onNextRound,
onRetry = sessionViewModel::retryBootstrap,
)
}
item {
SessionView(
uiState = sessionState,
localizationStore = container.localizationStore,
localeCode = localeCode,
onRetry = sessionViewModel::retryBootstrap,
)
}
item {
@@ -0,0 +1,21 @@
package com.hermes.study.core.errors
import com.hermes.study.R
import com.hermes.study.core.localization.HermesLocalizationStore
import com.hermes.study.core.network.HermesApiException
import java.io.IOException
import kotlinx.coroutines.CancellationException
fun mapUserFacingError(
localizationStore: HermesLocalizationStore,
localeCode: String,
error: Throwable?,
): String? {
return when (error) {
null -> null
is CancellationException -> null
is IOException -> localizationStore.string(localeCode, R.string.errors_network)
is HermesApiException.HttpStatus -> localizationStore.string(localeCode, R.string.errors_generic)
else -> localizationStore.string(localeCode, R.string.errors_generic)
}
}
@@ -9,34 +9,81 @@ 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 kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class HermesRepository(private val apiClient: HermesApiClient) {
private val roundState = MutableStateFlow(SampleStudyData.round())
private val sessionMutex = Mutex()
private val _currentSession = MutableStateFlow<HermesSessionResponse?>(null)
private val _currentRound = MutableStateFlow<StudyRound?>(null)
private val _isLoading = MutableStateFlow(true)
private val _errorCause = MutableStateFlow<Throwable?>(null)
val currentRound: StateFlow<StudyRound> = roundState.asStateFlow()
val currentSession: StateFlow<HermesSessionResponse?> = _currentSession.asStateFlow()
val currentRound: StateFlow<StudyRound?> = _currentRound.asStateFlow()
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
val errorCause: StateFlow<Throwable?> = _errorCause.asStateFlow()
fun observeRound(): Flow<StudyRound> = roundState
fun observeRound(): Flow<StudyRound?> = currentRound
fun observeFeed(): Flow<StudyRound> = roundState
fun observeFeed(): Flow<StudyRound?> = currentRound
suspend fun refreshRoundFromNetwork(): StudyRound {
roundState.value = SampleStudyData.round()
return roundState.value
suspend fun bootstrap(request: HermesSessionStartRequest): HermesSessionResponse {
return sessionMutex.withLock {
_isLoading.value = true
_errorCause.value = null
try {
val session = _currentSession.value ?: startSession(request)
if (_currentRound.value == null) {
_currentRound.value = loadRoundFromNetwork()
}
suspend fun startSession(request: com.hermes.study.domain.HermesSessionStartRequest): HermesSessionResponse {
return apiClient.startSession(request)
session
} catch (error: Throwable) {
_errorCause.value = error
throw error
} finally {
_isLoading.value = false
}
}
}
suspend fun refreshRoundFromNetwork(): StudyRound {
return sessionMutex.withLock {
_isLoading.value = true
_errorCause.value = null
try {
val round = loadRoundFromNetwork()
_currentRound.value = round
round
} catch (error: Throwable) {
_errorCause.value = error
throw error
} finally {
_isLoading.value = false
}
}
}
suspend fun startSession(request: HermesSessionStartRequest): HermesSessionResponse {
val session = apiClient.startSession(request)
_currentSession.value = session
return session
}
suspend fun endSession(): HermesSessionResponse {
return apiClient.endSession()
val session = apiClient.endSession()
_currentSession.value = session
return session
}
suspend fun submitBetIntent(request: HermesBetIntentRequest): HermesBetIntentResponse {
@@ -62,4 +109,23 @@ class HermesRepository(private val apiClient: HermesApiClient) {
suspend fun submitAnalyticsBatch(request: HermesAnalyticsBatchRequest) {
apiClient.submitAnalyticsBatch(request)
}
private suspend fun loadRoundFromNetwork(): StudyRound {
val event = apiClient.nextEvent()
val manifest = apiClient.eventManifest(event.id)
val media = manifest.media.firstOrNull { it.mediaType == "hls_main" } ?: manifest.media.firstOrNull()
?: error("Event media not found")
val market = manifest.markets.firstOrNull() ?: apiClient.markets(event.id).firstOrNull()
?: error("Event market not found")
val oddsVersion = apiClient.currentOdds(market.id)
val settlement = apiClient.settlement(event.id)
return StudyRound(
event = event,
media = media,
market = market,
oddsVersion = oddsVersion,
settlement = settlement,
)
}
}
@@ -27,12 +27,28 @@ import com.hermes.study.core.designsystem.HermesSectionHeader
fun FeedScreen(
uiState: FeedUiState,
onWatchPreview: () -> Unit,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
) {
HermesCard(modifier = modifier, elevated = true) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
HermesSectionHeader(title = uiState.title, subtitle = uiState.subtitle)
when {
uiState.isLoading && !uiState.hasRound -> FeedLoadingState(
title = uiState.heroTitle,
subtitle = uiState.heroSubtitle,
)
!uiState.hasRound && uiState.bannerMessage != null -> FeedErrorState(
message = uiState.bannerMessage,
onRetry = onRetry,
retryText = uiState.retryText,
)
else -> {
if (uiState.bannerMessage != null) {
FeedBanner(message = uiState.bannerMessage)
}
Box(
modifier = Modifier
.fillMaxWidth()
@@ -65,7 +81,76 @@ fun FeedScreen(
HermesMetricChip(label = uiState.oddsLabel, value = uiState.oddsValue, modifier = Modifier.fillMaxWidth(0.5f))
}
HermesPrimaryButton(text = uiState.ctaText, onClick = onWatchPreview)
HermesPrimaryButton(text = uiState.ctaText, onClick = onWatchPreview, enabled = uiState.hasRound)
}
}
}
}
}
@Composable
private fun FeedLoadingState(
title: String,
subtitle: String,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(220.dp)
.clip(RoundedCornerShape(28.dp))
.background(
Brush.linearGradient(
colors = listOf(HermesColors.surfaceElevated, HermesColors.background),
),
)
.padding(20.dp),
contentAlignment = Alignment.CenterStart,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
color = HermesColors.textPrimary,
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = HermesColors.textSecondary,
)
}
}
}
@Composable
private fun FeedErrorState(
message: String,
onRetry: () -> Unit,
retryText: String,
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = HermesColors.warning,
)
HermesPrimaryButton(text = retryText, onClick = onRetry)
}
}
@Composable
private fun FeedBanner(message: String) {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(HermesColors.warning.copy(alpha = 0.16f))
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
Text(
text = message,
style = MaterialTheme.typography.bodySmall,
color = HermesColors.warning,
)
}
}
@@ -4,6 +4,7 @@ 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
@@ -27,6 +28,10 @@ data class FeedUiState(
val oddsLabel: String,
val oddsValue: String,
val ctaText: String,
val retryText: String,
val hasRound: Boolean,
val isLoading: Boolean,
val bannerMessage: String?,
)
class FeedViewModel(
@@ -43,14 +48,16 @@ class FeedViewModel(
}
}
val uiState: StateFlow<FeedUiState> = combine(roundFlow, localeFlow, nowFlow) { round, localeCode, now ->
buildUiState(round = round, localeCode = localeCode, now = now)
val uiState: StateFlow<FeedUiState> = combine(roundFlow, localeFlow, repository.isLoading, repository.errorCause, nowFlow) { round, localeCode, isLoading, errorCause, now ->
buildUiState(round = round, localeCode = localeCode, isLoading = isLoading, errorCause = errorCause, now = now)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = buildUiState(
round = roundFlow.value,
localeCode = localeFlow.value,
isLoading = repository.isLoading.value,
errorCause = repository.errorCause.value,
now = Clock.System.now(),
),
)
@@ -61,6 +68,10 @@ class FeedViewModel(
}
fun onWatchPreview() {
if (roundFlow.value == null) {
return
}
analyticsTracker.track("next_round_requested", attributes = mapOf("screen_name" to "feed"))
analyticsTracker.track(
"cta_pressed",
@@ -68,17 +79,30 @@ class FeedViewModel(
)
}
private fun buildUiState(round: StudyRound, localeCode: String, now: Instant): FeedUiState {
private fun buildUiState(round: StudyRound?, localeCode: String, isLoading: Boolean, errorCause: Throwable?, now: Instant): FeedUiState {
val bannerMessage = mapUserFacingError(localizationStore, localeCode, errorCause)
val hasRound = round != null
val showLoading = isLoading && !hasRound
return FeedUiState(
title = localized(localeCode, R.string.feed_title),
subtitle = localized(localeCode, R.string.feed_subtitle),
heroTitle = localized(localeCode, R.string.feed_hero_title),
heroSubtitle = localized(localeCode, R.string.feed_hero_subtitle),
heroTitle = round?.let { localizedEventTitle(it, localeCode) } ?: localized(localeCode, R.string.common_loading),
heroSubtitle = when {
showLoading -> localized(localeCode, R.string.common_loading)
bannerMessage != null && !hasRound -> bannerMessage
hasRound -> localized(localeCode, R.string.feed_hero_subtitle)
else -> localized(localeCode, R.string.feed_subtitle)
},
lockLabel = localized(localeCode, R.string.feed_lock_label),
lockValue = formatCountdown(round.event.lockAt, now),
lockValue = round?.let { formatCountdown(it.event.lockAt, now) } ?: "--:--",
oddsLabel = localized(localeCode, R.string.feed_odds_label),
oddsValue = formatOdds(round),
oddsValue = round?.let { formatOdds(it) } ?: "--",
ctaText = localized(localeCode, R.string.feed_cta),
retryText = localized(localeCode, R.string.common_retry),
hasRound = hasRound,
isLoading = showLoading,
bannerMessage = bannerMessage,
)
}
@@ -86,6 +110,10 @@ class FeedViewModel(
return localizationStore.string(localeCode, resId)
}
private fun localizedEventTitle(round: StudyRound, localeCode: String): String {
return if (localeCode == "sv") round.event.titleSv else round.event.titleEn
}
private fun formatCountdown(lockAt: Instant, now: Instant): String {
val remainingMillis = (lockAt.toEpochMilliseconds() - now.toEpochMilliseconds()).coerceAtLeast(0)
val totalSeconds = (remainingMillis / 1_000).toInt()
@@ -15,6 +15,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
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
@@ -26,6 +27,7 @@ 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
@Composable
fun RoundScreen(
@@ -35,22 +37,48 @@ fun RoundScreen(
onConfirmSelection: () -> Unit,
onRevealComplete: () -> Unit,
onNextRound: () -> Unit,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
) {
HermesCard(modifier = modifier, elevated = true) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
com.hermes.study.core.designsystem.HermesSectionHeader(title = uiState.title, subtitle = uiState.subtitle)
when {
uiState.isLoading && !uiState.hasRound -> RoundLoadingState(
title = uiState.phaseLabel,
subtitle = uiState.subtitle,
)
!uiState.hasRound && uiState.bannerMessage != null -> RoundErrorState(
message = uiState.bannerMessage,
retryText = uiState.retryText,
onRetry = onRetry,
)
else -> {
if (uiState.bannerMessage != null) {
RoundBanner(message = uiState.bannerMessage)
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(220.dp),
) {
if (uiState.hasRound) {
StudyVideoPlayerView(
coordinator = playerCoordinator,
modifier = Modifier.fillMaxSize(),
)
} else {
RoundLoadingState(
title = uiState.phaseLabel,
subtitle = uiState.subtitle,
)
}
if (uiState.hasRound) {
Column(
modifier = Modifier
.align(Alignment.TopEnd)
@@ -69,6 +97,7 @@ fun RoundScreen(
modifier = Modifier.widthIn(min = 128.dp),
)
}
}
Text(
text = uiState.phaseLabel,
@@ -123,3 +152,68 @@ fun RoundScreen(
}
}
}
}
}
@Composable
private fun RoundLoadingState(
title: String,
subtitle: String,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(220.dp)
.clip(RoundedCornerShape(28.dp))
.background(HermesColors.surfaceElevated)
.padding(20.dp),
contentAlignment = Alignment.CenterStart,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
color = HermesColors.textPrimary,
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = HermesColors.textSecondary,
)
}
}
}
@Composable
private fun RoundErrorState(
message: String,
retryText: String,
onRetry: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = HermesColors.warning,
)
HermesPrimaryButton(text = retryText, onClick = onRetry)
}
}
@Composable
private fun RoundBanner(message: String) {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(HermesColors.warning.copy(alpha = 0.16f))
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
Text(
text = message,
style = MaterialTheme.typography.bodySmall,
color = HermesColors.warning,
)
}
}
@@ -4,19 +4,24 @@ 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 java.util.Locale
import java.util.UUID
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@@ -61,6 +66,22 @@ data class RoundUiState(
val resultWinLabel: String,
val resultLoseLabel: String,
val resultNextRoundTitle: String,
val retryText: String,
val hasRound: Boolean,
val isLoading: Boolean,
val bannerMessage: String?,
val isSubmittingSelection: Boolean,
)
private data class RoundUiInputs(
val round: StudyRound?,
val localeCode: String,
val phase: RoundPhase,
val selectedOutcomeId: String?,
val isLoading: Boolean,
val errorCause: Throwable?,
val actionMessage: String?,
val isSubmitting: Boolean,
)
class RoundViewModel(
@@ -73,6 +94,8 @@ class RoundViewModel(
private val localeFlow = localizationStore.localeCode
private val phaseFlow = MutableStateFlow(RoundPhase.PREVIEW)
private val selectedOutcomeIdFlow = MutableStateFlow<String?>(null)
private val actionMessageFlow = MutableStateFlow<String?>(null)
private val isSubmittingSelectionFlow = MutableStateFlow(false)
private val nowFlow = flow {
while (true) {
emit(Clock.System.now())
@@ -81,39 +104,59 @@ class RoundViewModel(
}
private var transitionJob: Job? = null
val uiState: StateFlow<RoundUiState> = combine(
roundFlow,
localeFlow,
phaseFlow,
selectedOutcomeIdFlow,
nowFlow,
) { round, localeCode, phase, selectedOutcomeId, now ->
buildUiState(
private val uiInputsFlow = combine(roundFlow, localeFlow) { round, localeCode ->
RoundUiInputs(
round = round,
localeCode = localeCode,
phase = phase,
selectedOutcomeId = selectedOutcomeId,
now = now,
phase = phaseFlow.value,
selectedOutcomeId = selectedOutcomeIdFlow.value,
isLoading = repository.isLoading.value,
errorCause = repository.errorCause.value,
actionMessage = actionMessageFlow.value,
isSubmitting = isSubmittingSelectionFlow.value,
)
}
.combine(phaseFlow) { inputs, phase -> inputs.copy(phase = phase) }
.combine(selectedOutcomeIdFlow) { inputs, selectedOutcomeId -> inputs.copy(selectedOutcomeId = selectedOutcomeId) }
.combine(repository.isLoading) { inputs, isLoading -> inputs.copy(isLoading = isLoading) }
.combine(repository.errorCause) { inputs, errorCause -> inputs.copy(errorCause = errorCause) }
.combine(actionMessageFlow) { inputs, actionMessage -> inputs.copy(actionMessage = actionMessage) }
.combine(isSubmittingSelectionFlow) { inputs, isSubmitting -> inputs.copy(isSubmitting = isSubmitting) }
val uiState: StateFlow<RoundUiState> = combine(uiInputsFlow, nowFlow) { inputs, now ->
buildUiState(inputs, now)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = buildUiState(
RoundUiInputs(
round = roundFlow.value,
localeCode = localeFlow.value,
phase = phaseFlow.value,
selectedOutcomeId = selectedOutcomeIdFlow.value,
now = Clock.System.now(),
isLoading = repository.isLoading.value,
errorCause = repository.errorCause.value,
actionMessage = actionMessageFlow.value,
isSubmitting = isSubmittingSelectionFlow.value,
),
Clock.System.now(),
),
)
init {
startPreview(roundFlow.value)
viewModelScope.launch {
roundFlow
.filterNotNull()
.distinctUntilChangedBy { it.event.id }
.collect { round ->
startPreview(round)
}
}
}
fun onSelectOutcome(outcomeId: String) {
val round = roundFlow.value
if (phaseFlow.value != RoundPhase.PREVIEW) {
val round = roundFlow.value ?: return
if (phaseFlow.value != RoundPhase.PREVIEW || isSubmittingSelectionFlow.value) {
return
}
@@ -133,9 +176,9 @@ class RoundViewModel(
}
fun onConfirmSelection() {
val round = roundFlow.value
val round = roundFlow.value ?: return
val selectedOutcomeId = selectedOutcomeIdFlow.value ?: return
if (phaseFlow.value != RoundPhase.PREVIEW) {
if (phaseFlow.value != RoundPhase.PREVIEW || isSubmittingSelectionFlow.value) {
return
}
@@ -143,6 +186,34 @@ class RoundViewModel(
return
}
val session = repository.currentSession.value
if (session == null) {
actionMessageFlow.value = localized(localeFlow.value, R.string.errors_session_expired)
return
}
isSubmittingSelectionFlow.value = true
actionMessageFlow.value = null
viewModelScope.launch {
try {
val response = repository.submitBetIntent(
HermesBetIntentRequest(
sessionId = session.sessionId,
eventId = round.event.id,
marketId = round.market.id,
outcomeId = selectedOutcomeId,
idempotencyKey = UUID.randomUUID().toString(),
clientSentAt = Clock.System.now(),
),
)
if (!response.accepted) {
actionMessageFlow.value = localized(localeFlow.value, R.string.errors_generic)
phaseFlow.value = RoundPhase.PREVIEW
return@launch
}
analyticsTracker.track(
"selection_submitted",
attributes = baseSelectionAttributes(selectedOutcomeId),
@@ -170,10 +241,17 @@ class RoundViewModel(
)
}
}
} catch (error: Throwable) {
actionMessageFlow.value = mapUserFacingError(localizationStore, localeFlow.value, error)
phaseFlow.value = RoundPhase.PREVIEW
} finally {
isSubmittingSelectionFlow.value = false
}
}
}
fun onRevealAcknowledged() {
val round = roundFlow.value
val round = roundFlow.value ?: return
val selectedOutcomeId = selectedOutcomeIdFlow.value ?: return
analyticsTracker.track(
@@ -197,14 +275,17 @@ class RoundViewModel(
viewModelScope.launch {
transitionJob?.cancel()
val round = repository.refreshRoundFromNetwork()
startPreview(round)
actionMessageFlow.value = null
runCatching { repository.refreshRoundFromNetwork() }
}
}
private fun startPreview(round: StudyRound) {
transitionJob?.cancel()
phaseFlow.value = RoundPhase.PREVIEW
selectedOutcomeIdFlow.value = null
actionMessageFlow.value = null
isSubmittingSelectionFlow.value = false
playerCoordinator.preparePreview(round.media.hlsMasterUrl)
playerCoordinator.player.seekTo(round.media.previewStartMs.toLong())
@@ -213,39 +294,55 @@ class RoundViewModel(
analyticsTracker.track("screen_viewed", attributes = mapOf("screen_name" to "round"))
}
private fun buildUiState(
round: StudyRound,
localeCode: String,
phase: RoundPhase,
selectedOutcomeId: String?,
now: Instant,
): RoundUiState {
val remainingMillis = round.event.lockAt.toEpochMilliseconds() - now.toEpochMilliseconds()
val isTimerLocked = remainingMillis <= 0
val countdownText = formatCountdown(remainingMillis)
val selectionLocked = phase != RoundPhase.PREVIEW || isTimerLocked
val options = selectionOptions(round, localeCode)
val selectedTitle = resolveSelectedOutcomeTitle(round, localeCode, selectedOutcomeId)
val winningTitle = resolveWinningOutcomeTitle(round, localeCode)
private fun buildUiState(inputs: RoundUiInputs, now: Instant): RoundUiState {
val round = inputs.round
val localeCode = inputs.localeCode
val phase = inputs.phase
val selectedOutcomeId = inputs.selectedOutcomeId
val isLoading = inputs.isLoading
val errorCause = inputs.errorCause
val actionMessage = inputs.actionMessage
val isSubmitting = inputs.isSubmitting
val bannerMessage = actionMessage ?: mapUserFacingError(localizationStore, localeCode, errorCause)
val hasRound = round != null
val loading = isLoading && !hasRound
val countdownText = round?.let { formatCountdown(it.event.lockAt, now) } ?: "--:--"
val countdownWarning = round != null && phase == RoundPhase.PREVIEW && !isTimerLocked(round, now) && (round.event.lockAt.toEpochMilliseconds() - now.toEpochMilliseconds()) <= 10_000
val selectionLocked = !hasRound || phase != RoundPhase.PREVIEW || isTimerLocked(round, now) || isSubmitting
val options = round?.let { selectionOptions(it, localeCode) } ?: emptyList()
val selectedTitle = round?.let { resolveSelectedOutcomeTitle(it, localeCode, selectedOutcomeId) } ?: localized(localeCode, R.string.round_selection_prompt)
val winningTitle = round?.let { resolveWinningOutcomeTitle(it, localeCode) } ?: localized(localeCode, R.string.round_selection_prompt)
return RoundUiState(
title = localized(localeCode, R.string.round_title),
subtitle = localized(localeCode, R.string.round_subtitle),
countdownLabel = localized(localeCode, R.string.round_countdown_label),
countdownText = countdownText,
countdownWarning = phase == RoundPhase.PREVIEW && !isTimerLocked && remainingMillis <= 10_000,
countdownWarning = countdownWarning,
oddsLabel = localized(localeCode, R.string.round_odds_label),
oddsValue = oddsSummary(round),
oddsValue = round?.let { oddsSummary(it) } ?: "--",
phase = phase,
phaseLabel = phaseLabel(localeCode, phase, isTimerLocked),
selectionStatusText = if (selectionLocked) localized(localeCode, R.string.round_locked_label) else localized(localeCode, R.string.round_selection_prompt),
phaseLabel = when {
loading -> localized(localeCode, R.string.common_loading)
!hasRound && bannerMessage != null -> localized(localeCode, R.string.errors_generic)
phase == RoundPhase.PREVIEW && round != null && isTimerLocked(round, now) -> localized(localeCode, R.string.round_locked_label)
else -> phaseLabel(localeCode, phase, round?.let { isTimerLocked(it, now) } ?: false)
},
selectionStatusText = when {
isSubmitting -> localized(localeCode, R.string.common_loading)
!hasRound && loading -> localized(localeCode, R.string.common_loading)
!hasRound && bannerMessage != null -> bannerMessage
selectionLocked -> localized(localeCode, R.string.round_locked_label)
else -> localized(localeCode, R.string.round_selection_prompt)
},
selectionLocked = selectionLocked,
selectionOptions = options,
selectedOutcomeId = selectedOutcomeId,
selectedOutcomeTitle = selectedTitle,
winningOutcomeTitle = winningTitle,
didWin = selectedOutcomeId == round.settlement.winningOutcomeId,
videoUrl = round.media.hlsMasterUrl,
didWin = round != null && selectedOutcomeId == round.settlement.winningOutcomeId,
videoUrl = round?.media?.hlsMasterUrl.orEmpty(),
confirmTitle = localized(localeCode, R.string.round_primary_cta),
revealTitle = localized(localeCode, R.string.reveal_title),
revealSubtitle = localized(localeCode, R.string.reveal_subtitle),
@@ -259,6 +356,11 @@ class RoundViewModel(
resultWinLabel = localized(localeCode, R.string.result_win),
resultLoseLabel = localized(localeCode, R.string.result_lose),
resultNextRoundTitle = localized(localeCode, R.string.result_next_round),
retryText = localized(localeCode, R.string.common_retry),
hasRound = hasRound,
isLoading = loading,
bannerMessage = bannerMessage,
isSubmittingSelection = isSubmitting,
)
}
@@ -317,8 +419,9 @@ class RoundViewModel(
return String.format(Locale.US, "%.2f", value)
}
private fun formatCountdown(remainingMillis: Long): String {
val totalSeconds = (remainingMillis.coerceAtLeast(0) / 1_000).toInt()
private fun formatCountdown(lockAt: Instant, now: Instant): String {
val remainingMillis = (lockAt.toEpochMilliseconds() - now.toEpochMilliseconds()).coerceAtLeast(0)
val totalSeconds = (remainingMillis / 1_000).toInt()
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return String.format(Locale.US, "%02d:%02d", minutes, seconds)
@@ -333,8 +436,8 @@ class RoundViewModel(
}
}
private fun isTimerLocked(round: StudyRound, now: Instant): Boolean {
return now.toEpochMilliseconds() >= round.event.lockAt.toEpochMilliseconds()
private fun isTimerLocked(round: StudyRound?, now: Instant): Boolean {
return round != null && now.toEpochMilliseconds() >= round.event.lockAt.toEpochMilliseconds()
}
private fun baseSelectionAttributes(outcomeId: String): Map<String, String> {
@@ -2,6 +2,8 @@ package com.hermes.study.feature.session
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@@ -10,27 +12,136 @@ import com.hermes.study.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
@Composable
fun SessionView(
uiState: SessionUiState,
localizationStore: HermesLocalizationStore,
localeCode: String,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
) {
HermesCard(modifier = modifier) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
HermesSectionHeader(
title = localizationStore.string(localeCode, R.string.session_title),
subtitle = localizationStore.string(localeCode, R.string.session_subtitle),
title = localizationStore.string(uiState.localeCode, R.string.session_title),
subtitle = localizationStore.string(uiState.localeCode, R.string.session_subtitle),
)
when {
uiState.isLoading && !uiState.hasSession -> SessionLoadingState(
title = uiState.statusText,
subtitle = localizationStore.string(uiState.localeCode, R.string.session_note),
)
!uiState.hasSession && uiState.bannerMessage != null -> SessionErrorState(
message = uiState.bannerMessage,
retryText = localizationStore.string(uiState.localeCode, R.string.common_retry),
onRetry = onRetry,
)
else -> {
SessionStatusBadge(
text = uiState.statusText,
warning = uiState.bannerMessage != null,
)
if (uiState.bannerMessage != null) {
SessionBanner(
message = uiState.bannerMessage,
)
}
SessionRow(
label = localizationStore.string(uiState.localeCode, R.string.session_id_label),
value = uiState.sessionId ?: "--",
)
SessionRow(
label = localizationStore.string(uiState.localeCode, R.string.session_user_id_label),
value = uiState.userId ?: "--",
)
SessionRow(
label = localizationStore.string(uiState.localeCode, R.string.session_locale_label),
value = uiState.localeCode.uppercase(),
)
SessionRow(
label = localizationStore.string(uiState.localeCode, R.string.session_started_label),
value = uiState.startedAt ?: "--",
)
SessionRow(
label = localizationStore.string(uiState.localeCode, R.string.session_variant_label),
value = uiState.experimentVariant ?: "--",
)
Text(
text = localizationStore.string(localeCode, R.string.session_note),
text = localizationStore.string(uiState.localeCode, R.string.session_note),
style = MaterialTheme.typography.bodyMedium,
color = HermesColors.textSecondary,
)
}
}
}
}
}
@Composable
private fun SessionLoadingState(
title: String,
subtitle: String,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(text = title, style = MaterialTheme.typography.bodyLarge, color = HermesColors.textPrimary)
Text(text = subtitle, style = MaterialTheme.typography.bodyMedium, color = HermesColors.textSecondary)
}
}
@Composable
private fun SessionErrorState(
message: String,
retryText: String,
onRetry: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(text = message, style = MaterialTheme.typography.bodyMedium, color = HermesColors.warning)
HermesPrimaryButton(text = retryText, onClick = onRetry)
}
}
@Composable
private fun SessionStatusBadge(
text: String,
warning: Boolean,
) {
Text(
text = text,
style = MaterialTheme.typography.labelMedium,
color = if (warning) HermesColors.warning else HermesColors.accent,
)
}
@Composable
private fun SessionBanner(
message: String,
) {
Text(
text = message,
style = MaterialTheme.typography.bodySmall,
color = HermesColors.warning,
)
}
@Composable
private fun SessionRow(
label: String,
value: String,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(text = label, style = MaterialTheme.typography.bodyMedium, color = HermesColors.textSecondary)
Text(text = value, style = MaterialTheme.typography.bodyMedium, color = HermesColors.textPrimary)
}
}
@@ -0,0 +1,147 @@
package com.hermes.study.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 java.util.Locale
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
data class SessionUiState(
val isLoading: Boolean,
val hasSession: Boolean,
val statusText: String,
val bannerMessage: String?,
val sessionId: String?,
val userId: String?,
val localeCode: String,
val startedAt: String?,
val experimentVariant: String?,
val appVersion: String?,
val deviceModel: String?,
val osVersion: String?,
)
class SessionViewModel(
private val repository: HermesRepository,
private val localizationStore: HermesLocalizationStore,
private val analyticsTracker: HermesAnalyticsTracker,
private val sessionRequestFactory: (String) -> HermesSessionStartRequest,
) : ViewModel() {
private var bootstrapJob: Job? = null
private var lastLocaleCode: String = localizationStore.localeCode.value
val uiState: StateFlow<SessionUiState> = combine(
repository.currentSession,
repository.isLoading,
repository.errorCause,
localizationStore.localeCode,
) { session, isLoading, errorCause, localeCode ->
val bannerMessage = mapUserFacingError(localizationStore, localeCode, errorCause)
val statusText = when {
session != null -> localizationStore.string(localeCode, R.string.session_status_ready)
bannerMessage != null -> localizationStore.string(localeCode, R.string.session_status_error)
else -> localizationStore.string(localeCode, R.string.session_status_loading)
}
SessionUiState(
isLoading = isLoading && session == null,
hasSession = session != null,
statusText = statusText,
bannerMessage = bannerMessage,
sessionId = session?.sessionId?.toString(),
userId = session?.userId?.toString(),
localeCode = localeCode,
startedAt = session?.startedAt?.formatCompact(),
experimentVariant = session?.experimentVariant,
appVersion = session?.appVersion,
deviceModel = session?.deviceModel,
osVersion = session?.osVersion,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = SessionUiState(
isLoading = true,
hasSession = false,
statusText = localizationStore.string(localizationStore.localeCode.value, R.string.session_status_loading),
bannerMessage = null,
sessionId = null,
userId = null,
localeCode = localizationStore.localeCode.value,
startedAt = null,
experimentVariant = null,
appVersion = null,
deviceModel = null,
osVersion = null,
),
)
fun bootstrap(localeCode: String) {
lastLocaleCode = localeCode
if (bootstrapJob?.isActive == true) {
return
}
if (repository.currentSession.value != null && repository.currentRound.value != null) {
return
}
bootstrapJob = viewModelScope.launch {
analyticsTracker.track(
"session_start_requested",
attributes = mapOf("screen_name" to "session", "locale_code" to localeCode),
)
try {
repository.bootstrap(sessionRequestFactory(localeCode))
analyticsTracker.track(
"session_started",
attributes = mapOf("screen_name" to "session", "locale_code" to localeCode),
)
} catch (error: Throwable) {
if (error is CancellationException) {
return@launch
}
analyticsTracker.track(
"session_start_failed",
attributes = mapOf("screen_name" to "session", "locale_code" to localeCode),
)
}
}
}
fun retryBootstrap() {
bootstrapJob?.cancel()
bootstrapJob = null
bootstrap(lastLocaleCode)
}
private fun Instant.formatCompact(): String {
val local = toLocalDateTime(TimeZone.currentSystemDefault())
return String.format(
Locale.US,
"%04d-%02d-%02d %02d:%02d",
local.year,
local.monthNumber,
local.dayOfMonth,
local.hour,
local.minute,
)
}
}
@@ -54,4 +54,12 @@
<string name="session_title">Session</string>
<string name="session_subtitle">Sessionssynk och livscykelkontroller.</string>
<string name="session_note">Sessionsstatus visas här när backend-sessionen har startat.</string>
<string name="session_status_loading">Startar session</string>
<string name="session_status_ready">Session aktiv</string>
<string name="session_status_error">Sessionen är otillgänglig</string>
<string name="session_id_label">Sessions-ID</string>
<string name="session_user_id_label">Användar-ID</string>
<string name="session_locale_label">Språk</string>
<string name="session_started_label">Startad</string>
<string name="session_variant_label">Variant</string>
</resources>
@@ -54,4 +54,12 @@
<string name="session_title">Session</string>
<string name="session_subtitle">Session sync and lifecycle controls.</string>
<string name="session_note">Session state will appear here once the backend session is started.</string>
<string name="session_status_loading">Starting session</string>
<string name="session_status_ready">Session active</string>
<string name="session_status_error">Session unavailable</string>
<string name="session_id_label">Session ID</string>
<string name="session_user_id_label">User ID</string>
<string name="session_locale_label">Locale</string>
<string name="session_started_label">Started</string>
<string name="session_variant_label">Variant</string>
</resources>