view
This commit is contained in:
@@ -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 {
|
||||
|
||||
+21
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+35
-7
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+147
-44
@@ -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> {
|
||||
|
||||
+115
-4
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+147
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user