From e401b6dbabe11ee70766c5bc8ba4e54397289879 Mon Sep 17 00:00:00 2001 From: Love Billenius Date: Thu, 9 Apr 2026 17:03:33 +0200 Subject: [PATCH] view --- .../app/src/main/AndroidManifest.xml | 3 + .../com/hermes/study/HermesAppContainer.kt | 21 ++ .../java/com/hermes/study/HermesStudyApp.kt | 17 +- .../study/core/errors/HermesErrorMapper.kt | 21 ++ .../com/hermes/study/data/HermesRepository.kt | 88 ++++++- .../hermes/study/feature/feed/FeedScreen.kt | 147 ++++++++--- .../study/feature/feed/FeedViewModel.kt | 42 ++- .../hermes/study/feature/round/RoundScreen.kt | 234 ++++++++++++----- .../study/feature/round/RoundViewModel.kt | 243 +++++++++++++----- .../study/feature/session/SessionView.kt | 127 ++++++++- .../study/feature/session/SessionViewModel.kt | 147 +++++++++++ .../app/src/main/res/values-sv/strings.xml | 8 + .../app/src/main/res/values/strings.xml | 8 + 13 files changed, 906 insertions(+), 200 deletions(-) create mode 100644 mobile/android-app/app/src/main/java/com/hermes/study/core/errors/HermesErrorMapper.kt create mode 100644 mobile/android-app/app/src/main/java/com/hermes/study/feature/session/SessionViewModel.kt diff --git a/mobile/android-app/app/src/main/AndroidManifest.xml b/mobile/android-app/app/src/main/AndroidManifest.xml index 1d72880..760c0b8 100644 --- a/mobile/android-app/app/src/main/AndroidManifest.xml +++ b/mobile/android-app/app/src/main/AndroidManifest.xml @@ -1,9 +1,12 @@ + + diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/HermesAppContainer.kt b/mobile/android-app/app/src/main/java/com/hermes/study/HermesAppContainer.kt index e660b9c..9c7834c 100644 --- a/mobile/android-app/app/src/main/java/com/hermes/study/HermesAppContainer.kt +++ b/mobile/android-app/app/src/main/java/com/hermes/study/HermesAppContainer.kt @@ -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(private val creator: () -> T) : ViewModelProvider.Factory { diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/HermesStudyApp.kt b/mobile/android-app/app/src/main/java/com/hermes/study/HermesStudyApp.kt index f363f39..2f7338f 100644 --- a/mobile/android-app/app/src/main/java/com/hermes/study/HermesStudyApp.kt +++ b/mobile/android-app/app/src/main/java/com/hermes/study/HermesStudyApp.kt @@ -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 { diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/core/errors/HermesErrorMapper.kt b/mobile/android-app/app/src/main/java/com/hermes/study/core/errors/HermesErrorMapper.kt new file mode 100644 index 0000000..dbd1eaa --- /dev/null +++ b/mobile/android-app/app/src/main/java/com/hermes/study/core/errors/HermesErrorMapper.kt @@ -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) + } +} diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/data/HermesRepository.kt b/mobile/android-app/app/src/main/java/com/hermes/study/data/HermesRepository.kt index 9dea593..90b00b1 100644 --- a/mobile/android-app/app/src/main/java/com/hermes/study/data/HermesRepository.kt +++ b/mobile/android-app/app/src/main/java/com/hermes/study/data/HermesRepository.kt @@ -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(null) + private val _currentRound = MutableStateFlow(null) + private val _isLoading = MutableStateFlow(true) + private val _errorCause = MutableStateFlow(null) - val currentRound: StateFlow = roundState.asStateFlow() + val currentSession: StateFlow = _currentSession.asStateFlow() + val currentRound: StateFlow = _currentRound.asStateFlow() + val isLoading: StateFlow = _isLoading.asStateFlow() + val errorCause: StateFlow = _errorCause.asStateFlow() - fun observeRound(): Flow = roundState + fun observeRound(): Flow = currentRound - fun observeFeed(): Flow = roundState + fun observeFeed(): Flow = 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() + } + + session + } catch (error: Throwable) { + _errorCause.value = error + throw error + } finally { + _isLoading.value = false + } + } } - suspend fun startSession(request: com.hermes.study.domain.HermesSessionStartRequest): HermesSessionResponse { - return apiClient.startSession(request) + 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, + ) + } } diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/feature/feed/FeedScreen.kt b/mobile/android-app/app/src/main/java/com/hermes/study/feature/feed/FeedScreen.kt index 8724e11..d3ed071 100644 --- a/mobile/android-app/app/src/main/java/com/hermes/study/feature/feed/FeedScreen.kt +++ b/mobile/android-app/app/src/main/java/com/hermes/study/feature/feed/FeedScreen.kt @@ -27,45 +27,130 @@ 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) - 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.BottomStart, - ) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - text = uiState.heroTitle, - style = MaterialTheme.typography.headlineSmall, - color = HermesColors.textPrimary, - ) - Text( - text = uiState.heroSubtitle, - style = MaterialTheme.typography.bodyMedium, - color = HermesColors.textSecondary, - ) + 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() + .height(220.dp) + .clip(RoundedCornerShape(28.dp)) + .background( + Brush.linearGradient( + colors = listOf(HermesColors.surfaceElevated, HermesColors.background), + ), + ) + .padding(20.dp), + contentAlignment = Alignment.BottomStart, + ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = uiState.heroTitle, + style = MaterialTheme.typography.headlineSmall, + color = HermesColors.textPrimary, + ) + Text( + text = uiState.heroSubtitle, + style = MaterialTheme.typography.bodyMedium, + color = HermesColors.textSecondary, + ) + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { + HermesMetricChip(label = uiState.lockLabel, value = uiState.lockValue, modifier = Modifier.fillMaxWidth(0.5f)) + HermesMetricChip(label = uiState.oddsLabel, value = uiState.oddsValue, modifier = Modifier.fillMaxWidth(0.5f)) + } + + HermesPrimaryButton(text = uiState.ctaText, onClick = onWatchPreview, enabled = uiState.hasRound) } } - - Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { - HermesMetricChip(label = uiState.lockLabel, value = uiState.lockValue, modifier = Modifier.fillMaxWidth(0.5f)) - HermesMetricChip(label = uiState.oddsLabel, value = uiState.oddsValue, modifier = Modifier.fillMaxWidth(0.5f)) - } - - HermesPrimaryButton(text = uiState.ctaText, onClick = onWatchPreview) } } } + +@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, + ) + } +} diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/feature/feed/FeedViewModel.kt b/mobile/android-app/app/src/main/java/com/hermes/study/feature/feed/FeedViewModel.kt index 64dd553..760f4db 100644 --- a/mobile/android-app/app/src/main/java/com/hermes/study/feature/feed/FeedViewModel.kt +++ b/mobile/android-app/app/src/main/java/com/hermes/study/feature/feed/FeedViewModel.kt @@ -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 = combine(roundFlow, localeFlow, nowFlow) { round, localeCode, now -> - buildUiState(round = round, localeCode = localeCode, now = now) + val uiState: StateFlow = 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() diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/feature/round/RoundScreen.kt b/mobile/android-app/app/src/main/java/com/hermes/study/feature/round/RoundScreen.kt index 3a78ab8..cbba2a3 100644 --- a/mobile/android-app/app/src/main/java/com/hermes/study/feature/round/RoundScreen.kt +++ b/mobile/android-app/app/src/main/java/com/hermes/study/feature/round/RoundScreen.kt @@ -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,91 +37,183 @@ 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) - Box( - modifier = Modifier - .fillMaxWidth() - .height(220.dp), - ) { - StudyVideoPlayerView( - coordinator = playerCoordinator, - modifier = Modifier.fillMaxSize(), + when { + uiState.isLoading && !uiState.hasRound -> RoundLoadingState( + title = uiState.phaseLabel, + subtitle = uiState.subtitle, ) - Column( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(12.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - HermesCountdownBadge( - label = uiState.countdownLabel, - value = uiState.countdownText, - warning = uiState.countdownWarning, - ) + !uiState.hasRound && uiState.bannerMessage != null -> RoundErrorState( + message = uiState.bannerMessage, + retryText = uiState.retryText, + onRetry = onRetry, + ) - HermesMetricChip( - label = uiState.oddsLabel, - value = uiState.oddsValue, - modifier = Modifier.widthIn(min = 128.dp), - ) - } + else -> { + if (uiState.bannerMessage != null) { + RoundBanner(message = uiState.bannerMessage) + } - Text( - text = uiState.phaseLabel, - modifier = Modifier - .align(Alignment.BottomStart) - .padding(12.dp) - .background( - color = Color.Black.copy(alpha = 0.35f), - shape = RoundedCornerShape(999.dp), + 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) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + HermesCountdownBadge( + label = uiState.countdownLabel, + value = uiState.countdownText, + warning = uiState.countdownWarning, + ) + + HermesMetricChip( + label = uiState.oddsLabel, + value = uiState.oddsValue, + modifier = Modifier.widthIn(min = 128.dp), + ) + } + } + + Text( + text = uiState.phaseLabel, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(12.dp) + .background( + color = Color.Black.copy(alpha = 0.35f), + shape = RoundedCornerShape(999.dp), + ) + .padding(horizontal = 12.dp, vertical = 8.dp), + style = MaterialTheme.typography.labelMedium, + color = HermesColors.textPrimary, ) - .padding(horizontal = 12.dp, vertical = 8.dp), - style = MaterialTheme.typography.labelMedium, - color = HermesColors.textPrimary, - ) - } + } - when (uiState.phase) { - RoundPhase.PREVIEW, RoundPhase.LOCKED -> SelectionPanel( - statusText = uiState.selectionStatusText, - options = uiState.selectionOptions, - selectedOptionId = uiState.selectedOutcomeId, - isLocked = uiState.selectionLocked, - confirmTitle = uiState.confirmTitle, - onSelect = { onSelectOutcome(it.id) }, - onConfirm = onConfirmSelection, - ) + when (uiState.phase) { + RoundPhase.PREVIEW, RoundPhase.LOCKED -> SelectionPanel( + statusText = uiState.selectionStatusText, + options = uiState.selectionOptions, + selectedOptionId = uiState.selectedOutcomeId, + isLocked = uiState.selectionLocked, + confirmTitle = uiState.confirmTitle, + onSelect = { onSelectOutcome(it.id) }, + onConfirm = onConfirmSelection, + ) - RoundPhase.REVEAL -> RevealPanel( - title = uiState.revealTitle, - subtitle = uiState.revealSubtitle, - statusText = uiState.revealStatusText, - selectionLabel = uiState.revealSelectionLabel, - selectionValue = uiState.selectedOutcomeTitle, - continueTitle = uiState.revealContinueTitle, - onContinue = onRevealComplete, - ) + RoundPhase.REVEAL -> RevealPanel( + title = uiState.revealTitle, + subtitle = uiState.revealSubtitle, + statusText = uiState.revealStatusText, + selectionLabel = uiState.revealSelectionLabel, + selectionValue = uiState.selectedOutcomeTitle, + continueTitle = uiState.revealContinueTitle, + onContinue = onRevealComplete, + ) - RoundPhase.RESULT -> ResultPanel( - title = uiState.resultTitle, - subtitle = uiState.resultSubtitle, - selectionLabel = uiState.resultSelectionLabel, - selectionValue = uiState.selectedOutcomeTitle, - outcomeLabel = uiState.resultOutcomeLabel, - outcomeValue = uiState.winningOutcomeTitle, - didWin = uiState.didWin, - winLabel = uiState.resultWinLabel, - loseLabel = uiState.resultLoseLabel, - nextRoundTitle = uiState.resultNextRoundTitle, - onNextRound = onNextRound, - ) + RoundPhase.RESULT -> ResultPanel( + title = uiState.resultTitle, + subtitle = uiState.resultSubtitle, + selectionLabel = uiState.resultSelectionLabel, + selectionValue = uiState.selectedOutcomeTitle, + outcomeLabel = uiState.resultOutcomeLabel, + outcomeValue = uiState.winningOutcomeTitle, + didWin = uiState.didWin, + winLabel = uiState.resultWinLabel, + loseLabel = uiState.resultLoseLabel, + nextRoundTitle = uiState.resultNextRoundTitle, + onNextRound = onNextRound, + ) + } + } } } } } + +@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, + ) + } +} diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/feature/round/RoundViewModel.kt b/mobile/android-app/app/src/main/java/com/hermes/study/feature/round/RoundViewModel.kt index c091ff5..3ccd2b5 100644 --- a/mobile/android-app/app/src/main/java/com/hermes/study/feature/round/RoundViewModel.kt +++ b/mobile/android-app/app/src/main/java/com/hermes/study/feature/round/RoundViewModel.kt @@ -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(null) + private val actionMessageFlow = MutableStateFlow(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 = 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 = combine(uiInputsFlow, nowFlow) { inputs, now -> + buildUiState(inputs, now) }.stateIn( scope = viewModelScope, started = SharingStarted.Eagerly, initialValue = buildUiState( - round = roundFlow.value, - localeCode = localeFlow.value, - phase = phaseFlow.value, - selectedOutcomeId = selectedOutcomeIdFlow.value, - now = Clock.System.now(), + RoundUiInputs( + round = roundFlow.value, + localeCode = localeFlow.value, + phase = phaseFlow.value, + selectedOutcomeId = selectedOutcomeIdFlow.value, + 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,37 +186,72 @@ class RoundViewModel( return } - analyticsTracker.track( - "selection_submitted", - attributes = baseSelectionAttributes(selectedOutcomeId), - ) - analyticsTracker.track( - "selection_accepted", - attributes = baseSelectionAttributes(selectedOutcomeId), - ) - analyticsTracker.track( - "market_locked", - attributes = baseSelectionAttributes(selectedOutcomeId) + ("lock_reason" to "manual_selection"), - ) + val session = repository.currentSession.value + if (session == null) { + actionMessageFlow.value = localized(localeFlow.value, R.string.errors_session_expired) + return + } - phaseFlow.value = RoundPhase.LOCKED - playerCoordinator.pause() + isSubmittingSelectionFlow.value = true + actionMessageFlow.value = null - transitionJob?.cancel() - transitionJob = viewModelScope.launch { - delay(750) - if (phaseFlow.value == RoundPhase.LOCKED) { - phaseFlow.value = RoundPhase.REVEAL - analyticsTracker.track( - "reveal_started", - attributes = roundAnalyticsAttributes(round) + baseSelectionAttributes(selectedOutcomeId), + 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), + ) + analyticsTracker.track( + "selection_accepted", + attributes = baseSelectionAttributes(selectedOutcomeId), + ) + analyticsTracker.track( + "market_locked", + attributes = baseSelectionAttributes(selectedOutcomeId) + ("lock_reason" to "manual_selection"), + ) + + phaseFlow.value = RoundPhase.LOCKED + playerCoordinator.pause() + + transitionJob?.cancel() + transitionJob = viewModelScope.launch { + delay(750) + if (phaseFlow.value == RoundPhase.LOCKED) { + phaseFlow.value = RoundPhase.REVEAL + analyticsTracker.track( + "reveal_started", + attributes = roundAnalyticsAttributes(round) + baseSelectionAttributes(selectedOutcomeId), + ) + } + } + } 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 { diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/feature/session/SessionView.kt b/mobile/android-app/app/src/main/java/com/hermes/study/feature/session/SessionView.kt index 7fb48fb..80f1158 100644 --- a/mobile/android-app/app/src/main/java/com/hermes/study/feature/session/SessionView.kt +++ b/mobile/android-app/app/src/main/java/com/hermes/study/feature/session/SessionView.kt @@ -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), ) - Text( - text = localizationStore.string(localeCode, R.string.session_note), - style = MaterialTheme.typography.bodyMedium, - color = HermesColors.textSecondary, - ) + 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(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) + } +} diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/feature/session/SessionViewModel.kt b/mobile/android-app/app/src/main/java/com/hermes/study/feature/session/SessionViewModel.kt new file mode 100644 index 0000000..01d5ee3 --- /dev/null +++ b/mobile/android-app/app/src/main/java/com/hermes/study/feature/session/SessionViewModel.kt @@ -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 = 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, + ) + } +} diff --git a/mobile/android-app/app/src/main/res/values-sv/strings.xml b/mobile/android-app/app/src/main/res/values-sv/strings.xml index cc3ae53..9fa4cc6 100644 --- a/mobile/android-app/app/src/main/res/values-sv/strings.xml +++ b/mobile/android-app/app/src/main/res/values-sv/strings.xml @@ -54,4 +54,12 @@ Session Sessionssynk och livscykelkontroller. Sessionsstatus visas här när backend-sessionen har startat. + Startar session + Session aktiv + Sessionen är otillgänglig + Sessions-ID + Användar-ID + Språk + Startad + Variant diff --git a/mobile/android-app/app/src/main/res/values/strings.xml b/mobile/android-app/app/src/main/res/values/strings.xml index 08007fa..26c3e2a 100644 --- a/mobile/android-app/app/src/main/res/values/strings.xml +++ b/mobile/android-app/app/src/main/res/values/strings.xml @@ -54,4 +54,12 @@ Session Session sync and lifecycle controls. Session state will appear here once the backend session is started. + Starting session + Session active + Session unavailable + Session ID + User ID + Locale + Started + Variant