scaffolding hermes flow and audit logging
This commit is contained in:
@@ -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
|
||||
|
||||
+26
-15
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package com.hermes.study
|
||||
package com.hermes.app
|
||||
|
||||
import android.app.Application
|
||||
|
||||
+4
-4
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
@@ -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)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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,
|
||||
+4
-4
@@ -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
-1
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package com.hermes.study.core.haptics
|
||||
package com.hermes.app.core.haptics
|
||||
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.View
|
||||
+11
-1
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package com.hermes.study.core.media
|
||||
package com.hermes.app.core.media
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
+2
-2
@@ -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,
|
||||
) {
|
||||
+17
-13
@@ -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
-1
@@ -1,3 +1,3 @@
|
||||
package com.hermes.study.core.network
|
||||
package com.hermes.app.core.network
|
||||
|
||||
// Placeholder for future API-specific mapping helpers.
|
||||
+44
-20
@@ -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,
|
||||
+14
-2
@@ -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,
|
||||
+6
-6
@@ -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(
|
||||
+13
-14
@@ -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 }
|
||||
+5
-5
@@ -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(
|
||||
+5
-5
@@ -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(
|
||||
+13
-13
@@ -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(),
|
||||
)
|
||||
+24
-24
@@ -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,
|
||||
+3
-3
@@ -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,
|
||||
+8
-8
@@ -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),
|
||||
+7
-7
@@ -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
|
||||
+7
-8
@@ -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
|
||||
}
|
||||
-26
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user