scaffolding hermes flow and audit logging

This commit is contained in:
2026-04-09 18:54:10 +02:00
parent e401b6dbab
commit cf5316a2c1
59 changed files with 1830 additions and 593 deletions
+2 -2
View File
@@ -6,11 +6,11 @@ plugins {
}
android {
namespace = "com.hermes.study"
namespace = "com.hermes.app"
compileSdk = 35
defaultConfig {
applicationId = "com.hermes.study"
applicationId = "com.hermes.app"
minSdk = 26
targetSdk = 35
versionCode = 1
@@ -1,4 +1,4 @@
package com.hermes.study
package com.hermes.app
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -21,21 +21,22 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.foundation.layout.width
import com.hermes.study.R
import com.hermes.study.core.designsystem.HermesPalette
import com.hermes.study.core.designsystem.HermesSecondaryButton
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
import com.hermes.app.R
import com.hermes.app.core.designsystem.HermesPalette
import com.hermes.app.core.designsystem.HermesSecondaryButton
import com.hermes.app.core.designsystem.HermesTheme
import com.hermes.app.core.localization.HermesLocalizationStore
import com.hermes.app.feature.feed.FeedScreen
import com.hermes.app.feature.feed.FeedViewModel
import com.hermes.app.feature.session.SessionView
import com.hermes.app.feature.session.SessionViewModel
import com.hermes.app.feature.settings.SettingsView
import com.hermes.app.feature.round.RoundScreen
import com.hermes.app.feature.round.RoundViewModel
import androidx.compose.material3.ExperimentalMaterial3Api
@Composable
fun HermesStudyApp(container: HermesAppContainer) {
fun HermesApp(container: HermesAppContainer) {
val feedViewModel: FeedViewModel = viewModel(factory = container.feedViewModelFactory())
val roundViewModel: RoundViewModel = viewModel(factory = container.roundViewModelFactory())
val sessionViewModel: SessionViewModel = viewModel(factory = container.sessionViewModelFactory())
@@ -53,6 +54,7 @@ fun HermesStudyApp(container: HermesAppContainer) {
HermesAppBar(
title = container.localizationStore.string(localeCode, R.string.app_name),
localeCode = localeCode,
localizationStore = container.localizationStore,
onLocaleSelected = container.localizationStore::setLocale,
)
},
@@ -105,14 +107,23 @@ fun HermesStudyApp(container: HermesAppContainer) {
private fun HermesAppBar(
title: String,
localeCode: String,
localizationStore: HermesLocalizationStore,
onLocaleSelected: (String) -> Unit,
) {
androidx.compose.material3.CenterAlignedTopAppBar(
title = { Text(text = title) },
actions = {
HermesSecondaryButton(text = "EN", selected = localeCode == "en", onClick = { onLocaleSelected("en") })
HermesSecondaryButton(
text = localizationStore.localeName("en", localeCode),
selected = localeCode == "en",
onClick = { onLocaleSelected("en") },
)
androidx.compose.foundation.layout.Spacer(modifier = Modifier.width(8.dp))
HermesSecondaryButton(text = "SV", selected = localeCode == "sv", onClick = { onLocaleSelected("sv") })
HermesSecondaryButton(
text = localizationStore.localeName("sv", localeCode),
selected = localeCode == "sv",
onClick = { onLocaleSelected("sv") },
)
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = HermesPalette.colors.background
@@ -0,0 +1,101 @@
package com.hermes.app
import android.content.Context
import android.os.Build
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.hermes.app.core.analytics.HermesAnalyticsTracker
import com.hermes.app.core.localization.HermesLocalizationStore
import com.hermes.app.core.media.HermesPlayerCoordinator
import com.hermes.app.core.network.HermesApiClient
import com.hermes.app.data.HermesRepository
import com.hermes.app.domain.HermesSessionStartRequest
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.HttpUrl.Companion.toHttpUrl
class HermesAppContainer(context: Context) {
val localizationStore = HermesLocalizationStore(context.applicationContext)
val analyticsTracker = HermesAnalyticsTracker()
val apiClient = HermesApiClient(BuildConfig.API_BASE_URL.toHttpUrl())
val repository = HermesRepository(apiClient)
val playerCoordinator = HermesPlayerCoordinator(context.applicationContext)
private val analyticsScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val analyticsFlushMutex = Mutex()
private val analyticsSessionJob: Job
private val analyticsTickerJob: Job
init {
analyticsSessionJob = analyticsScope.launch {
repository.currentSession.filterNotNull().collect {
flushAnalytics()
}
}
analyticsTickerJob = analyticsScope.launch {
while (isActive) {
delay(5_000)
flushAnalytics()
}
}
}
fun feedViewModelFactory(): ViewModelProvider.Factory = HermesViewModelFactory {
com.hermes.app.feature.feed.FeedViewModel(repository, localizationStore, analyticsTracker)
}
fun roundViewModelFactory(): ViewModelProvider.Factory = HermesViewModelFactory {
com.hermes.app.feature.round.RoundViewModel(repository, localizationStore, analyticsTracker, playerCoordinator)
}
fun sessionViewModelFactory(): ViewModelProvider.Factory = HermesViewModelFactory {
com.hermes.app.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,
)
}
suspend fun flushAnalytics() {
analyticsFlushMutex.withLock {
if (repository.currentSession.value == null) {
return
}
val events = analyticsTracker.pendingEventsSnapshot()
if (events.isEmpty()) {
return
}
runCatching {
repository.submitAnalyticsBatch(analyticsTracker.toBatchRequest(events))
}.onSuccess {
analyticsTracker.markDelivered(events.map { it.id }.toSet())
}
}
}
}
class HermesViewModelFactory<T : ViewModel>(private val creator: () -> T) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = creator() as T
}
@@ -1,4 +1,4 @@
package com.hermes.study
package com.hermes.app
import android.app.Application
@@ -1,9 +1,9 @@
package com.hermes.study
package com.hermes.app
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import com.hermes.study.core.designsystem.HermesStudyTheme
import com.hermes.app.core.designsystem.HermesTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -12,8 +12,8 @@ class MainActivity : ComponentActivity() {
val app = application as HermesApplication
setContent {
HermesStudyTheme {
HermesStudyApp(app.container)
HermesTheme {
HermesApp(app.container)
}
}
}
@@ -0,0 +1,58 @@
package com.hermes.app.core.analytics
import android.util.Log
import com.hermes.app.domain.HermesAnalyticsAttributeInput
import com.hermes.app.domain.HermesAnalyticsBatchRequest
import com.hermes.app.domain.HermesAnalyticsEventInput
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import java.util.UUID
data class HermesTrackedEvent(
val id: UUID,
val name: String,
val attributes: Map<String, String>,
val timestamp: Instant,
)
class HermesAnalyticsTracker {
private val eventsFlow = MutableSharedFlow<HermesTrackedEvent>(replay = 64, extraBufferCapacity = 64)
private val pendingEvents = mutableListOf<HermesTrackedEvent>()
val events: SharedFlow<HermesTrackedEvent> = eventsFlow.asSharedFlow()
@Synchronized
fun track(name: String, attributes: Map<String, String> = emptyMap()) {
val event = HermesTrackedEvent(UUID.randomUUID(), name, attributes, Clock.System.now())
pendingEvents += event
eventsFlow.tryEmit(event)
Log.d("HermesAnalytics", "${event.name} ${event.attributes}")
}
@Synchronized
fun pendingEventsSnapshot(): List<HermesTrackedEvent> {
return pendingEvents.toList()
}
@Synchronized
fun markDelivered(deliveredIds: Set<UUID>) {
pendingEvents.removeAll { it.id in deliveredIds }
}
fun toBatchRequest(events: List<HermesTrackedEvent>): HermesAnalyticsBatchRequest {
return HermesAnalyticsBatchRequest(
events = events.map { event ->
HermesAnalyticsEventInput(
eventName = event.name,
occurredAt = event.timestamp,
attributes = event.attributes.map { (key, value) ->
HermesAnalyticsAttributeInput(key = key, value = value)
},
)
},
)
}
}
@@ -1,4 +1,4 @@
package com.hermes.study.core.designsystem
package com.hermes.app.core.designsystem
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -57,7 +57,7 @@ private val HermesShapes = androidx.compose.material3.Shapes(
)
@Composable
fun HermesStudyTheme(content: @Composable () -> Unit) {
fun HermesTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = HermesColorScheme,
typography = MaterialTheme.typography,
@@ -1,8 +1,8 @@
package com.hermes.study.core.errors
package com.hermes.app.core.errors
import com.hermes.study.R
import com.hermes.study.core.localization.HermesLocalizationStore
import com.hermes.study.core.network.HermesApiException
import com.hermes.app.R
import com.hermes.app.core.localization.HermesLocalizationStore
import com.hermes.app.core.network.HermesApiException
import java.io.IOException
import kotlinx.coroutines.CancellationException
@@ -1,4 +1,4 @@
package com.hermes.study.core.gestures
package com.hermes.app.core.gestures
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.ui.Modifier
@@ -1,4 +1,4 @@
package com.hermes.study.core.haptics
package com.hermes.app.core.haptics
import android.view.HapticFeedbackConstants
import android.view.View
@@ -1,7 +1,8 @@
package com.hermes.study.core.localization
package com.hermes.app.core.localization
import android.content.Context
import android.content.res.Configuration
import com.hermes.app.R
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -21,6 +22,15 @@ class HermesLocalizationStore(private val appContext: Context) {
fun string(localeCode: String, resId: Int): String = localizedContext(normalize(localeCode)).getString(resId)
fun localeName(targetLocaleCode: String, displayLocaleCode: String = localeCode.value): String {
val resId = when (normalize(targetLocaleCode)) {
"sv" -> R.string.locale_swedish
else -> R.string.locale_english
}
return string(displayLocaleCode, resId)
}
private fun localizedContext(localeCode: String): Context {
val configuration = Configuration(appContext.resources.configuration)
configuration.setLocale(Locale.forLanguageTag(localeCode))
@@ -1,4 +1,4 @@
package com.hermes.study.core.media
package com.hermes.app.core.media
import android.content.Context
import android.net.Uri
@@ -1,4 +1,4 @@
package com.hermes.study.core.media
package com.hermes.app.core.media
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -7,7 +7,7 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.ui.PlayerView
@Composable
fun StudyVideoPlayerView(
fun HermesVideoPlayerView(
coordinator: HermesPlayerCoordinator,
modifier: Modifier = Modifier,
) {
@@ -1,16 +1,18 @@
package com.hermes.study.core.network
package com.hermes.app.core.network
import com.hermes.study.domain.HermesAnalyticsBatchRequest
import com.hermes.study.domain.HermesBetIntentRequest
import com.hermes.study.domain.HermesBetIntentResponse
import com.hermes.study.domain.HermesEvent
import com.hermes.study.domain.HermesExperimentConfig
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.app.domain.HermesAnalyticsBatchRequest
import com.hermes.app.domain.HermesBetIntentRequest
import com.hermes.app.domain.HermesBetIntentResponse
import com.hermes.app.domain.HermesHealthResponse
import com.hermes.app.domain.HermesEvent
import com.hermes.app.domain.HermesEventManifest
import com.hermes.app.domain.HermesExperimentConfig
import com.hermes.app.domain.HermesLocalizationBundle
import com.hermes.app.domain.HermesMarket
import com.hermes.app.domain.HermesOddsVersion
import com.hermes.app.domain.HermesSessionResponse
import com.hermes.app.domain.HermesSessionStartRequest
import com.hermes.app.domain.HermesSettlement
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
@@ -37,13 +39,15 @@ class HermesApiClient(
suspend fun startSession(request: HermesSessionStartRequest): HermesSessionResponse = post("api/v1/session/start", request)
suspend fun health(): HermesHealthResponse = get("health")
suspend fun endSession(): HermesSessionResponse = post("api/v1/session/end")
suspend fun currentSession(): HermesSessionResponse = get("api/v1/session/me")
suspend fun nextEvent(): HermesEvent = get("api/v1/feed/next")
suspend fun eventManifest(eventId: String): com.hermes.study.domain.HermesEventManifest = get("api/v1/events/$eventId/manifest")
suspend fun eventManifest(eventId: String): HermesEventManifest = get("api/v1/events/$eventId/manifest")
suspend fun markets(eventId: String): List<HermesMarket> = get("api/v1/events/$eventId/markets")
@@ -1,3 +1,3 @@
package com.hermes.study.core.network
package com.hermes.app.core.network
// Placeholder for future API-specific mapping helpers.
@@ -1,17 +1,19 @@
package com.hermes.study.data
package com.hermes.app.data
import com.hermes.study.core.network.HermesApiClient
import com.hermes.study.domain.HermesAnalyticsBatchRequest
import com.hermes.study.domain.HermesBetIntentRequest
import com.hermes.study.domain.HermesBetIntentResponse
import com.hermes.study.domain.HermesExperimentConfig
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 com.hermes.app.core.network.HermesApiClient
import com.hermes.app.domain.HermesAnalyticsBatchRequest
import com.hermes.app.domain.HermesBetIntentRequest
import com.hermes.app.domain.HermesBetIntentResponse
import com.hermes.app.domain.HermesExperimentConfig
import com.hermes.app.domain.HermesLocalizationBundle
import com.hermes.app.domain.HermesMarket
import com.hermes.app.domain.HermesOddsVersion
import com.hermes.app.domain.HermesRound
import com.hermes.app.domain.HermesSessionResponse
import com.hermes.app.domain.HermesSessionStartRequest
import com.hermes.app.domain.HermesSettlement
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -22,18 +24,20 @@ import kotlinx.coroutines.sync.withLock
class HermesRepository(private val apiClient: HermesApiClient) {
private val sessionMutex = Mutex()
private val _currentSession = MutableStateFlow<HermesSessionResponse?>(null)
private val _currentRound = MutableStateFlow<StudyRound?>(null)
private val _currentRound = MutableStateFlow<HermesRound?>(null)
private val _isLoading = MutableStateFlow(true)
private val _errorCause = MutableStateFlow<Throwable?>(null)
private val _serverClockOffsetMs = MutableStateFlow<Long?>(null)
val currentSession: StateFlow<HermesSessionResponse?> = _currentSession.asStateFlow()
val currentRound: StateFlow<StudyRound?> = _currentRound.asStateFlow()
val currentRound: StateFlow<HermesRound?> = _currentRound.asStateFlow()
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
val errorCause: StateFlow<Throwable?> = _errorCause.asStateFlow()
val serverClockOffsetMs: StateFlow<Long?> = _serverClockOffsetMs.asStateFlow()
fun observeRound(): Flow<StudyRound?> = currentRound
fun observeRound(): Flow<HermesRound?> = currentRound
fun observeFeed(): Flow<StudyRound?> = currentRound
fun observeFeed(): Flow<HermesRound?> = currentRound
suspend fun bootstrap(request: HermesSessionStartRequest): HermesSessionResponse {
return sessionMutex.withLock {
@@ -41,6 +45,11 @@ class HermesRepository(private val apiClient: HermesApiClient) {
_errorCause.value = null
try {
try {
syncClock()
} catch (_: Throwable) {
// Clock sync is best-effort.
}
val session = _currentSession.value ?: startSession(request)
if (_currentRound.value == null) {
_currentRound.value = loadRoundFromNetwork()
@@ -56,12 +65,17 @@ class HermesRepository(private val apiClient: HermesApiClient) {
}
}
suspend fun refreshRoundFromNetwork(): StudyRound {
suspend fun refreshRoundFromNetwork(): HermesRound {
return sessionMutex.withLock {
_isLoading.value = true
_errorCause.value = null
try {
try {
syncClock()
} catch (_: Throwable) {
// Clock sync is best-effort.
}
val round = loadRoundFromNetwork()
_currentRound.value = round
round
@@ -110,7 +124,17 @@ class HermesRepository(private val apiClient: HermesApiClient) {
apiClient.submitAnalyticsBatch(request)
}
private suspend fun loadRoundFromNetwork(): StudyRound {
fun serverNow(): Instant {
val offsetMs = _serverClockOffsetMs.value ?: 0L
return Instant.fromEpochMilliseconds(Clock.System.now().toEpochMilliseconds() + offsetMs)
}
private suspend fun syncClock() {
val health = apiClient.health()
_serverClockOffsetMs.value = health.serverTime.toEpochMilliseconds() - Clock.System.now().toEpochMilliseconds()
}
private suspend fun loadRoundFromNetwork(): HermesRound {
val event = apiClient.nextEvent()
val manifest = apiClient.eventManifest(event.id)
val media = manifest.media.firstOrNull { it.mediaType == "hls_main" } ?: manifest.media.firstOrNull()
@@ -120,7 +144,7 @@ class HermesRepository(private val apiClient: HermesApiClient) {
val oddsVersion = apiClient.currentOdds(market.id)
val settlement = apiClient.settlement(event.id)
return StudyRound(
return HermesRound(
event = event,
media = media,
market = market,
@@ -1,4 +1,4 @@
package com.hermes.study.domain
package com.hermes.app.domain
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
@@ -28,6 +28,18 @@ data class HermesSessionResponse(
val devicePlatform: String,
)
@Serializable
data class HermesHealthResponse(
val status: String,
val serviceName: String,
val environment: String,
val version: String,
val uptimeMs: Long,
val serverTime: Instant,
val databaseReady: Boolean,
val redisReady: Boolean,
)
@Serializable
data class HermesEvent(
val id: String,
@@ -163,7 +175,7 @@ data class HermesLocalizationBundle(
val values: Map<String, String>,
)
data class StudyRound(
data class HermesRound(
val event: HermesEvent,
val media: HermesEventMedia,
val market: HermesMarket,
@@ -1,4 +1,4 @@
package com.hermes.study.feature.feed
package com.hermes.app.feature.feed
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -17,11 +17,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.unit.dp
import com.hermes.study.core.designsystem.HermesCard
import com.hermes.study.core.designsystem.HermesColors
import com.hermes.study.core.designsystem.HermesMetricChip
import com.hermes.study.core.designsystem.HermesPrimaryButton
import com.hermes.study.core.designsystem.HermesSectionHeader
import com.hermes.app.core.designsystem.HermesCard
import com.hermes.app.core.designsystem.HermesColors
import com.hermes.app.core.designsystem.HermesMetricChip
import com.hermes.app.core.designsystem.HermesPrimaryButton
import com.hermes.app.core.designsystem.HermesSectionHeader
@Composable
fun FeedScreen(
@@ -1,13 +1,13 @@
package com.hermes.study.feature.feed
package com.hermes.app.feature.feed
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
import com.hermes.app.R
import com.hermes.app.core.analytics.HermesAnalyticsTracker
import com.hermes.app.core.errors.mapUserFacingError
import com.hermes.app.core.localization.HermesLocalizationStore
import com.hermes.app.data.HermesRepository
import com.hermes.app.domain.HermesRound
import java.util.Locale
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.SharingStarted
@@ -15,7 +15,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
data class FeedUiState(
@@ -35,7 +34,7 @@ data class FeedUiState(
)
class FeedViewModel(
repository: HermesRepository,
private val repository: HermesRepository,
private val localizationStore: HermesLocalizationStore,
private val analyticsTracker: HermesAnalyticsTracker,
) : ViewModel() {
@@ -43,7 +42,7 @@ class FeedViewModel(
private val localeFlow = localizationStore.localeCode
private val nowFlow = flow {
while (true) {
emit(Clock.System.now())
emit(repository.serverNow())
delay(1_000)
}
}
@@ -58,7 +57,7 @@ class FeedViewModel(
localeCode = localeFlow.value,
isLoading = repository.isLoading.value,
errorCause = repository.errorCause.value,
now = Clock.System.now(),
now = repository.serverNow(),
),
)
@@ -79,7 +78,7 @@ class FeedViewModel(
)
}
private fun buildUiState(round: StudyRound?, localeCode: String, isLoading: Boolean, errorCause: Throwable?, now: Instant): FeedUiState {
private fun buildUiState(round: HermesRound?, localeCode: String, isLoading: Boolean, errorCause: Throwable?, now: Instant): FeedUiState {
val bannerMessage = mapUserFacingError(localizationStore, localeCode, errorCause)
val hasRound = round != null
val showLoading = isLoading && !hasRound
@@ -110,7 +109,7 @@ class FeedViewModel(
return localizationStore.string(localeCode, resId)
}
private fun localizedEventTitle(round: StudyRound, localeCode: String): String {
private fun localizedEventTitle(round: HermesRound, localeCode: String): String {
return if (localeCode == "sv") round.event.titleSv else round.event.titleEn
}
@@ -122,7 +121,7 @@ class FeedViewModel(
return String.format(Locale.US, "%02d:%02d", minutes, seconds)
}
private fun formatOdds(round: StudyRound): String {
private fun formatOdds(round: HermesRound): String {
val oddsByOutcomeId = round.oddsVersion.odds.associateBy { it.outcomeId }
return round.market.outcomes
.sortedBy { it.sortOrder }
@@ -1,4 +1,4 @@
package com.hermes.study.feature.result
package com.hermes.app.feature.result
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -14,10 +14,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.hermes.study.core.designsystem.HermesColors
import com.hermes.study.core.designsystem.HermesMetricChip
import com.hermes.study.core.designsystem.HermesPrimaryButton
import com.hermes.study.core.designsystem.HermesSectionHeader
import com.hermes.app.core.designsystem.HermesColors
import com.hermes.app.core.designsystem.HermesMetricChip
import com.hermes.app.core.designsystem.HermesPrimaryButton
import com.hermes.app.core.designsystem.HermesSectionHeader
@Composable
fun ResultPanel(
@@ -1,4 +1,4 @@
package com.hermes.study.feature.reveal
package com.hermes.app.feature.reveal
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -6,12 +6,12 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.hermes.study.core.designsystem.HermesMetricChip
import com.hermes.study.core.designsystem.HermesPrimaryButton
import com.hermes.study.core.designsystem.HermesSectionHeader
import com.hermes.app.core.designsystem.HermesMetricChip
import com.hermes.app.core.designsystem.HermesPrimaryButton
import com.hermes.app.core.designsystem.HermesSectionHeader
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import com.hermes.study.core.designsystem.HermesColors
import com.hermes.app.core.designsystem.HermesColors
@Composable
fun RevealPanel(
@@ -1,4 +1,4 @@
package com.hermes.study.feature.round
package com.hermes.app.feature.round
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -18,16 +18,16 @@ 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
import com.hermes.study.core.designsystem.HermesCountdownBadge
import com.hermes.study.core.designsystem.HermesCard
import com.hermes.study.core.designsystem.HermesMetricChip
import com.hermes.study.core.media.HermesPlayerCoordinator
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
import com.hermes.app.core.designsystem.HermesColors
import com.hermes.app.core.designsystem.HermesCountdownBadge
import com.hermes.app.core.designsystem.HermesCard
import com.hermes.app.core.designsystem.HermesMetricChip
import com.hermes.app.core.media.HermesPlayerCoordinator
import com.hermes.app.core.media.HermesVideoPlayerView
import com.hermes.app.feature.reveal.RevealPanel
import com.hermes.app.feature.result.ResultPanel
import com.hermes.app.feature.selection.SelectionPanel
import com.hermes.app.core.designsystem.HermesPrimaryButton
@Composable
fun RoundScreen(
@@ -42,7 +42,7 @@ fun RoundScreen(
) {
HermesCard(modifier = modifier, elevated = true) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
com.hermes.study.core.designsystem.HermesSectionHeader(title = uiState.title, subtitle = uiState.subtitle)
com.hermes.app.core.designsystem.HermesSectionHeader(title = uiState.title, subtitle = uiState.subtitle)
when {
uiState.isLoading && !uiState.hasRound -> RoundLoadingState(
@@ -67,7 +67,7 @@ fun RoundScreen(
.height(220.dp),
) {
if (uiState.hasRound) {
StudyVideoPlayerView(
HermesVideoPlayerView(
coordinator = playerCoordinator,
modifier = Modifier.fillMaxSize(),
)
@@ -1,17 +1,17 @@
package com.hermes.study.feature.round
package com.hermes.app.feature.round
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 com.hermes.app.R
import com.hermes.app.core.analytics.HermesAnalyticsTracker
import com.hermes.app.core.errors.mapUserFacingError
import com.hermes.app.core.localization.HermesLocalizationStore
import com.hermes.app.core.media.HermesPlayerCoordinator
import com.hermes.app.data.HermesRepository
import com.hermes.app.domain.HermesOutcome
import com.hermes.app.domain.HermesBetIntentRequest
import com.hermes.app.domain.HermesRound
import com.hermes.app.feature.selection.SelectionOptionUi
import java.util.Locale
import java.util.UUID
import kotlinx.coroutines.Job
@@ -74,7 +74,7 @@ data class RoundUiState(
)
private data class RoundUiInputs(
val round: StudyRound?,
val round: HermesRound?,
val localeCode: String,
val phase: RoundPhase,
val selectedOutcomeId: String?,
@@ -98,7 +98,7 @@ class RoundViewModel(
private val isSubmittingSelectionFlow = MutableStateFlow(false)
private val nowFlow = flow {
while (true) {
emit(Clock.System.now())
emit(repository.serverNow())
delay(1_000)
}
}
@@ -128,7 +128,7 @@ class RoundViewModel(
}.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = buildUiState(
initialValue = buildUiState(
RoundUiInputs(
round = roundFlow.value,
localeCode = localeFlow.value,
@@ -139,7 +139,7 @@ class RoundViewModel(
actionMessage = actionMessageFlow.value,
isSubmitting = isSubmittingSelectionFlow.value,
),
Clock.System.now(),
repository.serverNow(),
),
)
@@ -160,7 +160,7 @@ class RoundViewModel(
return
}
if (isTimerLocked(round, Clock.System.now())) {
if (isTimerLocked(round, repository.serverNow())) {
return
}
@@ -182,7 +182,7 @@ class RoundViewModel(
return
}
if (isTimerLocked(round, Clock.System.now())) {
if (isTimerLocked(round, repository.serverNow())) {
return
}
@@ -280,7 +280,7 @@ class RoundViewModel(
}
}
private fun startPreview(round: StudyRound) {
private fun startPreview(round: HermesRound) {
transitionJob?.cancel()
phaseFlow.value = RoundPhase.PREVIEW
selectedOutcomeIdFlow.value = null
@@ -368,7 +368,7 @@ class RoundViewModel(
return localizationStore.string(localeCode, resId)
}
private fun selectionOptions(round: StudyRound, localeCode: String): List<SelectionOptionUi> {
private fun selectionOptions(round: HermesRound, localeCode: String): List<SelectionOptionUi> {
val oddsByOutcomeId = round.oddsVersion.odds.associateBy { it.outcomeId }
return round.market.outcomes
@@ -391,7 +391,7 @@ class RoundViewModel(
}
}
private fun resolveSelectedOutcomeTitle(round: StudyRound, localeCode: String, selectedOutcomeId: String?): String {
private fun resolveSelectedOutcomeTitle(round: HermesRound, localeCode: String, selectedOutcomeId: String?): String {
if (selectedOutcomeId == null) {
return localized(localeCode, R.string.round_selection_prompt)
}
@@ -400,12 +400,12 @@ class RoundViewModel(
?: localized(localeCode, R.string.round_selection_prompt)
}
private fun resolveWinningOutcomeTitle(round: StudyRound, localeCode: String): String {
private fun resolveWinningOutcomeTitle(round: HermesRound, localeCode: String): String {
return round.market.outcomes.firstOrNull { it.id == round.settlement.winningOutcomeId }?.let { outcomeTitle(localeCode, it) }
?: localized(localeCode, R.string.round_selection_prompt)
}
private fun oddsSummary(round: StudyRound): String {
private fun oddsSummary(round: HermesRound): String {
val oddsByOutcomeId = round.oddsVersion.odds.associateBy { it.outcomeId }
return round.market.outcomes
.sortedBy { it.sortOrder }
@@ -436,7 +436,7 @@ class RoundViewModel(
}
}
private fun isTimerLocked(round: StudyRound?, now: Instant): Boolean {
private fun isTimerLocked(round: HermesRound?, now: Instant): Boolean {
return round != null && now.toEpochMilliseconds() >= round.event.lockAt.toEpochMilliseconds()
}
@@ -444,7 +444,7 @@ class RoundViewModel(
return mapOf("screen_name" to "round", "outcome_id" to outcomeId)
}
private fun roundAnalyticsAttributes(round: StudyRound): Map<String, String> {
private fun roundAnalyticsAttributes(round: HermesRound): Map<String, String> {
return mapOf(
"screen_name" to "round",
"event_id" to round.event.id,
@@ -1,4 +1,4 @@
package com.hermes.study.feature.selection
package com.hermes.app.feature.selection
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -23,8 +23,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.hermes.study.core.designsystem.HermesColors
import com.hermes.study.core.designsystem.HermesPrimaryButton
import com.hermes.app.core.designsystem.HermesColors
import com.hermes.app.core.designsystem.HermesPrimaryButton
data class SelectionOptionUi(
val id: String,
@@ -1,4 +1,4 @@
package com.hermes.study.feature.session
package com.hermes.app.feature.session
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -7,14 +7,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.hermes.study.R
import com.hermes.study.core.designsystem.HermesCard
import com.hermes.app.R
import com.hermes.app.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
import com.hermes.app.core.designsystem.HermesColors
import com.hermes.app.core.designsystem.HermesPrimaryButton
import com.hermes.app.core.designsystem.HermesSectionHeader
import com.hermes.app.core.localization.HermesLocalizationStore
@Composable
fun SessionView(
@@ -64,7 +64,7 @@ fun SessionView(
)
SessionRow(
label = localizationStore.string(uiState.localeCode, R.string.session_locale_label),
value = uiState.localeCode.uppercase(),
value = localizationStore.localeName(uiState.localeCode),
)
SessionRow(
label = localizationStore.string(uiState.localeCode, R.string.session_started_label),
@@ -1,13 +1,13 @@
package com.hermes.study.feature.session
package com.hermes.app.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 com.hermes.app.R
import com.hermes.app.core.analytics.HermesAnalyticsTracker
import com.hermes.app.core.errors.mapUserFacingError
import com.hermes.app.core.localization.HermesLocalizationStore
import com.hermes.app.data.HermesRepository
import com.hermes.app.domain.HermesSessionStartRequest
import java.util.Locale
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
@@ -1,4 +1,4 @@
package com.hermes.study.feature.settings
package com.hermes.app.feature.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -8,14 +8,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.hermes.study.R
import com.hermes.study.core.designsystem.HermesCard
import com.hermes.study.core.designsystem.HermesColors
import com.hermes.app.R
import com.hermes.app.core.designsystem.HermesCard
import com.hermes.app.core.designsystem.HermesColors
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import com.hermes.study.core.localization.HermesLocalizationStore
import com.hermes.study.core.designsystem.HermesSectionHeader
import java.util.Locale
import com.hermes.app.core.localization.HermesLocalizationStore
import com.hermes.app.core.designsystem.HermesSectionHeader
@Composable
fun SettingsView(
@@ -32,7 +31,7 @@ fun SettingsView(
SettingRow(
label = localizationStore.string(localeCode, R.string.settings_language),
value = localeCode.uppercase(Locale.US),
value = localizationStore.localeName(localeCode),
)
SettingRow(
label = localizationStore.string(localeCode, R.string.settings_haptics),
@@ -1,53 +0,0 @@
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
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) {
val localizationStore = HermesLocalizationStore(context.applicationContext)
val analyticsTracker = HermesAnalyticsTracker()
val apiClient = HermesApiClient(BuildConfig.API_BASE_URL.toHttpUrl())
val repository = HermesRepository(apiClient)
val playerCoordinator = HermesPlayerCoordinator(context.applicationContext)
fun feedViewModelFactory(): ViewModelProvider.Factory = HermesViewModelFactory {
com.hermes.study.feature.feed.FeedViewModel(repository, localizationStore, analyticsTracker)
}
fun roundViewModelFactory(): ViewModelProvider.Factory = HermesViewModelFactory {
com.hermes.study.feature.round.RoundViewModel(repository, localizationStore, analyticsTracker, playerCoordinator)
}
fun sessionViewModelFactory(): ViewModelProvider.Factory = HermesViewModelFactory {
com.hermes.study.feature.session.SessionViewModel(
repository = repository,
localizationStore = localizationStore,
analyticsTracker = analyticsTracker,
sessionRequestFactory = ::buildSessionStartRequest,
)
}
private fun buildSessionStartRequest(localeCode: String): HermesSessionStartRequest {
return HermesSessionStartRequest(
localeCode = localeCode,
devicePlatform = "android",
deviceModel = Build.MODEL,
osVersion = Build.VERSION.RELEASE,
appVersion = BuildConfig.VERSION_NAME,
)
}
}
class HermesViewModelFactory<T : ViewModel>(private val creator: () -> T) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = creator() as T
}
@@ -1,26 +0,0 @@
package com.hermes.study.core.analytics
import android.util.Log
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
data class HermesTrackedEvent(
val name: String,
val attributes: Map<String, String>,
val timestamp: Instant,
)
class HermesAnalyticsTracker {
private val eventsFlow = MutableSharedFlow<HermesTrackedEvent>(extraBufferCapacity = 64)
val events: SharedFlow<HermesTrackedEvent> = eventsFlow.asSharedFlow()
fun track(name: String, attributes: Map<String, String> = emptyMap()) {
val event = HermesTrackedEvent(name, attributes, Clock.System.now())
eventsFlow.tryEmit(event)
Log.d("HermesAnalytics", "${event.name} ${event.attributes}")
}
}
@@ -1,124 +0,0 @@
package com.hermes.study.data
import com.hermes.study.domain.HermesAnalyticsAttributeInput
import com.hermes.study.domain.HermesAnalyticsBatchRequest
import com.hermes.study.domain.HermesAnalyticsEventInput
import com.hermes.study.domain.HermesBetIntentRequest
import com.hermes.study.domain.HermesBetIntentResponse
import com.hermes.study.domain.HermesEvent
import com.hermes.study.domain.HermesEventMedia
import com.hermes.study.domain.HermesExperimentConfig
import com.hermes.study.domain.HermesLocalizationBundle
import com.hermes.study.domain.HermesMarket
import com.hermes.study.domain.HermesOddsVersion
import com.hermes.study.domain.HermesOutcome
import com.hermes.study.domain.HermesOutcomeOdds
import com.hermes.study.domain.HermesSessionResponse
import com.hermes.study.domain.HermesSettlement
import com.hermes.study.domain.StudyRound
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
object SampleStudyData {
fun round(): StudyRound {
val now: Instant = Clock.System.now()
val lockAt = Instant.fromEpochMilliseconds(now.toEpochMilliseconds() + 47_000)
val settleAt = Instant.fromEpochMilliseconds(now.toEpochMilliseconds() + 92_000)
val event = HermesEvent(
id = "11111111-1111-1111-1111-111111111111",
sportType = "football",
sourceRef = "sample-event-001",
titleEn = "Late winner chance",
titleSv = "Möjlighet till segermål",
status = "prefetch_ready",
previewStartMs = 0,
previewEndMs = 45_000,
revealStartMs = 50_000,
revealEndMs = 90_000,
lockAt = lockAt,
settleAt = settleAt,
)
val media = HermesEventMedia(
id = "33333333-3333-3333-3333-333333333333",
eventId = event.id,
mediaType = "hls_main",
hlsMasterUrl = "https://cdn.example.com/hermes/sample-event/master.m3u8",
posterUrl = "https://cdn.example.com/hermes/sample-event/poster.jpg",
durationMs = 90_000,
previewStartMs = 0,
previewEndMs = 45_000,
revealStartMs = 50_000,
revealEndMs = 90_000,
)
val home = HermesOutcome(
id = "44444444-4444-4444-4444-444444444444",
marketId = "22222222-2222-2222-2222-222222222222",
outcomeCode = "home",
labelKey = "round.home",
sortOrder = 1,
)
val away = HermesOutcome(
id = "55555555-5555-5555-5555-555555555555",
marketId = "22222222-2222-2222-2222-222222222222",
outcomeCode = "away",
labelKey = "round.away",
sortOrder = 2,
)
val market = HermesMarket(
id = "22222222-2222-2222-2222-222222222222",
eventId = event.id,
questionKey = "market.sample.winner",
marketType = "winner",
status = "open",
lockAt = lockAt,
settlementRuleKey = "settle_on_match_winner",
outcomes = listOf(home, away),
)
val oddsVersion = HermesOddsVersion(
id = "66666666-6666-6666-6666-666666666666",
marketId = market.id,
versionNo = 1,
createdAt = now,
isCurrent = true,
odds = listOf(
HermesOutcomeOdds(
id = "77777777-7777-7777-7777-777777777777",
oddsVersionId = "66666666-6666-6666-6666-666666666666",
outcomeId = home.id,
decimalOdds = 1.85,
fractionalNum = 17,
fractionalDen = 20,
),
HermesOutcomeOdds(
id = "88888888-8888-8888-8888-888888888888",
oddsVersionId = "66666666-6666-6666-6666-666666666666",
outcomeId = away.id,
decimalOdds = 2.05,
fractionalNum = 21,
fractionalDen = 20,
),
),
)
val settlement = HermesSettlement(
id = "99999999-9999-9999-9999-999999999999",
marketId = market.id,
settledAt = settleAt,
winningOutcomeId = home.id,
)
return StudyRound(
event = event,
media = media,
market = market,
oddsVersion = oddsVersion,
settlement = settlement,
)
}
}
@@ -1,7 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Hermes</string>
<string name="app_subtitle">Native prototyp för studien</string>
<string name="app_subtitle">Native Hermes-prototyp</string>
<string name="locale_english">Engelska</string>
<string name="locale_swedish">Svenska</string>
<string name="common_continue">Fortsätt</string>
<string name="common_cancel">Avbryt</string>
<string name="common_close">Stäng</string>
@@ -11,7 +13,7 @@
<string name="errors_network">Nätverksfel. Kontrollera anslutningen.</string>
<string name="errors_playback">Videouppspelningen misslyckades.</string>
<string name="errors_session_expired">Sessionen har gått ut. Starta igen.</string>
<string name="onboarding_title">Studieintro</string>
<string name="onboarding_title">Välkommen till Hermes</string>
<string name="onboarding_subtitle">Titta på klippet, välj före låsning och se sedan avslöjandet.</string>
<string name="onboarding_consent_body">Den här prototypen är för forskning och använder inga riktiga pengar.</string>
<string name="onboarding_consent_note">Du kan byta språk när som helst.</string>
@@ -1,7 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Hermes</string>
<string name="app_subtitle">Native study app prototype</string>
<string name="app_subtitle">Native Hermes app prototype</string>
<string name="locale_english">English</string>
<string name="locale_swedish">Swedish</string>
<string name="common_continue">Continue</string>
<string name="common_cancel">Cancel</string>
<string name="common_close">Close</string>
@@ -11,7 +13,7 @@
<string name="errors_network">Network error. Check your connection.</string>
<string name="errors_playback">Video playback failed.</string>
<string name="errors_session_expired">Session expired. Please start again.</string>
<string name="onboarding_title">Study intro</string>
<string name="onboarding_title">Welcome to Hermes</string>
<string name="onboarding_subtitle">Watch the clip, decide before lock, then see the reveal.</string>
<string name="onboarding_consent_body">This prototype is for research and does not use real money.</string>
<string name="onboarding_consent_note">You can switch languages at any time.</string>