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