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"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application <application
android:name=".HermesApplication" android:name=".HermesApplication"
android:allowBackup="true" android:allowBackup="true"
android:usesCleartextTraffic="true"
android:label="@string/app_name" android:label="@string/app_name"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Hermes"> android:theme="@style/Theme.Hermes">
@@ -1,6 +1,7 @@
package com.hermes.study package com.hermes.study
import android.content.Context import android.content.Context
import android.os.Build
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import com.hermes.study.core.analytics.HermesAnalyticsTracker 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.media.HermesPlayerCoordinator
import com.hermes.study.core.network.HermesApiClient import com.hermes.study.core.network.HermesApiClient
import com.hermes.study.data.HermesRepository import com.hermes.study.data.HermesRepository
import com.hermes.study.domain.HermesSessionStartRequest
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
class HermesAppContainer(context: Context) { class HermesAppContainer(context: Context) {
@@ -24,6 +26,25 @@ class HermesAppContainer(context: Context) {
fun roundViewModelFactory(): ViewModelProvider.Factory = HermesViewModelFactory { fun roundViewModelFactory(): ViewModelProvider.Factory = HermesViewModelFactory {
com.hermes.study.feature.round.RoundViewModel(repository, localizationStore, analyticsTracker, playerCoordinator) 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 { 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.Text
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp 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.FeedScreen
import com.hermes.study.feature.feed.FeedViewModel import com.hermes.study.feature.feed.FeedViewModel
import com.hermes.study.feature.session.SessionView 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.settings.SettingsView
import com.hermes.study.feature.round.RoundScreen import com.hermes.study.feature.round.RoundScreen
import com.hermes.study.feature.round.RoundViewModel import com.hermes.study.feature.round.RoundViewModel
@@ -36,10 +38,16 @@ import androidx.compose.material3.ExperimentalMaterial3Api
fun HermesStudyApp(container: HermesAppContainer) { fun HermesStudyApp(container: HermesAppContainer) {
val feedViewModel: FeedViewModel = viewModel(factory = container.feedViewModelFactory()) val feedViewModel: FeedViewModel = viewModel(factory = container.feedViewModelFactory())
val roundViewModel: RoundViewModel = viewModel(factory = container.roundViewModelFactory()) val roundViewModel: RoundViewModel = viewModel(factory = container.roundViewModelFactory())
val sessionViewModel: SessionViewModel = viewModel(factory = container.sessionViewModelFactory())
val localeCode by container.localizationStore.localeCode.collectAsStateWithLifecycle() val localeCode by container.localizationStore.localeCode.collectAsStateWithLifecycle()
val sessionState by sessionViewModel.uiState.collectAsStateWithLifecycle()
val feedState by feedViewModel.uiState.collectAsStateWithLifecycle() val feedState by feedViewModel.uiState.collectAsStateWithLifecycle()
val roundState by roundViewModel.uiState.collectAsStateWithLifecycle() val roundState by roundViewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(localeCode) {
sessionViewModel.bootstrap(localeCode)
}
Scaffold( Scaffold(
topBar = { topBar = {
HermesAppBar( HermesAppBar(
@@ -60,7 +68,8 @@ fun HermesStudyApp(container: HermesAppContainer) {
item { item {
FeedScreen( FeedScreen(
uiState = feedState, uiState = feedState,
onWatchPreview = feedViewModel::onWatchPreview onWatchPreview = feedViewModel::onWatchPreview,
onRetry = sessionViewModel::retryBootstrap,
) )
} }
item { item {
@@ -70,13 +79,15 @@ fun HermesStudyApp(container: HermesAppContainer) {
onSelectOutcome = roundViewModel::onSelectOutcome, onSelectOutcome = roundViewModel::onSelectOutcome,
onConfirmSelection = roundViewModel::onConfirmSelection, onConfirmSelection = roundViewModel::onConfirmSelection,
onRevealComplete = roundViewModel::onRevealAcknowledged, onRevealComplete = roundViewModel::onRevealAcknowledged,
onNextRound = roundViewModel::onNextRound onNextRound = roundViewModel::onNextRound,
onRetry = sessionViewModel::retryBootstrap,
) )
} }
item { item {
SessionView( SessionView(
uiState = sessionState,
localizationStore = container.localizationStore, localizationStore = container.localizationStore,
localeCode = localeCode, onRetry = sessionViewModel::retryBootstrap,
) )
} }
item { 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.HermesMarket
import com.hermes.study.domain.HermesOddsVersion import com.hermes.study.domain.HermesOddsVersion
import com.hermes.study.domain.HermesSessionResponse import com.hermes.study.domain.HermesSessionResponse
import com.hermes.study.domain.HermesSessionStartRequest
import com.hermes.study.domain.HermesSettlement import com.hermes.study.domain.HermesSettlement
import com.hermes.study.domain.StudyRound import com.hermes.study.domain.StudyRound
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow 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) { 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 { suspend fun bootstrap(request: HermesSessionStartRequest): HermesSessionResponse {
roundState.value = SampleStudyData.round() return sessionMutex.withLock {
return roundState.value _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 { session
return apiClient.startSession(request) } 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 { suspend fun endSession(): HermesSessionResponse {
return apiClient.endSession() val session = apiClient.endSession()
_currentSession.value = session
return session
} }
suspend fun submitBetIntent(request: HermesBetIntentRequest): HermesBetIntentResponse { suspend fun submitBetIntent(request: HermesBetIntentRequest): HermesBetIntentResponse {
@@ -62,4 +109,23 @@ class HermesRepository(private val apiClient: HermesApiClient) {
suspend fun submitAnalyticsBatch(request: HermesAnalyticsBatchRequest) { suspend fun submitAnalyticsBatch(request: HermesAnalyticsBatchRequest) {
apiClient.submitAnalyticsBatch(request) 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( fun FeedScreen(
uiState: FeedUiState, uiState: FeedUiState,
onWatchPreview: () -> Unit, onWatchPreview: () -> Unit,
onRetry: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
HermesCard(modifier = modifier, elevated = true) { HermesCard(modifier = modifier, elevated = true) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
HermesSectionHeader(title = uiState.title, subtitle = uiState.subtitle) 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( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -65,7 +81,76 @@ fun FeedScreen(
HermesMetricChip(label = uiState.oddsLabel, value = uiState.oddsValue, modifier = Modifier.fillMaxWidth(0.5f)) 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 androidx.lifecycle.viewModelScope
import com.hermes.study.R import com.hermes.study.R
import com.hermes.study.core.analytics.HermesAnalyticsTracker 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.localization.HermesLocalizationStore
import com.hermes.study.data.HermesRepository import com.hermes.study.data.HermesRepository
import com.hermes.study.domain.StudyRound import com.hermes.study.domain.StudyRound
@@ -27,6 +28,10 @@ data class FeedUiState(
val oddsLabel: String, val oddsLabel: String,
val oddsValue: String, val oddsValue: String,
val ctaText: String, val ctaText: String,
val retryText: String,
val hasRound: Boolean,
val isLoading: Boolean,
val bannerMessage: String?,
) )
class FeedViewModel( class FeedViewModel(
@@ -43,14 +48,16 @@ class FeedViewModel(
} }
} }
val uiState: StateFlow<FeedUiState> = combine(roundFlow, localeFlow, nowFlow) { round, localeCode, now -> val uiState: StateFlow<FeedUiState> = combine(roundFlow, localeFlow, repository.isLoading, repository.errorCause, nowFlow) { round, localeCode, isLoading, errorCause, now ->
buildUiState(round = round, localeCode = localeCode, now = now) buildUiState(round = round, localeCode = localeCode, isLoading = isLoading, errorCause = errorCause, now = now)
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.Eagerly, started = SharingStarted.Eagerly,
initialValue = buildUiState( initialValue = buildUiState(
round = roundFlow.value, round = roundFlow.value,
localeCode = localeFlow.value, localeCode = localeFlow.value,
isLoading = repository.isLoading.value,
errorCause = repository.errorCause.value,
now = Clock.System.now(), now = Clock.System.now(),
), ),
) )
@@ -61,6 +68,10 @@ class FeedViewModel(
} }
fun onWatchPreview() { fun onWatchPreview() {
if (roundFlow.value == null) {
return
}
analyticsTracker.track("next_round_requested", attributes = mapOf("screen_name" to "feed")) analyticsTracker.track("next_round_requested", attributes = mapOf("screen_name" to "feed"))
analyticsTracker.track( analyticsTracker.track(
"cta_pressed", "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( return FeedUiState(
title = localized(localeCode, R.string.feed_title), title = localized(localeCode, R.string.feed_title),
subtitle = localized(localeCode, R.string.feed_subtitle), subtitle = localized(localeCode, R.string.feed_subtitle),
heroTitle = localized(localeCode, R.string.feed_hero_title), heroTitle = round?.let { localizedEventTitle(it, localeCode) } ?: localized(localeCode, R.string.common_loading),
heroSubtitle = localized(localeCode, R.string.feed_hero_subtitle), 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), 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), oddsLabel = localized(localeCode, R.string.feed_odds_label),
oddsValue = formatOdds(round), oddsValue = round?.let { formatOdds(it) } ?: "--",
ctaText = localized(localeCode, R.string.feed_cta), 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) 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 { private fun formatCountdown(lockAt: Instant, now: Instant): String {
val remainingMillis = (lockAt.toEpochMilliseconds() - now.toEpochMilliseconds()).coerceAtLeast(0) val remainingMillis = (lockAt.toEpochMilliseconds() - now.toEpochMilliseconds()).coerceAtLeast(0)
val totalSeconds = (remainingMillis / 1_000).toInt() val totalSeconds = (remainingMillis / 1_000).toInt()
@@ -15,6 +15,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.hermes.study.core.designsystem.HermesColors 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.reveal.RevealPanel
import com.hermes.study.feature.result.ResultPanel import com.hermes.study.feature.result.ResultPanel
import com.hermes.study.feature.selection.SelectionPanel import com.hermes.study.feature.selection.SelectionPanel
import com.hermes.study.core.designsystem.HermesPrimaryButton
@Composable @Composable
fun RoundScreen( fun RoundScreen(
@@ -35,22 +37,48 @@ fun RoundScreen(
onConfirmSelection: () -> Unit, onConfirmSelection: () -> Unit,
onRevealComplete: () -> Unit, onRevealComplete: () -> Unit,
onNextRound: () -> Unit, onNextRound: () -> Unit,
onRetry: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
HermesCard(modifier = modifier, elevated = true) { HermesCard(modifier = modifier, elevated = true) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
com.hermes.study.core.designsystem.HermesSectionHeader(title = uiState.title, subtitle = uiState.subtitle) 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( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(220.dp), .height(220.dp),
) { ) {
if (uiState.hasRound) {
StudyVideoPlayerView( StudyVideoPlayerView(
coordinator = playerCoordinator, coordinator = playerCoordinator,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} else {
RoundLoadingState(
title = uiState.phaseLabel,
subtitle = uiState.subtitle,
)
}
if (uiState.hasRound) {
Column( Column(
modifier = Modifier modifier = Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
@@ -69,6 +97,7 @@ fun RoundScreen(
modifier = Modifier.widthIn(min = 128.dp), modifier = Modifier.widthIn(min = 128.dp),
) )
} }
}
Text( Text(
text = uiState.phaseLabel, 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 androidx.lifecycle.viewModelScope
import com.hermes.study.R import com.hermes.study.R
import com.hermes.study.core.analytics.HermesAnalyticsTracker 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.localization.HermesLocalizationStore
import com.hermes.study.core.media.HermesPlayerCoordinator import com.hermes.study.core.media.HermesPlayerCoordinator
import com.hermes.study.data.HermesRepository import com.hermes.study.data.HermesRepository
import com.hermes.study.domain.HermesOutcome import com.hermes.study.domain.HermesOutcome
import com.hermes.study.domain.HermesBetIntentRequest
import com.hermes.study.domain.StudyRound import com.hermes.study.domain.StudyRound
import com.hermes.study.feature.selection.SelectionOptionUi import com.hermes.study.feature.selection.SelectionOptionUi
import java.util.Locale import java.util.Locale
import java.util.UUID
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -61,6 +66,22 @@ data class RoundUiState(
val resultWinLabel: String, val resultWinLabel: String,
val resultLoseLabel: String, val resultLoseLabel: String,
val resultNextRoundTitle: 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( class RoundViewModel(
@@ -73,6 +94,8 @@ class RoundViewModel(
private val localeFlow = localizationStore.localeCode private val localeFlow = localizationStore.localeCode
private val phaseFlow = MutableStateFlow(RoundPhase.PREVIEW) private val phaseFlow = MutableStateFlow(RoundPhase.PREVIEW)
private val selectedOutcomeIdFlow = MutableStateFlow<String?>(null) private val selectedOutcomeIdFlow = MutableStateFlow<String?>(null)
private val actionMessageFlow = MutableStateFlow<String?>(null)
private val isSubmittingSelectionFlow = MutableStateFlow(false)
private val nowFlow = flow { private val nowFlow = flow {
while (true) { while (true) {
emit(Clock.System.now()) emit(Clock.System.now())
@@ -81,39 +104,59 @@ class RoundViewModel(
} }
private var transitionJob: Job? = null private var transitionJob: Job? = null
val uiState: StateFlow<RoundUiState> = combine( private val uiInputsFlow = combine(roundFlow, localeFlow) { round, localeCode ->
roundFlow, RoundUiInputs(
localeFlow,
phaseFlow,
selectedOutcomeIdFlow,
nowFlow,
) { round, localeCode, phase, selectedOutcomeId, now ->
buildUiState(
round = round, round = round,
localeCode = localeCode, localeCode = localeCode,
phase = phase, phase = phaseFlow.value,
selectedOutcomeId = selectedOutcomeId, selectedOutcomeId = selectedOutcomeIdFlow.value,
now = now, 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( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.Eagerly, started = SharingStarted.Eagerly,
initialValue = buildUiState( initialValue = buildUiState(
RoundUiInputs(
round = roundFlow.value, round = roundFlow.value,
localeCode = localeFlow.value, localeCode = localeFlow.value,
phase = phaseFlow.value, phase = phaseFlow.value,
selectedOutcomeId = selectedOutcomeIdFlow.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 { init {
startPreview(roundFlow.value) viewModelScope.launch {
roundFlow
.filterNotNull()
.distinctUntilChangedBy { it.event.id }
.collect { round ->
startPreview(round)
}
}
} }
fun onSelectOutcome(outcomeId: String) { fun onSelectOutcome(outcomeId: String) {
val round = roundFlow.value val round = roundFlow.value ?: return
if (phaseFlow.value != RoundPhase.PREVIEW) { if (phaseFlow.value != RoundPhase.PREVIEW || isSubmittingSelectionFlow.value) {
return return
} }
@@ -133,9 +176,9 @@ class RoundViewModel(
} }
fun onConfirmSelection() { fun onConfirmSelection() {
val round = roundFlow.value val round = roundFlow.value ?: return
val selectedOutcomeId = selectedOutcomeIdFlow.value ?: return val selectedOutcomeId = selectedOutcomeIdFlow.value ?: return
if (phaseFlow.value != RoundPhase.PREVIEW) { if (phaseFlow.value != RoundPhase.PREVIEW || isSubmittingSelectionFlow.value) {
return return
} }
@@ -143,6 +186,34 @@ class RoundViewModel(
return 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( analyticsTracker.track(
"selection_submitted", "selection_submitted",
attributes = baseSelectionAttributes(selectedOutcomeId), 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() { fun onRevealAcknowledged() {
val round = roundFlow.value val round = roundFlow.value ?: return
val selectedOutcomeId = selectedOutcomeIdFlow.value ?: return val selectedOutcomeId = selectedOutcomeIdFlow.value ?: return
analyticsTracker.track( analyticsTracker.track(
@@ -197,14 +275,17 @@ class RoundViewModel(
viewModelScope.launch { viewModelScope.launch {
transitionJob?.cancel() transitionJob?.cancel()
val round = repository.refreshRoundFromNetwork() actionMessageFlow.value = null
startPreview(round) runCatching { repository.refreshRoundFromNetwork() }
} }
} }
private fun startPreview(round: StudyRound) { private fun startPreview(round: StudyRound) {
transitionJob?.cancel()
phaseFlow.value = RoundPhase.PREVIEW phaseFlow.value = RoundPhase.PREVIEW
selectedOutcomeIdFlow.value = null selectedOutcomeIdFlow.value = null
actionMessageFlow.value = null
isSubmittingSelectionFlow.value = false
playerCoordinator.preparePreview(round.media.hlsMasterUrl) playerCoordinator.preparePreview(round.media.hlsMasterUrl)
playerCoordinator.player.seekTo(round.media.previewStartMs.toLong()) playerCoordinator.player.seekTo(round.media.previewStartMs.toLong())
@@ -213,39 +294,55 @@ class RoundViewModel(
analyticsTracker.track("screen_viewed", attributes = mapOf("screen_name" to "round")) analyticsTracker.track("screen_viewed", attributes = mapOf("screen_name" to "round"))
} }
private fun buildUiState( private fun buildUiState(inputs: RoundUiInputs, now: Instant): RoundUiState {
round: StudyRound, val round = inputs.round
localeCode: String, val localeCode = inputs.localeCode
phase: RoundPhase, val phase = inputs.phase
selectedOutcomeId: String?, val selectedOutcomeId = inputs.selectedOutcomeId
now: Instant, val isLoading = inputs.isLoading
): RoundUiState { val errorCause = inputs.errorCause
val remainingMillis = round.event.lockAt.toEpochMilliseconds() - now.toEpochMilliseconds() val actionMessage = inputs.actionMessage
val isTimerLocked = remainingMillis <= 0 val isSubmitting = inputs.isSubmitting
val countdownText = formatCountdown(remainingMillis)
val selectionLocked = phase != RoundPhase.PREVIEW || isTimerLocked val bannerMessage = actionMessage ?: mapUserFacingError(localizationStore, localeCode, errorCause)
val options = selectionOptions(round, localeCode) val hasRound = round != null
val selectedTitle = resolveSelectedOutcomeTitle(round, localeCode, selectedOutcomeId) val loading = isLoading && !hasRound
val winningTitle = resolveWinningOutcomeTitle(round, localeCode) 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( return RoundUiState(
title = localized(localeCode, R.string.round_title), title = localized(localeCode, R.string.round_title),
subtitle = localized(localeCode, R.string.round_subtitle), subtitle = localized(localeCode, R.string.round_subtitle),
countdownLabel = localized(localeCode, R.string.round_countdown_label), countdownLabel = localized(localeCode, R.string.round_countdown_label),
countdownText = countdownText, countdownText = countdownText,
countdownWarning = phase == RoundPhase.PREVIEW && !isTimerLocked && remainingMillis <= 10_000, countdownWarning = countdownWarning,
oddsLabel = localized(localeCode, R.string.round_odds_label), oddsLabel = localized(localeCode, R.string.round_odds_label),
oddsValue = oddsSummary(round), oddsValue = round?.let { oddsSummary(it) } ?: "--",
phase = phase, phase = phase,
phaseLabel = phaseLabel(localeCode, phase, isTimerLocked), phaseLabel = when {
selectionStatusText = if (selectionLocked) localized(localeCode, R.string.round_locked_label) else localized(localeCode, R.string.round_selection_prompt), 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, selectionLocked = selectionLocked,
selectionOptions = options, selectionOptions = options,
selectedOutcomeId = selectedOutcomeId, selectedOutcomeId = selectedOutcomeId,
selectedOutcomeTitle = selectedTitle, selectedOutcomeTitle = selectedTitle,
winningOutcomeTitle = winningTitle, winningOutcomeTitle = winningTitle,
didWin = selectedOutcomeId == round.settlement.winningOutcomeId, didWin = round != null && selectedOutcomeId == round.settlement.winningOutcomeId,
videoUrl = round.media.hlsMasterUrl, videoUrl = round?.media?.hlsMasterUrl.orEmpty(),
confirmTitle = localized(localeCode, R.string.round_primary_cta), confirmTitle = localized(localeCode, R.string.round_primary_cta),
revealTitle = localized(localeCode, R.string.reveal_title), revealTitle = localized(localeCode, R.string.reveal_title),
revealSubtitle = localized(localeCode, R.string.reveal_subtitle), revealSubtitle = localized(localeCode, R.string.reveal_subtitle),
@@ -259,6 +356,11 @@ class RoundViewModel(
resultWinLabel = localized(localeCode, R.string.result_win), resultWinLabel = localized(localeCode, R.string.result_win),
resultLoseLabel = localized(localeCode, R.string.result_lose), resultLoseLabel = localized(localeCode, R.string.result_lose),
resultNextRoundTitle = localized(localeCode, R.string.result_next_round), 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) return String.format(Locale.US, "%.2f", value)
} }
private fun formatCountdown(remainingMillis: Long): String { private fun formatCountdown(lockAt: Instant, now: Instant): String {
val totalSeconds = (remainingMillis.coerceAtLeast(0) / 1_000).toInt() val remainingMillis = (lockAt.toEpochMilliseconds() - now.toEpochMilliseconds()).coerceAtLeast(0)
val totalSeconds = (remainingMillis / 1_000).toInt()
val minutes = totalSeconds / 60 val minutes = totalSeconds / 60
val seconds = totalSeconds % 60 val seconds = totalSeconds % 60
return String.format(Locale.US, "%02d:%02d", minutes, seconds) return String.format(Locale.US, "%02d:%02d", minutes, seconds)
@@ -333,8 +436,8 @@ class RoundViewModel(
} }
} }
private fun isTimerLocked(round: StudyRound, now: Instant): Boolean { private fun isTimerLocked(round: StudyRound?, now: Instant): Boolean {
return now.toEpochMilliseconds() >= round.event.lockAt.toEpochMilliseconds() return round != null && now.toEpochMilliseconds() >= round.event.lockAt.toEpochMilliseconds()
} }
private fun baseSelectionAttributes(outcomeId: String): Map<String, String> { 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.Arrangement
import androidx.compose.foundation.layout.Column 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.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import com.hermes.study.core.designsystem.HermesColors 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.designsystem.HermesSectionHeader
import com.hermes.study.core.localization.HermesLocalizationStore import com.hermes.study.core.localization.HermesLocalizationStore
@Composable @Composable
fun SessionView( fun SessionView(
uiState: SessionUiState,
localizationStore: HermesLocalizationStore, localizationStore: HermesLocalizationStore,
localeCode: String, onRetry: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
HermesCard(modifier = modifier) { HermesCard(modifier = modifier) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
HermesSectionHeader( HermesSectionHeader(
title = localizationStore.string(localeCode, R.string.session_title), title = localizationStore.string(uiState.localeCode, R.string.session_title),
subtitle = localizationStore.string(localeCode, R.string.session_subtitle), 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(
text = localizationStore.string(localeCode, R.string.session_note), text = localizationStore.string(uiState.localeCode, R.string.session_note),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = HermesColors.textSecondary, 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_title">Session</string>
<string name="session_subtitle">Sessionssynk och livscykelkontroller.</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_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> </resources>
@@ -54,4 +54,12 @@
<string name="session_title">Session</string> <string name="session_title">Session</string>
<string name="session_subtitle">Session sync and lifecycle controls.</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_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> </resources>