diff --git a/.gitignore b/.gitignore
index 6438f1c..8dfe5c5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,4 @@
target/
build/
+.gradle/
+local.properties
diff --git a/mobile/android-app/app/build.gradle.kts b/mobile/android-app/app/build.gradle.kts
new file mode 100644
index 0000000..15af2c8
--- /dev/null
+++ b/mobile/android-app/app/build.gradle.kts
@@ -0,0 +1,70 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("org.jetbrains.kotlin.plugin.compose")
+ id("org.jetbrains.kotlin.plugin.serialization")
+}
+
+android {
+ namespace = "com.hermes.study"
+ compileSdk = 35
+
+ defaultConfig {
+ applicationId = "com.hermes.study"
+ minSdk = 26
+ targetSdk = 35
+ versionCode = 1
+ versionName = "0.1.0"
+
+ buildConfigField("String", "API_BASE_URL", "\"http://10.0.2.2:3000/\"")
+ }
+
+ buildFeatures {
+ compose = true
+ buildConfig = true
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+dependencies {
+ implementation(platform("androidx.compose:compose-bom:2024.06.00"))
+ implementation("androidx.activity:activity-compose:1.9.1")
+ implementation("androidx.core:core-ktx:1.13.1")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.4")
+ implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.4")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.4")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.4")
+ implementation("androidx.compose.foundation:foundation")
+ implementation("androidx.compose.animation:animation")
+ implementation("androidx.compose.material3:material3")
+ implementation("androidx.compose.material:material-icons-extended")
+ implementation("androidx.compose.ui:ui")
+ implementation("androidx.compose.ui:ui-tooling-preview")
+ implementation("com.google.android.material:material:1.12.0")
+ implementation("androidx.navigation:navigation-compose:2.7.7")
+ implementation("androidx.media3:media3-common:1.4.1")
+ implementation("androidx.media3:media3-exoplayer:1.4.1")
+ implementation("androidx.media3:media3-ui:1.4.1")
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
+ implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.1")
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1")
+
+ debugImplementation("androidx.compose.ui:ui-tooling")
+ debugImplementation("androidx.compose.ui:ui-test-manifest")
+}
diff --git a/mobile/android-app/app/src/main/AndroidManifest.xml b/mobile/android-app/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..1d72880
--- /dev/null
+++ b/mobile/android-app/app/src/main/AndroidManifest.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/HermesAppContainer.kt b/mobile/android-app/app/src/main/java/com/hermes/study/HermesAppContainer.kt
new file mode 100644
index 0000000..e660b9c
--- /dev/null
+++ b/mobile/android-app/app/src/main/java/com/hermes/study/HermesAppContainer.kt
@@ -0,0 +1,32 @@
+package com.hermes.study
+
+import android.content.Context
+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 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)
+ }
+}
+
+class HermesViewModelFactory(private val creator: () -> T) : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T = creator() as T
+}
diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/HermesApplication.kt b/mobile/android-app/app/src/main/java/com/hermes/study/HermesApplication.kt
new file mode 100644
index 0000000..914d0b2
--- /dev/null
+++ b/mobile/android-app/app/src/main/java/com/hermes/study/HermesApplication.kt
@@ -0,0 +1,13 @@
+package com.hermes.study
+
+import android.app.Application
+
+class HermesApplication : Application() {
+ lateinit var container: HermesAppContainer
+ private set
+
+ override fun onCreate() {
+ super.onCreate()
+ container = HermesAppContainer(applicationContext)
+ }
+}
diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/HermesStudyApp.kt b/mobile/android-app/app/src/main/java/com/hermes/study/HermesStudyApp.kt
new file mode 100644
index 0000000..f363f39
--- /dev/null
+++ b/mobile/android-app/app/src/main/java/com/hermes/study/HermesStudyApp.kt
@@ -0,0 +1,110 @@
+package com.hermes.study
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+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.settings.SettingsView
+import com.hermes.study.feature.round.RoundScreen
+import com.hermes.study.feature.round.RoundViewModel
+import androidx.compose.material3.ExperimentalMaterial3Api
+
+@Composable
+fun HermesStudyApp(container: HermesAppContainer) {
+ val feedViewModel: FeedViewModel = viewModel(factory = container.feedViewModelFactory())
+ val roundViewModel: RoundViewModel = viewModel(factory = container.roundViewModelFactory())
+ val localeCode by container.localizationStore.localeCode.collectAsStateWithLifecycle()
+ val feedState by feedViewModel.uiState.collectAsStateWithLifecycle()
+ val roundState by roundViewModel.uiState.collectAsStateWithLifecycle()
+
+ Scaffold(
+ topBar = {
+ HermesAppBar(
+ title = container.localizationStore.string(localeCode, R.string.app_name),
+ localeCode = localeCode,
+ onLocaleSelected = container.localizationStore::setLocale,
+ )
+ },
+ containerColor = HermesPalette.colors.background
+ ) { padding ->
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding),
+ contentPadding = PaddingValues(20.dp),
+ verticalArrangement = Arrangement.spacedBy(20.dp)
+ ) {
+ item {
+ FeedScreen(
+ uiState = feedState,
+ onWatchPreview = feedViewModel::onWatchPreview
+ )
+ }
+ item {
+ RoundScreen(
+ uiState = roundState,
+ playerCoordinator = container.playerCoordinator,
+ onSelectOutcome = roundViewModel::onSelectOutcome,
+ onConfirmSelection = roundViewModel::onConfirmSelection,
+ onRevealComplete = roundViewModel::onRevealAcknowledged,
+ onNextRound = roundViewModel::onNextRound
+ )
+ }
+ item {
+ SessionView(
+ localizationStore = container.localizationStore,
+ localeCode = localeCode,
+ )
+ }
+ item {
+ SettingsView(
+ localizationStore = container.localizationStore,
+ localeCode = localeCode,
+ )
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun HermesAppBar(
+ title: String,
+ localeCode: String,
+ onLocaleSelected: (String) -> Unit,
+) {
+ androidx.compose.material3.CenterAlignedTopAppBar(
+ title = { Text(text = title) },
+ actions = {
+ HermesSecondaryButton(text = "EN", 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") })
+ },
+ colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
+ containerColor = HermesPalette.colors.background
+ )
+ )
+}
diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/MainActivity.kt b/mobile/android-app/app/src/main/java/com/hermes/study/MainActivity.kt
new file mode 100644
index 0000000..54b15f2
--- /dev/null
+++ b/mobile/android-app/app/src/main/java/com/hermes/study/MainActivity.kt
@@ -0,0 +1,20 @@
+package com.hermes.study
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import com.hermes.study.core.designsystem.HermesStudyTheme
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val app = application as HermesApplication
+
+ setContent {
+ HermesStudyTheme {
+ HermesStudyApp(app.container)
+ }
+ }
+ }
+}
diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/core/analytics/HermesAnalyticsTracker.kt b/mobile/android-app/app/src/main/java/com/hermes/study/core/analytics/HermesAnalyticsTracker.kt
new file mode 100644
index 0000000..b21002b
--- /dev/null
+++ b/mobile/android-app/app/src/main/java/com/hermes/study/core/analytics/HermesAnalyticsTracker.kt
@@ -0,0 +1,26 @@
+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,
+ val timestamp: Instant,
+)
+
+class HermesAnalyticsTracker {
+ private val eventsFlow = MutableSharedFlow(extraBufferCapacity = 64)
+
+ val events: SharedFlow = eventsFlow.asSharedFlow()
+
+ fun track(name: String, attributes: Map = emptyMap()) {
+ val event = HermesTrackedEvent(name, attributes, Clock.System.now())
+ eventsFlow.tryEmit(event)
+ Log.d("HermesAnalytics", "${event.name} ${event.attributes}")
+ }
+}
diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/core/designsystem/HermesTheme.kt b/mobile/android-app/app/src/main/java/com/hermes/study/core/designsystem/HermesTheme.kt
new file mode 100644
index 0000000..591ada4
--- /dev/null
+++ b/mobile/android-app/app/src/main/java/com/hermes/study/core/designsystem/HermesTheme.kt
@@ -0,0 +1,150 @@
+package com.hermes.study.core.designsystem
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+
+object HermesPalette {
+ val colors = HermesColors
+}
+
+object HermesColors {
+ val background = Color(0xFF0A0D14)
+ val surface = Color(0xFF171B24)
+ val surfaceElevated = Color(0xFF222838)
+ val accent = Color(0xFFDFBF5B)
+ val accentSoft = Color(0x22DFBF5B)
+ val positive = Color(0xFF56C28F)
+ val warning = Color(0xFFF0B14C)
+ val textPrimary = Color.White
+ val textSecondary = Color(0xFFB9C0D0)
+ val textTertiary = Color(0x80B9C0D0)
+}
+
+private val HermesColorScheme = darkColorScheme(
+ primary = HermesColors.accent,
+ onPrimary = HermesColors.background,
+ secondary = HermesColors.surfaceElevated,
+ onSecondary = HermesColors.textPrimary,
+ background = HermesColors.background,
+ onBackground = HermesColors.textPrimary,
+ surface = HermesColors.surface,
+ onSurface = HermesColors.textPrimary,
+)
+
+private val HermesShapes = androidx.compose.material3.Shapes(
+ extraSmall = RoundedCornerShape(12.dp),
+ small = RoundedCornerShape(16.dp),
+ medium = RoundedCornerShape(24.dp),
+ large = RoundedCornerShape(28.dp),
+ extraLarge = RoundedCornerShape(32.dp),
+)
+
+@Composable
+fun HermesStudyTheme(content: @Composable () -> Unit) {
+ MaterialTheme(
+ colorScheme = HermesColorScheme,
+ typography = MaterialTheme.typography,
+ shapes = HermesShapes,
+ content = content,
+ )
+}
+
+@Composable
+fun HermesCard(modifier: Modifier = Modifier, elevated: Boolean = false, content: @Composable () -> Unit) {
+ Card(
+ modifier = modifier.fillMaxWidth(),
+ shape = HermesShapes.large,
+ colors = CardDefaults.cardColors(
+ containerColor = if (elevated) HermesColors.surfaceElevated else HermesColors.surface,
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = if (elevated) 10.dp else 4.dp),
+ content = {
+ Column(modifier = Modifier.padding(20.dp)) {
+ content()
+ }
+ },
+ )
+}
+
+@Composable
+fun HermesSectionHeader(title: String, subtitle: String, modifier: Modifier = Modifier) {
+ Column(modifier = modifier.fillMaxWidth()) {
+ Text(text = title, color = HermesColors.textPrimary, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
+ Spacer(modifier = Modifier.size(6.dp))
+ Text(text = subtitle, color = HermesColors.textSecondary, style = MaterialTheme.typography.bodyMedium)
+ }
+}
+
+@Composable
+fun HermesMetricChip(label: String, value: String, modifier: Modifier = Modifier) {
+ Column(
+ modifier = modifier
+ .heightIn(min = 64.dp)
+ ) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = HermesColors.surfaceElevated),
+ shape = HermesShapes.medium,
+ ) {
+ Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) {
+ Text(text = label, color = HermesColors.textTertiary, style = MaterialTheme.typography.labelSmall)
+ Text(text = value, color = HermesColors.textPrimary, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ }
+ }
+ }
+}
+
+@Composable
+fun HermesCountdownBadge(label: String, value: String, warning: Boolean = false, modifier: Modifier = Modifier) {
+ Card(
+ modifier = modifier,
+ colors = CardDefaults.cardColors(containerColor = if (warning) HermesColors.warning.copy(alpha = 0.18f) else HermesColors.accentSoft),
+ shape = HermesShapes.medium,
+ ) {
+ Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) {
+ Text(text = label, color = HermesColors.textTertiary, style = MaterialTheme.typography.labelSmall)
+ Text(text = value, color = if (warning) HermesColors.warning else HermesColors.accent, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
+ }
+ }
+}
+
+@Composable
+fun HermesPrimaryButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) {
+ Button(
+ onClick = onClick,
+ enabled = enabled,
+ modifier = modifier.fillMaxWidth(),
+ colors = ButtonDefaults.buttonColors(containerColor = HermesColors.accent, contentColor = HermesColors.background),
+ shape = HermesShapes.medium,
+ ) { Text(text) }
+}
+
+@Composable
+fun HermesSecondaryButton(text: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
+ Button(
+ onClick = onClick,
+ modifier = modifier,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = if (selected) HermesColors.accent else HermesColors.surfaceElevated,
+ contentColor = if (selected) HermesColors.background else HermesColors.textPrimary,
+ ),
+ shape = HermesShapes.small,
+ ) { Text(text) }
+}
diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/core/gestures/HermesGestures.kt b/mobile/android-app/app/src/main/java/com/hermes/study/core/gestures/HermesGestures.kt
new file mode 100644
index 0000000..9ab7794
--- /dev/null
+++ b/mobile/android-app/app/src/main/java/com/hermes/study/core/gestures/HermesGestures.kt
@@ -0,0 +1,13 @@
+package com.hermes.study.core.gestures
+
+import androidx.compose.foundation.gestures.detectVerticalDragGestures
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.pointer.pointerInput
+
+fun Modifier.hermesSwipeDown(onSwipeDown: () -> Unit): Modifier = pointerInput(onSwipeDown) {
+ detectVerticalDragGestures { _, dragAmount ->
+ if (dragAmount > 32f) {
+ onSwipeDown()
+ }
+ }
+}
diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/core/haptics/HermesHaptics.kt b/mobile/android-app/app/src/main/java/com/hermes/study/core/haptics/HermesHaptics.kt
new file mode 100644
index 0000000..0c66efe
--- /dev/null
+++ b/mobile/android-app/app/src/main/java/com/hermes/study/core/haptics/HermesHaptics.kt
@@ -0,0 +1,14 @@
+package com.hermes.study.core.haptics
+
+import android.view.HapticFeedbackConstants
+import android.view.View
+
+class HermesHaptics(private val view: View) {
+ fun selectionAccepted() {
+ view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
+ }
+
+ fun marketLocked() {
+ view.performHapticFeedback(HapticFeedbackConstants.CONFIRM)
+ }
+}
diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/core/localization/HermesLocalizationStore.kt b/mobile/android-app/app/src/main/java/com/hermes/study/core/localization/HermesLocalizationStore.kt
new file mode 100644
index 0000000..8419d65
--- /dev/null
+++ b/mobile/android-app/app/src/main/java/com/hermes/study/core/localization/HermesLocalizationStore.kt
@@ -0,0 +1,33 @@
+package com.hermes.study.core.localization
+
+import android.content.Context
+import android.content.res.Configuration
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import java.util.Locale
+
+class HermesLocalizationStore(private val appContext: Context) {
+ private val supportedLocales = setOf("en", "sv")
+ private val _localeCode = MutableStateFlow(defaultLocale())
+
+ val localeCode: StateFlow = _localeCode.asStateFlow()
+
+ fun setLocale(localeCode: String) {
+ _localeCode.value = normalize(localeCode)
+ }
+
+ fun string(resId: Int): String = localizedContext(localeCode.value).getString(resId)
+
+ fun string(localeCode: String, resId: Int): String = localizedContext(normalize(localeCode)).getString(resId)
+
+ private fun localizedContext(localeCode: String): Context {
+ val configuration = Configuration(appContext.resources.configuration)
+ configuration.setLocale(Locale.forLanguageTag(localeCode))
+ return appContext.createConfigurationContext(configuration)
+ }
+
+ private fun defaultLocale(): String = normalize(Locale.getDefault().language)
+
+ private fun normalize(localeCode: String): String = if (supportedLocales.contains(localeCode)) localeCode else "en"
+}
diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/core/media/HermesPlayerCoordinator.kt b/mobile/android-app/app/src/main/java/com/hermes/study/core/media/HermesPlayerCoordinator.kt
new file mode 100644
index 0000000..ff5afe9
--- /dev/null
+++ b/mobile/android-app/app/src/main/java/com/hermes/study/core/media/HermesPlayerCoordinator.kt
@@ -0,0 +1,42 @@
+package com.hermes.study.core.media
+
+import android.content.Context
+import android.net.Uri
+import androidx.media3.common.MediaItem
+import androidx.media3.exoplayer.ExoPlayer
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+class HermesPlayerCoordinator(context: Context) {
+ val player: ExoPlayer = ExoPlayer.Builder(context.applicationContext).build()
+
+ private val _isPlaying = MutableStateFlow(false)
+ val isPlaying: StateFlow = _isPlaying.asStateFlow()
+
+ fun preparePreview(mediaUrl: String) {
+ player.setMediaItem(MediaItem.fromUri(Uri.parse(mediaUrl)))
+ player.prepare()
+ player.playWhenReady = true
+ _isPlaying.value = true
+ }
+
+ fun play() {
+ player.play()
+ _isPlaying.value = true
+ }
+
+ fun pause() {
+ player.pause()
+ _isPlaying.value = false
+ }
+
+ fun restart(mediaUrl: String) {
+ preparePreview(mediaUrl)
+ player.seekTo(0)
+ }
+
+ fun release() {
+ player.release()
+ }
+}
diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/core/media/StudyVideoPlayerView.kt b/mobile/android-app/app/src/main/java/com/hermes/study/core/media/StudyVideoPlayerView.kt
new file mode 100644
index 0000000..a997cff
--- /dev/null
+++ b/mobile/android-app/app/src/main/java/com/hermes/study/core/media/StudyVideoPlayerView.kt
@@ -0,0 +1,30 @@
+package com.hermes.study.core.media
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.media3.ui.PlayerView
+
+@Composable
+fun StudyVideoPlayerView(
+ coordinator: HermesPlayerCoordinator,
+ modifier: Modifier = Modifier,
+) {
+ DisposableEffect(coordinator.player) {
+ onDispose {
+ // Player lifecycle is controlled by the coordinator.
+ }
+ }
+
+ AndroidView(
+ modifier = modifier,
+ factory = { context ->
+ PlayerView(context).apply {
+ useController = false
+ player = coordinator.player
+ }
+ },
+ update = { view -> view.player = coordinator.player },
+ )
+}
diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/core/network/HermesApiClient.kt b/mobile/android-app/app/src/main/java/com/hermes/study/core/network/HermesApiClient.kt
new file mode 100644
index 0000000..6818376
--- /dev/null
+++ b/mobile/android-app/app/src/main/java/com/hermes/study/core/network/HermesApiClient.kt
@@ -0,0 +1,128 @@
+package com.hermes.study.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 kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonNamingStrategy
+import okhttp3.HttpUrl
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+
+@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
+class HermesApiClient(
+ private val baseUrl: HttpUrl,
+ private val client: OkHttpClient = OkHttpClient(),
+ private val json: Json = Json {
+ ignoreUnknownKeys = true
+ encodeDefaults = false
+ explicitNulls = false
+ namingStrategy = JsonNamingStrategy.SnakeCase
+ },
+ ) {
+ private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
+
+ suspend fun startSession(request: HermesSessionStartRequest): HermesSessionResponse = post("api/v1/session/start", request)
+
+ 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 markets(eventId: String): List = get("api/v1/events/$eventId/markets")
+
+ suspend fun currentOdds(marketId: String): HermesOddsVersion = get("api/v1/markets/$marketId/odds/current")
+
+ suspend fun submitBetIntent(request: HermesBetIntentRequest): HermesBetIntentResponse = post("api/v1/bets/intent", request)
+
+ suspend fun betIntent(id: String): HermesBetIntentResponse = get("api/v1/bets/$id")
+
+ suspend fun settlement(eventId: String): HermesSettlement = get("api/v1/events/$eventId/result")
+
+ suspend fun experimentConfig(): HermesExperimentConfig = get("api/v1/experiments/config")
+
+ suspend fun localization(localeCode: String): HermesLocalizationBundle = get("api/v1/localization/$localeCode")
+
+ suspend fun submitAnalyticsBatch(request: HermesAnalyticsBatchRequest) {
+ postNoContent("api/v1/analytics/batch", request)
+ }
+
+ private suspend inline fun get(path: String): T = withContext(Dispatchers.IO) {
+ execute(path = path, method = "GET")
+ }
+
+ private suspend inline fun post(path: String): T = withContext(Dispatchers.IO) {
+ execute(path = path, method = "POST")
+ }
+
+ private suspend inline fun post(path: String, body: B): T = withContext(Dispatchers.IO) {
+ execute(path = path, method = "POST", bodyJson = json.encodeToString(body))
+ }
+
+ private suspend inline fun postNoContent(path: String, body: B) {
+ withContext(Dispatchers.IO) {
+ executeNoContent(path = path, method = "POST", bodyJson = json.encodeToString(body))
+ }
+ }
+
+ private inline fun execute(path: String, method: String, bodyJson: String? = null): T {
+ val requestBody = when {
+ bodyJson != null -> bodyJson.toRequestBody(jsonMediaType)
+ method == "GET" -> null
+ else -> "{}".toRequestBody(jsonMediaType)
+ }
+
+ val requestBuilder = Request.Builder()
+ .url(baseUrl.newBuilder().addEncodedPathSegments(path.trimStart('/')).build())
+ .method(method, requestBody)
+ .header("Accept", "application/json")
+
+ client.newCall(requestBuilder.build()).execute().use { response ->
+ if (!response.isSuccessful) {
+ throw HermesApiException.HttpStatus(response.code, response.body?.string().orEmpty())
+ }
+
+ val payload = response.body?.string().orEmpty()
+ return json.decodeFromString(payload)
+ }
+ }
+
+ private fun executeNoContent(path: String, method: String, bodyJson: String? = null) {
+ val requestBody = when {
+ bodyJson != null -> bodyJson.toRequestBody(jsonMediaType)
+ method == "GET" -> null
+ else -> "{}".toRequestBody(jsonMediaType)
+ }
+
+ val requestBuilder = Request.Builder()
+ .url(baseUrl.newBuilder().addEncodedPathSegments(path.trimStart('/')).build())
+ .method(method, requestBody)
+ .header("Accept", "application/json")
+
+ client.newCall(requestBuilder.build()).execute().use { response ->
+ if (!response.isSuccessful) {
+ throw HermesApiException.HttpStatus(response.code, response.body?.string().orEmpty())
+ }
+ }
+ }
+}
+
+sealed class HermesApiException(message: String) : RuntimeException(message) {
+ class HttpStatus(val code: Int, val body: String) : HermesApiException("HTTP $code: $body")
+}
diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/core/network/HermesApiModels.kt b/mobile/android-app/app/src/main/java/com/hermes/study/core/network/HermesApiModels.kt
new file mode 100644
index 0000000..2507eeb
--- /dev/null
+++ b/mobile/android-app/app/src/main/java/com/hermes/study/core/network/HermesApiModels.kt
@@ -0,0 +1,3 @@
+package com.hermes.study.core.network
+
+// Placeholder for future API-specific mapping helpers.
diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/data/HermesRepository.kt b/mobile/android-app/app/src/main/java/com/hermes/study/data/HermesRepository.kt
new file mode 100644
index 0000000..9dea593
--- /dev/null
+++ b/mobile/android-app/app/src/main/java/com/hermes/study/data/HermesRepository.kt
@@ -0,0 +1,65 @@
+package com.hermes.study.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.HermesSettlement
+import com.hermes.study.domain.StudyRound
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.flowOf
+
+class HermesRepository(private val apiClient: HermesApiClient) {
+ private val roundState = MutableStateFlow(SampleStudyData.round())
+
+ val currentRound: StateFlow = roundState.asStateFlow()
+
+ fun observeRound(): Flow = roundState
+
+ fun observeFeed(): Flow = roundState
+
+ suspend fun refreshRoundFromNetwork(): StudyRound {
+ roundState.value = SampleStudyData.round()
+ return roundState.value
+ }
+
+ suspend fun startSession(request: com.hermes.study.domain.HermesSessionStartRequest): HermesSessionResponse {
+ return apiClient.startSession(request)
+ }
+
+ suspend fun endSession(): HermesSessionResponse {
+ return apiClient.endSession()
+ }
+
+ suspend fun submitBetIntent(request: HermesBetIntentRequest): HermesBetIntentResponse {
+ return apiClient.submitBetIntent(request)
+ }
+
+ suspend fun currentOdds(marketId: String): HermesOddsVersion {
+ return apiClient.currentOdds(marketId)
+ }
+
+ suspend fun settlement(eventId: String): HermesSettlement {
+ return apiClient.settlement(eventId)
+ }
+
+ suspend fun experimentConfig(): HermesExperimentConfig {
+ return apiClient.experimentConfig()
+ }
+
+ suspend fun localization(localeCode: String): HermesLocalizationBundle {
+ return apiClient.localization(localeCode)
+ }
+
+ suspend fun submitAnalyticsBatch(request: HermesAnalyticsBatchRequest) {
+ apiClient.submitAnalyticsBatch(request)
+ }
+}
diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/data/SampleStudyData.kt b/mobile/android-app/app/src/main/java/com/hermes/study/data/SampleStudyData.kt
new file mode 100644
index 0000000..4155633
--- /dev/null
+++ b/mobile/android-app/app/src/main/java/com/hermes/study/data/SampleStudyData.kt
@@ -0,0 +1,124 @@
+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,
+ )
+ }
+}
diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/domain/HermesModels.kt b/mobile/android-app/app/src/main/java/com/hermes/study/domain/HermesModels.kt
new file mode 100644
index 0000000..28ea78a
--- /dev/null
+++ b/mobile/android-app/app/src/main/java/com/hermes/study/domain/HermesModels.kt
@@ -0,0 +1,172 @@
+package com.hermes.study.domain
+
+import kotlinx.datetime.Instant
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class HermesSessionStartRequest(
+ val externalRef: String? = null,
+ val localeCode: String? = null,
+ val devicePlatform: String? = null,
+ val deviceModel: String? = null,
+ val osVersion: String? = null,
+ val appVersion: String? = null,
+ val experimentVariant: String? = null,
+)
+
+@Serializable
+data class HermesSessionResponse(
+ val sessionId: String,
+ val userId: String,
+ val startedAt: Instant,
+ val endedAt: Instant? = null,
+ val experimentVariant: String,
+ val appVersion: String,
+ val deviceModel: String? = null,
+ val osVersion: String? = null,
+ val localeCode: String,
+ val devicePlatform: String,
+)
+
+@Serializable
+data class HermesEvent(
+ val id: String,
+ val sportType: String,
+ val sourceRef: String,
+ val titleEn: String,
+ val titleSv: String,
+ val status: String,
+ val previewStartMs: Int,
+ val previewEndMs: Int,
+ val revealStartMs: Int,
+ val revealEndMs: Int,
+ val lockAt: Instant,
+ val settleAt: Instant,
+)
+
+@Serializable
+data class HermesEventMedia(
+ val id: String,
+ val eventId: String,
+ val mediaType: String,
+ val hlsMasterUrl: String,
+ val posterUrl: String? = null,
+ val durationMs: Int,
+ val previewStartMs: Int,
+ val previewEndMs: Int,
+ val revealStartMs: Int,
+ val revealEndMs: Int,
+)
+
+@Serializable
+data class HermesEventManifest(
+ val event: HermesEvent,
+ val media: List,
+ val markets: List,
+)
+
+@Serializable
+data class HermesOutcome(
+ val id: String,
+ val marketId: String,
+ val outcomeCode: String,
+ val labelKey: String,
+ val sortOrder: Int,
+)
+
+@Serializable
+data class HermesMarket(
+ val id: String,
+ val eventId: String,
+ val questionKey: String,
+ val marketType: String,
+ val status: String,
+ val lockAt: Instant,
+ val settlementRuleKey: String,
+ val outcomes: List,
+)
+
+@Serializable
+data class HermesOutcomeOdds(
+ val id: String,
+ val oddsVersionId: String,
+ val outcomeId: String,
+ val decimalOdds: Double,
+ val fractionalNum: Int,
+ val fractionalDen: Int,
+)
+
+@Serializable
+data class HermesOddsVersion(
+ val id: String,
+ val marketId: String,
+ val versionNo: Int,
+ val createdAt: Instant,
+ val isCurrent: Boolean,
+ val odds: List,
+)
+
+@Serializable
+data class HermesBetIntentRequest(
+ val sessionId: String,
+ val eventId: String,
+ val marketId: String,
+ val outcomeId: String,
+ val idempotencyKey: String,
+ val clientSentAt: Instant,
+)
+
+@Serializable
+data class HermesBetIntentResponse(
+ val id: String,
+ val accepted: Boolean,
+ val acceptanceCode: String,
+ val acceptedOddsVersionId: String? = null,
+ val serverReceivedAt: Instant,
+)
+
+@Serializable
+data class HermesSettlement(
+ val id: String,
+ val marketId: String,
+ val settledAt: Instant,
+ val winningOutcomeId: String,
+)
+
+@Serializable
+data class HermesAnalyticsAttributeInput(
+ val key: String,
+ val value: String,
+)
+
+@Serializable
+data class HermesAnalyticsEventInput(
+ val eventName: String,
+ val occurredAt: Instant,
+ val attributes: List? = null,
+)
+
+@Serializable
+data class HermesAnalyticsBatchRequest(
+ val events: List,
+)
+
+@Serializable
+data class HermesExperimentConfig(
+ val variant: String,
+ val featureFlags: Map,
+)
+
+@Serializable
+data class HermesLocalizationBundle(
+ val localeCode: String,
+ val values: Map,
+)
+
+data class StudyRound(
+ val event: HermesEvent,
+ val media: HermesEventMedia,
+ val market: HermesMarket,
+ val oddsVersion: HermesOddsVersion,
+ val settlement: HermesSettlement,
+)
diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/feature/feed/FeedScreen.kt b/mobile/android-app/app/src/main/java/com/hermes/study/feature/feed/FeedScreen.kt
new file mode 100644
index 0000000..8724e11
--- /dev/null
+++ b/mobile/android-app/app/src/main/java/com/hermes/study/feature/feed/FeedScreen.kt
@@ -0,0 +1,71 @@
+package com.hermes.study.feature.feed
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.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
+
+@Composable
+fun FeedScreen(
+ uiState: FeedUiState,
+ onWatchPreview: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ HermesCard(modifier = modifier, elevated = true) {
+ Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ HermesSectionHeader(title = uiState.title, subtitle = uiState.subtitle)
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(220.dp)
+ .clip(RoundedCornerShape(28.dp))
+ .background(
+ Brush.linearGradient(
+ colors = listOf(HermesColors.surfaceElevated, HermesColors.background),
+ ),
+ )
+ .padding(20.dp),
+ contentAlignment = Alignment.BottomStart,
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ text = uiState.heroTitle,
+ style = MaterialTheme.typography.headlineSmall,
+ color = HermesColors.textPrimary,
+ )
+ Text(
+ text = uiState.heroSubtitle,
+ style = MaterialTheme.typography.bodyMedium,
+ color = HermesColors.textSecondary,
+ )
+ }
+ }
+
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
+ HermesMetricChip(label = uiState.lockLabel, value = uiState.lockValue, modifier = Modifier.fillMaxWidth(0.5f))
+ HermesMetricChip(label = uiState.oddsLabel, value = uiState.oddsValue, modifier = Modifier.fillMaxWidth(0.5f))
+ }
+
+ HermesPrimaryButton(text = uiState.ctaText, onClick = onWatchPreview)
+ }
+ }
+}
diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/feature/feed/FeedViewModel.kt b/mobile/android-app/app/src/main/java/com/hermes/study/feature/feed/FeedViewModel.kt
new file mode 100644
index 0000000..64dd553
--- /dev/null
+++ b/mobile/android-app/app/src/main/java/com/hermes/study/feature/feed/FeedViewModel.kt
@@ -0,0 +1,106 @@
+package com.hermes.study.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.localization.HermesLocalizationStore
+import com.hermes.study.data.HermesRepository
+import com.hermes.study.domain.StudyRound
+import java.util.Locale
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.SharingStarted
+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(
+ val title: String,
+ val subtitle: String,
+ val heroTitle: String,
+ val heroSubtitle: String,
+ val lockLabel: String,
+ val lockValue: String,
+ val oddsLabel: String,
+ val oddsValue: String,
+ val ctaText: String,
+)
+
+class FeedViewModel(
+ repository: HermesRepository,
+ private val localizationStore: HermesLocalizationStore,
+ private val analyticsTracker: HermesAnalyticsTracker,
+) : ViewModel() {
+ private val roundFlow = repository.currentRound
+ private val localeFlow = localizationStore.localeCode
+ private val nowFlow = flow {
+ while (true) {
+ emit(Clock.System.now())
+ delay(1_000)
+ }
+ }
+
+ val uiState: StateFlow = combine(roundFlow, localeFlow, nowFlow) { round, localeCode, now ->
+ buildUiState(round = round, localeCode = localeCode, now = now)
+ }.stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.Eagerly,
+ initialValue = buildUiState(
+ round = roundFlow.value,
+ localeCode = localeFlow.value,
+ now = Clock.System.now(),
+ ),
+ )
+
+ init {
+ analyticsTracker.track("feed_viewed", attributes = mapOf("screen_name" to "feed"))
+ analyticsTracker.track("screen_viewed", attributes = mapOf("screen_name" to "feed"))
+ }
+
+ fun onWatchPreview() {
+ analyticsTracker.track("next_round_requested", attributes = mapOf("screen_name" to "feed"))
+ analyticsTracker.track(
+ "cta_pressed",
+ attributes = mapOf("screen_name" to "feed", "action" to "watch_preview"),
+ )
+ }
+
+ private fun buildUiState(round: StudyRound, localeCode: String, now: Instant): FeedUiState {
+ return FeedUiState(
+ title = localized(localeCode, R.string.feed_title),
+ subtitle = localized(localeCode, R.string.feed_subtitle),
+ heroTitle = localized(localeCode, R.string.feed_hero_title),
+ heroSubtitle = localized(localeCode, R.string.feed_hero_subtitle),
+ lockLabel = localized(localeCode, R.string.feed_lock_label),
+ lockValue = formatCountdown(round.event.lockAt, now),
+ oddsLabel = localized(localeCode, R.string.feed_odds_label),
+ oddsValue = formatOdds(round),
+ ctaText = localized(localeCode, R.string.feed_cta),
+ )
+ }
+
+ private fun localized(localeCode: String, resId: Int): String {
+ return localizationStore.string(localeCode, resId)
+ }
+
+ private fun formatCountdown(lockAt: Instant, now: Instant): String {
+ val remainingMillis = (lockAt.toEpochMilliseconds() - now.toEpochMilliseconds()).coerceAtLeast(0)
+ val totalSeconds = (remainingMillis / 1_000).toInt()
+ val minutes = totalSeconds / 60
+ val seconds = totalSeconds % 60
+ return String.format(Locale.US, "%02d:%02d", minutes, seconds)
+ }
+
+ private fun formatOdds(round: StudyRound): String {
+ val oddsByOutcomeId = round.oddsVersion.odds.associateBy { it.outcomeId }
+ return round.market.outcomes
+ .sortedBy { it.sortOrder }
+ .mapNotNull { outcome ->
+ oddsByOutcomeId[outcome.id]?.decimalOdds?.let { String.format(Locale.US, "%.2f", it) }
+ }
+ .joinToString(" / ")
+ }
+}
diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/feature/result/ResultPanel.kt b/mobile/android-app/app/src/main/java/com/hermes/study/feature/result/ResultPanel.kt
new file mode 100644
index 0000000..c112da3
--- /dev/null
+++ b/mobile/android-app/app/src/main/java/com/hermes/study/feature/result/ResultPanel.kt
@@ -0,0 +1,60 @@
+package com.hermes.study.feature.result
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Cancel
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+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
+
+@Composable
+fun ResultPanel(
+ title: String,
+ subtitle: String,
+ selectionLabel: String,
+ selectionValue: String,
+ outcomeLabel: String,
+ outcomeValue: String,
+ didWin: Boolean,
+ winLabel: String,
+ loseLabel: String,
+ nextRoundTitle: String,
+ onNextRound: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ HermesSectionHeader(title = title, subtitle = subtitle)
+
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
+ HermesMetricChip(label = selectionLabel, value = selectionValue, modifier = Modifier.fillMaxWidth(0.5f))
+ HermesMetricChip(label = outcomeLabel, value = outcomeValue, modifier = Modifier.fillMaxWidth(0.5f))
+ }
+
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ Icon(
+ imageVector = if (didWin) Icons.Filled.CheckCircle else Icons.Filled.Cancel,
+ contentDescription = null,
+ tint = if (didWin) HermesColors.positive else HermesColors.warning,
+ )
+ Text(
+ text = if (didWin) winLabel else loseLabel,
+ style = MaterialTheme.typography.titleMedium,
+ color = HermesColors.textPrimary,
+ )
+ }
+
+ HermesPrimaryButton(text = nextRoundTitle, onClick = onNextRound)
+ }
+}
diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/feature/reveal/RevealPanel.kt b/mobile/android-app/app/src/main/java/com/hermes/study/feature/reveal/RevealPanel.kt
new file mode 100644
index 0000000..f73d01e
--- /dev/null
+++ b/mobile/android-app/app/src/main/java/com/hermes/study/feature/reveal/RevealPanel.kt
@@ -0,0 +1,40 @@
+package com.hermes.study.feature.reveal
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+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 androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import com.hermes.study.core.designsystem.HermesColors
+
+@Composable
+fun RevealPanel(
+ title: String,
+ subtitle: String,
+ statusText: String,
+ selectionLabel: String,
+ selectionValue: String,
+ continueTitle: String,
+ onContinue: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ HermesSectionHeader(title = title, subtitle = subtitle)
+
+ HermesMetricChip(label = selectionLabel, value = selectionValue, modifier = Modifier.fillMaxWidth())
+
+ Text(
+ text = statusText,
+ style = MaterialTheme.typography.bodyMedium,
+ color = HermesColors.textSecondary,
+ )
+
+ HermesPrimaryButton(text = continueTitle, onClick = onContinue)
+ }
+}
diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/feature/round/RoundScreen.kt b/mobile/android-app/app/src/main/java/com/hermes/study/feature/round/RoundScreen.kt
new file mode 100644
index 0000000..3a78ab8
--- /dev/null
+++ b/mobile/android-app/app/src/main/java/com/hermes/study/feature/round/RoundScreen.kt
@@ -0,0 +1,125 @@
+package com.hermes.study.feature.round
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+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
+
+@Composable
+fun RoundScreen(
+ uiState: RoundUiState,
+ playerCoordinator: HermesPlayerCoordinator,
+ onSelectOutcome: (String) -> Unit,
+ onConfirmSelection: () -> Unit,
+ onRevealComplete: () -> Unit,
+ onNextRound: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ HermesCard(modifier = modifier, elevated = true) {
+ Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ com.hermes.study.core.designsystem.HermesSectionHeader(title = uiState.title, subtitle = uiState.subtitle)
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(220.dp),
+ ) {
+ StudyVideoPlayerView(
+ coordinator = playerCoordinator,
+ modifier = Modifier.fillMaxSize(),
+ )
+
+ Column(
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(12.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ HermesCountdownBadge(
+ label = uiState.countdownLabel,
+ value = uiState.countdownText,
+ warning = uiState.countdownWarning,
+ )
+
+ HermesMetricChip(
+ label = uiState.oddsLabel,
+ value = uiState.oddsValue,
+ modifier = Modifier.widthIn(min = 128.dp),
+ )
+ }
+
+ Text(
+ text = uiState.phaseLabel,
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .padding(12.dp)
+ .background(
+ color = Color.Black.copy(alpha = 0.35f),
+ shape = RoundedCornerShape(999.dp),
+ )
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ style = MaterialTheme.typography.labelMedium,
+ color = HermesColors.textPrimary,
+ )
+ }
+
+ when (uiState.phase) {
+ RoundPhase.PREVIEW, RoundPhase.LOCKED -> SelectionPanel(
+ statusText = uiState.selectionStatusText,
+ options = uiState.selectionOptions,
+ selectedOptionId = uiState.selectedOutcomeId,
+ isLocked = uiState.selectionLocked,
+ confirmTitle = uiState.confirmTitle,
+ onSelect = { onSelectOutcome(it.id) },
+ onConfirm = onConfirmSelection,
+ )
+
+ RoundPhase.REVEAL -> RevealPanel(
+ title = uiState.revealTitle,
+ subtitle = uiState.revealSubtitle,
+ statusText = uiState.revealStatusText,
+ selectionLabel = uiState.revealSelectionLabel,
+ selectionValue = uiState.selectedOutcomeTitle,
+ continueTitle = uiState.revealContinueTitle,
+ onContinue = onRevealComplete,
+ )
+
+ RoundPhase.RESULT -> ResultPanel(
+ title = uiState.resultTitle,
+ subtitle = uiState.resultSubtitle,
+ selectionLabel = uiState.resultSelectionLabel,
+ selectionValue = uiState.selectedOutcomeTitle,
+ outcomeLabel = uiState.resultOutcomeLabel,
+ outcomeValue = uiState.winningOutcomeTitle,
+ didWin = uiState.didWin,
+ winLabel = uiState.resultWinLabel,
+ loseLabel = uiState.resultLoseLabel,
+ nextRoundTitle = uiState.resultNextRoundTitle,
+ onNextRound = onNextRound,
+ )
+ }
+ }
+ }
+}
diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/feature/round/RoundViewModel.kt b/mobile/android-app/app/src/main/java/com/hermes/study/feature/round/RoundViewModel.kt
new file mode 100644
index 0000000..c091ff5
--- /dev/null
+++ b/mobile/android-app/app/src/main/java/com/hermes/study/feature/round/RoundViewModel.kt
@@ -0,0 +1,351 @@
+package com.hermes.study.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.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.StudyRound
+import com.hermes.study.feature.selection.SelectionOptionUi
+import java.util.Locale
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import kotlinx.datetime.Clock
+import kotlinx.datetime.Instant
+
+enum class RoundPhase {
+ PREVIEW,
+ LOCKED,
+ REVEAL,
+ RESULT,
+}
+
+data class RoundUiState(
+ val title: String,
+ val subtitle: String,
+ val countdownLabel: String,
+ val countdownText: String,
+ val countdownWarning: Boolean,
+ val oddsLabel: String,
+ val oddsValue: String,
+ val phase: RoundPhase,
+ val phaseLabel: String,
+ val selectionStatusText: String,
+ val selectionLocked: Boolean,
+ val selectionOptions: List,
+ val selectedOutcomeId: String?,
+ val selectedOutcomeTitle: String,
+ val winningOutcomeTitle: String,
+ val didWin: Boolean,
+ val videoUrl: String,
+ val confirmTitle: String,
+ val revealTitle: String,
+ val revealSubtitle: String,
+ val revealStatusText: String,
+ val revealSelectionLabel: String,
+ val revealContinueTitle: String,
+ val resultTitle: String,
+ val resultSubtitle: String,
+ val resultSelectionLabel: String,
+ val resultOutcomeLabel: String,
+ val resultWinLabel: String,
+ val resultLoseLabel: String,
+ val resultNextRoundTitle: String,
+)
+
+class RoundViewModel(
+ private val repository: HermesRepository,
+ private val localizationStore: HermesLocalizationStore,
+ private val analyticsTracker: HermesAnalyticsTracker,
+ private val playerCoordinator: HermesPlayerCoordinator,
+) : ViewModel() {
+ private val roundFlow = repository.currentRound
+ private val localeFlow = localizationStore.localeCode
+ private val phaseFlow = MutableStateFlow(RoundPhase.PREVIEW)
+ private val selectedOutcomeIdFlow = MutableStateFlow(null)
+ private val nowFlow = flow {
+ while (true) {
+ emit(Clock.System.now())
+ delay(1_000)
+ }
+ }
+ private var transitionJob: Job? = null
+
+ val uiState: StateFlow = combine(
+ roundFlow,
+ localeFlow,
+ phaseFlow,
+ selectedOutcomeIdFlow,
+ nowFlow,
+ ) { round, localeCode, phase, selectedOutcomeId, now ->
+ buildUiState(
+ round = round,
+ localeCode = localeCode,
+ phase = phase,
+ selectedOutcomeId = selectedOutcomeId,
+ now = now,
+ )
+ }.stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.Eagerly,
+ initialValue = buildUiState(
+ round = roundFlow.value,
+ localeCode = localeFlow.value,
+ phase = phaseFlow.value,
+ selectedOutcomeId = selectedOutcomeIdFlow.value,
+ now = Clock.System.now(),
+ ),
+ )
+
+ init {
+ startPreview(roundFlow.value)
+ }
+
+ fun onSelectOutcome(outcomeId: String) {
+ val round = roundFlow.value
+ if (phaseFlow.value != RoundPhase.PREVIEW) {
+ return
+ }
+
+ if (isTimerLocked(round, Clock.System.now())) {
+ return
+ }
+
+ selectedOutcomeIdFlow.value = outcomeId
+ analyticsTracker.track(
+ "outcome_focused",
+ attributes = mapOf("screen_name" to "round", "outcome_id" to outcomeId),
+ )
+ analyticsTracker.track(
+ "outcome_selected",
+ attributes = mapOf("screen_name" to "round", "outcome_id" to outcomeId),
+ )
+ }
+
+ fun onConfirmSelection() {
+ val round = roundFlow.value
+ val selectedOutcomeId = selectedOutcomeIdFlow.value ?: return
+ if (phaseFlow.value != RoundPhase.PREVIEW) {
+ return
+ }
+
+ if (isTimerLocked(round, Clock.System.now())) {
+ return
+ }
+
+ analyticsTracker.track(
+ "selection_submitted",
+ attributes = baseSelectionAttributes(selectedOutcomeId),
+ )
+ analyticsTracker.track(
+ "selection_accepted",
+ attributes = baseSelectionAttributes(selectedOutcomeId),
+ )
+ analyticsTracker.track(
+ "market_locked",
+ attributes = baseSelectionAttributes(selectedOutcomeId) + ("lock_reason" to "manual_selection"),
+ )
+
+ phaseFlow.value = RoundPhase.LOCKED
+ playerCoordinator.pause()
+
+ transitionJob?.cancel()
+ transitionJob = viewModelScope.launch {
+ delay(750)
+ if (phaseFlow.value == RoundPhase.LOCKED) {
+ phaseFlow.value = RoundPhase.REVEAL
+ analyticsTracker.track(
+ "reveal_started",
+ attributes = roundAnalyticsAttributes(round) + baseSelectionAttributes(selectedOutcomeId),
+ )
+ }
+ }
+ }
+
+ fun onRevealAcknowledged() {
+ val round = roundFlow.value
+ val selectedOutcomeId = selectedOutcomeIdFlow.value ?: return
+
+ analyticsTracker.track(
+ "reveal_completed",
+ attributes = roundAnalyticsAttributes(round) + baseSelectionAttributes(selectedOutcomeId),
+ )
+ phaseFlow.value = RoundPhase.RESULT
+ analyticsTracker.track(
+ "result_viewed",
+ attributes = roundAnalyticsAttributes(round) + baseSelectionAttributes(selectedOutcomeId) + mapOf(
+ "outcome" to resolveWinningOutcomeTitle(round, localeFlow.value),
+ ),
+ )
+ }
+
+ fun onNextRound() {
+ analyticsTracker.track(
+ "next_round_requested",
+ attributes = mapOf("screen_name" to "result"),
+ )
+
+ viewModelScope.launch {
+ transitionJob?.cancel()
+ val round = repository.refreshRoundFromNetwork()
+ startPreview(round)
+ }
+ }
+
+ private fun startPreview(round: StudyRound) {
+ phaseFlow.value = RoundPhase.PREVIEW
+ selectedOutcomeIdFlow.value = null
+ playerCoordinator.preparePreview(round.media.hlsMasterUrl)
+ playerCoordinator.player.seekTo(round.media.previewStartMs.toLong())
+
+ analyticsTracker.track("round_loaded", attributes = roundAnalyticsAttributes(round))
+ analyticsTracker.track("preview_started", attributes = roundAnalyticsAttributes(round))
+ analyticsTracker.track("screen_viewed", attributes = mapOf("screen_name" to "round"))
+ }
+
+ private fun buildUiState(
+ round: StudyRound,
+ localeCode: String,
+ phase: RoundPhase,
+ selectedOutcomeId: String?,
+ now: Instant,
+ ): RoundUiState {
+ val remainingMillis = round.event.lockAt.toEpochMilliseconds() - now.toEpochMilliseconds()
+ val isTimerLocked = remainingMillis <= 0
+ val countdownText = formatCountdown(remainingMillis)
+ val selectionLocked = phase != RoundPhase.PREVIEW || isTimerLocked
+ val options = selectionOptions(round, localeCode)
+ val selectedTitle = resolveSelectedOutcomeTitle(round, localeCode, selectedOutcomeId)
+ val winningTitle = resolveWinningOutcomeTitle(round, localeCode)
+
+ return RoundUiState(
+ title = localized(localeCode, R.string.round_title),
+ subtitle = localized(localeCode, R.string.round_subtitle),
+ countdownLabel = localized(localeCode, R.string.round_countdown_label),
+ countdownText = countdownText,
+ countdownWarning = phase == RoundPhase.PREVIEW && !isTimerLocked && remainingMillis <= 10_000,
+ oddsLabel = localized(localeCode, R.string.round_odds_label),
+ oddsValue = oddsSummary(round),
+ phase = phase,
+ phaseLabel = phaseLabel(localeCode, phase, isTimerLocked),
+ selectionStatusText = if (selectionLocked) localized(localeCode, R.string.round_locked_label) else localized(localeCode, R.string.round_selection_prompt),
+ selectionLocked = selectionLocked,
+ selectionOptions = options,
+ selectedOutcomeId = selectedOutcomeId,
+ selectedOutcomeTitle = selectedTitle,
+ winningOutcomeTitle = winningTitle,
+ didWin = selectedOutcomeId == round.settlement.winningOutcomeId,
+ videoUrl = round.media.hlsMasterUrl,
+ confirmTitle = localized(localeCode, R.string.round_primary_cta),
+ revealTitle = localized(localeCode, R.string.reveal_title),
+ revealSubtitle = localized(localeCode, R.string.reveal_subtitle),
+ revealStatusText = localized(localeCode, R.string.reveal_status),
+ revealSelectionLabel = localized(localeCode, R.string.result_selection_label),
+ revealContinueTitle = localized(localeCode, R.string.reveal_cta),
+ resultTitle = localized(localeCode, R.string.result_title),
+ resultSubtitle = localized(localeCode, R.string.result_subtitle),
+ resultSelectionLabel = localized(localeCode, R.string.result_selection_label),
+ resultOutcomeLabel = localized(localeCode, R.string.result_outcome_label),
+ resultWinLabel = localized(localeCode, R.string.result_win),
+ resultLoseLabel = localized(localeCode, R.string.result_lose),
+ resultNextRoundTitle = localized(localeCode, R.string.result_next_round),
+ )
+ }
+
+ private fun localized(localeCode: String, resId: Int): String {
+ return localizationStore.string(localeCode, resId)
+ }
+
+ private fun selectionOptions(round: StudyRound, localeCode: String): List {
+ val oddsByOutcomeId = round.oddsVersion.odds.associateBy { it.outcomeId }
+
+ return round.market.outcomes
+ .sortedBy { it.sortOrder }
+ .map { outcome ->
+ SelectionOptionUi(
+ id = outcome.id,
+ title = outcomeTitle(localeCode, outcome),
+ subtitle = localized(localeCode, R.string.round_selection_prompt),
+ odds = oddsByOutcomeId[outcome.id]?.decimalOdds?.let { formatOdds(it) } ?: "--",
+ )
+ }
+ }
+
+ private fun outcomeTitle(localeCode: String, outcome: HermesOutcome): String {
+ return when (outcome.labelKey) {
+ "round.home" -> localized(localeCode, R.string.round_home)
+ "round.away" -> localized(localeCode, R.string.round_away)
+ else -> outcome.outcomeCode.replaceFirstChar { char -> char.uppercaseChar().toString() }
+ }
+ }
+
+ private fun resolveSelectedOutcomeTitle(round: StudyRound, localeCode: String, selectedOutcomeId: String?): String {
+ if (selectedOutcomeId == null) {
+ return localized(localeCode, R.string.round_selection_prompt)
+ }
+
+ return round.market.outcomes.firstOrNull { it.id == selectedOutcomeId }?.let { outcomeTitle(localeCode, it) }
+ ?: localized(localeCode, R.string.round_selection_prompt)
+ }
+
+ private fun resolveWinningOutcomeTitle(round: StudyRound, 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 {
+ val oddsByOutcomeId = round.oddsVersion.odds.associateBy { it.outcomeId }
+ return round.market.outcomes
+ .sortedBy { it.sortOrder }
+ .mapNotNull { outcome ->
+ oddsByOutcomeId[outcome.id]?.decimalOdds?.let { formatOdds(it) }
+ }
+ .joinToString(" / ")
+ }
+
+ private fun formatOdds(value: Double): String {
+ return String.format(Locale.US, "%.2f", value)
+ }
+
+ private fun formatCountdown(remainingMillis: Long): String {
+ val totalSeconds = (remainingMillis.coerceAtLeast(0) / 1_000).toInt()
+ val minutes = totalSeconds / 60
+ val seconds = totalSeconds % 60
+ return String.format(Locale.US, "%02d:%02d", minutes, seconds)
+ }
+
+ private fun phaseLabel(localeCode: String, phase: RoundPhase, isTimerLocked: Boolean): String {
+ return when (phase) {
+ RoundPhase.PREVIEW -> if (isTimerLocked) localized(localeCode, R.string.round_locked_label) else localized(localeCode, R.string.round_preview_label)
+ RoundPhase.LOCKED -> localized(localeCode, R.string.round_locked_label)
+ RoundPhase.REVEAL -> localized(localeCode, R.string.reveal_title)
+ RoundPhase.RESULT -> localized(localeCode, R.string.result_title)
+ }
+ }
+
+ private fun isTimerLocked(round: StudyRound, now: Instant): Boolean {
+ return now.toEpochMilliseconds() >= round.event.lockAt.toEpochMilliseconds()
+ }
+
+ private fun baseSelectionAttributes(outcomeId: String): Map {
+ return mapOf("screen_name" to "round", "outcome_id" to outcomeId)
+ }
+
+ private fun roundAnalyticsAttributes(round: StudyRound): Map {
+ return mapOf(
+ "screen_name" to "round",
+ "event_id" to round.event.id,
+ "market_id" to round.market.id,
+ )
+ }
+}
diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/feature/selection/SelectionPanel.kt b/mobile/android-app/app/src/main/java/com/hermes/study/feature/selection/SelectionPanel.kt
new file mode 100644
index 0000000..d297e6d
--- /dev/null
+++ b/mobile/android-app/app/src/main/java/com/hermes/study/feature/selection/SelectionPanel.kt
@@ -0,0 +1,131 @@
+package com.hermes.study.feature.selection
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.Lock
+import androidx.compose.material.icons.filled.TouchApp
+import androidx.compose.material.icons.outlined.RadioButtonUnchecked
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.hermes.study.core.designsystem.HermesColors
+import com.hermes.study.core.designsystem.HermesPrimaryButton
+
+data class SelectionOptionUi(
+ val id: String,
+ val title: String,
+ val subtitle: String,
+ val odds: String,
+)
+
+@Composable
+fun SelectionPanel(
+ statusText: String,
+ options: List,
+ selectedOptionId: String?,
+ isLocked: Boolean,
+ confirmTitle: String,
+ onSelect: (SelectionOptionUi) -> Unit,
+ onConfirm: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ val statusColor = if (isLocked) HermesColors.warning else HermesColors.accent
+ Row(
+ modifier = Modifier
+ .clip(RoundedCornerShape(999.dp))
+ .background(statusColor.copy(alpha = 0.16f))
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Icon(
+ imageVector = if (isLocked) Icons.Filled.Lock else Icons.Filled.TouchApp,
+ contentDescription = null,
+ tint = statusColor,
+ )
+ Text(
+ text = statusText,
+ color = statusColor,
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ options.forEach { option ->
+ SelectionOptionButton(
+ option = option,
+ selected = selectedOptionId == option.id,
+ isLocked = isLocked,
+ onClick = { onSelect(option) },
+ )
+ }
+ }
+
+ HermesPrimaryButton(
+ text = confirmTitle,
+ onClick = onConfirm,
+ enabled = !isLocked && selectedOptionId != null,
+ )
+ }
+}
+
+@Composable
+private fun SelectionOptionButton(
+ option: SelectionOptionUi,
+ selected: Boolean,
+ isLocked: Boolean,
+ onClick: () -> Unit,
+) {
+ val containerColor = if (selected) HermesColors.accent else HermesColors.surfaceElevated
+ val contentColor = if (selected) HermesColors.background else HermesColors.textPrimary
+ val subtitleColor = if (selected) HermesColors.background.copy(alpha = 0.7f) else HermesColors.textSecondary
+
+ Button(
+ onClick = onClick,
+ enabled = !isLocked,
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(18.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = containerColor,
+ contentColor = contentColor,
+ disabledContainerColor = HermesColors.surfaceElevated.copy(alpha = 0.65f),
+ disabledContentColor = HermesColors.textTertiary,
+ ),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = Modifier.fillMaxWidth(0.72f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(text = option.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ Text(text = option.subtitle, style = MaterialTheme.typography.bodySmall, color = subtitleColor)
+ }
+
+ Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(text = option.odds, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
+ Icon(
+ imageVector = if (selected) Icons.Filled.CheckCircle else Icons.Outlined.RadioButtonUnchecked,
+ contentDescription = null,
+ tint = contentColor,
+ )
+ }
+ }
+ }
+}
diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/feature/session/SessionView.kt b/mobile/android-app/app/src/main/java/com/hermes/study/feature/session/SessionView.kt
new file mode 100644
index 0000000..7fb48fb
--- /dev/null
+++ b/mobile/android-app/app/src/main/java/com/hermes/study/feature/session/SessionView.kt
@@ -0,0 +1,36 @@
+package com.hermes.study.feature.session
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+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 androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import com.hermes.study.core.designsystem.HermesColors
+import com.hermes.study.core.designsystem.HermesSectionHeader
+import com.hermes.study.core.localization.HermesLocalizationStore
+
+@Composable
+fun SessionView(
+ localizationStore: HermesLocalizationStore,
+ localeCode: String,
+ modifier: Modifier = Modifier,
+) {
+ HermesCard(modifier = modifier) {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ HermesSectionHeader(
+ title = localizationStore.string(localeCode, R.string.session_title),
+ subtitle = localizationStore.string(localeCode, R.string.session_subtitle),
+ )
+
+ Text(
+ text = localizationStore.string(localeCode, R.string.session_note),
+ style = MaterialTheme.typography.bodyMedium,
+ color = HermesColors.textSecondary,
+ )
+ }
+ }
+}
diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/feature/settings/SettingsView.kt b/mobile/android-app/app/src/main/java/com/hermes/study/feature/settings/SettingsView.kt
new file mode 100644
index 0000000..635b870
--- /dev/null
+++ b/mobile/android-app/app/src/main/java/com/hermes/study/feature/settings/SettingsView.kt
@@ -0,0 +1,62 @@
+package com.hermes.study.feature.settings
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.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 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
+
+@Composable
+fun SettingsView(
+ localizationStore: HermesLocalizationStore,
+ localeCode: String,
+ modifier: Modifier = Modifier,
+) {
+ HermesCard(modifier = modifier) {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ HermesSectionHeader(
+ title = localizationStore.string(localeCode, R.string.settings_title),
+ subtitle = localizationStore.string(localeCode, R.string.settings_subtitle),
+ )
+
+ SettingRow(
+ label = localizationStore.string(localeCode, R.string.settings_language),
+ value = localeCode.uppercase(Locale.US),
+ )
+ SettingRow(
+ label = localizationStore.string(localeCode, R.string.settings_haptics),
+ value = localizationStore.string(localeCode, R.string.settings_enabled),
+ )
+ SettingRow(
+ label = localizationStore.string(localeCode, R.string.settings_analytics),
+ value = localizationStore.string(localeCode, R.string.settings_enabled),
+ )
+ }
+ }
+}
+
+@Composable
+private fun SettingRow(
+ label: String,
+ value: String,
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(text = label, style = MaterialTheme.typography.bodyLarge, color = HermesColors.textPrimary)
+ Text(text = value, style = MaterialTheme.typography.bodyMedium, color = HermesColors.textSecondary)
+ }
+}
diff --git a/mobile/android-app/app/src/main/res/values-night/themes.xml b/mobile/android-app/app/src/main/res/values-night/themes.xml
new file mode 100644
index 0000000..4357c4b
--- /dev/null
+++ b/mobile/android-app/app/src/main/res/values-night/themes.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/mobile/android-app/app/src/main/res/values-sv/strings.xml b/mobile/android-app/app/src/main/res/values-sv/strings.xml
new file mode 100644
index 0000000..cc3ae53
--- /dev/null
+++ b/mobile/android-app/app/src/main/res/values-sv/strings.xml
@@ -0,0 +1,57 @@
+
+
+ Hermes
+ Native prototyp för studien
+ Fortsätt
+ Avbryt
+ Stäng
+ Försök igen
+ Laddar
+ Försök igen.
+ Nätverksfel. Kontrollera anslutningen.
+ Videouppspelningen misslyckades.
+ Sessionen har gått ut. Starta igen.
+ Studieintro
+ Titta på klippet, välj före låsning och se sedan avslöjandet.
+ Den här prototypen är för forskning och använder inga riktiga pengar.
+ Du kan byta språk när som helst.
+ Starta session
+ Förhandsflöde
+ Nästa runda är redo att granskas.
+ Möjlighet till segermål
+ En kort förhandsvisning leder till ett tydligt val.
+ Låses om
+ Liveodds
+ Titta på förhandsklipp
+ Aktiv runda
+ Gör ditt val före låsning och vänta sedan på avslöjandet.
+ Videoförhandsvisning
+ Förhandsvisning spelas
+ Låsning om
+ Odds
+ Välj ett utfall.
+ Bekräfta valet
+ Låst
+ Hemma
+ Borta
+ Avslöjande
+ Klippet visar nu utfallet.
+ Avslöjningssegmentet spelas upp.
+ Se resultat
+ Resultat
+ Ditt val jämfört med det faktiska utfallet.
+ Ditt val
+ Utfall
+ Vinnande val
+ Inte denna gång
+ Nästa runda
+ Inställningar
+ Språk-, haptik- och analysinställningar.
+ Språk
+ Aktiverad
+ Haptik
+ Analys
+ Session
+ Sessionssynk och livscykelkontroller.
+ Sessionsstatus visas här när backend-sessionen har startat.
+
diff --git a/mobile/android-app/app/src/main/res/values/colors.xml b/mobile/android-app/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..58a5fa5
--- /dev/null
+++ b/mobile/android-app/app/src/main/res/values/colors.xml
@@ -0,0 +1,11 @@
+
+
+ #0A0D14
+ #171B24
+ #222838
+ #DFBF5B
+ #56C28F
+ #F0B14C
+ #FFFFFF
+ #B9C0D0
+
diff --git a/mobile/android-app/app/src/main/res/values/strings.xml b/mobile/android-app/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..08007fa
--- /dev/null
+++ b/mobile/android-app/app/src/main/res/values/strings.xml
@@ -0,0 +1,57 @@
+
+
+ Hermes
+ Native study app prototype
+ Continue
+ Cancel
+ Close
+ Retry
+ Loading
+ Please try again.
+ Network error. Check your connection.
+ Video playback failed.
+ Session expired. Please start again.
+ Study intro
+ Watch the clip, decide before lock, then see the reveal.
+ This prototype is for research and does not use real money.
+ You can switch languages at any time.
+ Start session
+ Preview feed
+ The next round is ready for review.
+ Late winner chance
+ A short preview leads into a single, clear choice.
+ Locks in
+ Live odds
+ Watch preview
+ Active round
+ Make your choice before lock, then wait for the reveal.
+ Video preview
+ Preview playing
+ Lock in
+ Odds
+ Choose an outcome.
+ Confirm selection
+ Locked
+ Home
+ Away
+ Reveal
+ The clip is now showing the outcome.
+ Reveal segment is playing.
+ See result
+ Result
+ Your selection versus the actual outcome.
+ Your selection
+ Outcome
+ Winning selection
+ Not this time
+ Next round
+ Settings
+ Language, haptics and analytics preferences.
+ Language
+ Enabled
+ Haptics
+ Analytics
+ Session
+ Session sync and lifecycle controls.
+ Session state will appear here once the backend session is started.
+
diff --git a/mobile/android-app/app/src/main/res/values/themes.xml b/mobile/android-app/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..4357c4b
--- /dev/null
+++ b/mobile/android-app/app/src/main/res/values/themes.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/mobile/android-app/build.gradle.kts b/mobile/android-app/build.gradle.kts
new file mode 100644
index 0000000..6706068
--- /dev/null
+++ b/mobile/android-app/build.gradle.kts
@@ -0,0 +1,6 @@
+plugins {
+ id("com.android.application") version "8.5.2" apply false
+ id("org.jetbrains.kotlin.android") version "2.0.21" apply false
+ id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
+ id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
+}
diff --git a/mobile/android-app/gradle.properties b/mobile/android-app/gradle.properties
new file mode 100644
index 0000000..7bf4d21
--- /dev/null
+++ b/mobile/android-app/gradle.properties
@@ -0,0 +1,5 @@
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+android.suppressUnsupportedCompileSdk=35
+kotlin.code.style=official
+org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
diff --git a/mobile/android-app/gradle/wrapper/gradle-wrapper.properties b/mobile/android-app/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..b82aa23
--- /dev/null
+++ b/mobile/android-app/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/mobile/android-app/gradlew b/mobile/android-app/gradlew
new file mode 100755
index 0000000..6ce3803
--- /dev/null
+++ b/mobile/android-app/gradlew
@@ -0,0 +1,60 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROPERTIES_FILE="$APP_DIR/gradle/wrapper/gradle-wrapper.properties"
+GRADLE_USER_HOME="${GRADLE_USER_HOME:-$HOME/.gradle}"
+
+if [[ ! -f "$PROPERTIES_FILE" ]]; then
+ echo "Missing $PROPERTIES_FILE" >&2
+ exit 1
+fi
+
+distribution_url=""
+while IFS='=' read -r key value; do
+ case "$key" in
+ distributionUrl)
+ distribution_url="${value//\\:/:}"
+ ;;
+ esac
+done < "$PROPERTIES_FILE"
+
+if [[ -z "$distribution_url" ]]; then
+ echo "Unable to read distributionUrl from $PROPERTIES_FILE" >&2
+ exit 1
+fi
+
+gradle_version="${distribution_url##*/gradle-}"
+gradle_version="${gradle_version%-bin.zip}"
+gradle_version="${gradle_version%-all.zip}"
+
+dists_dir="$GRADLE_USER_HOME/wrapper/dists/gradle-${gradle_version}-bin"
+
+shopt -s nullglob
+for candidate in "$dists_dir"/*/gradle-"$gradle_version"; do
+ if [[ -x "$candidate/bin/gradle" ]]; then
+ exec "$candidate/bin/gradle" "$@"
+ fi
+done
+
+download_dir="$dists_dir/manual/gradle-$gradle_version"
+if [[ ! -x "$download_dir/bin/gradle" ]]; then
+ mkdir -p "$dists_dir/manual"
+ tmp_zip="$(mktemp)"
+ cleanup() { rm -f "$tmp_zip"; }
+ trap cleanup EXIT
+
+ if command -v curl >/dev/null 2>&1; then
+ curl -fsSL "$distribution_url" -o "$tmp_zip"
+ elif command -v wget >/dev/null 2>&1; then
+ wget -qO "$tmp_zip" "$distribution_url"
+ else
+ echo "curl or wget is required to download Gradle" >&2
+ exit 1
+ fi
+
+ unzip -q "$tmp_zip" -d "$dists_dir/manual"
+fi
+
+exec "$download_dir/bin/gradle" "$@"
diff --git a/mobile/android-app/settings.gradle.kts b/mobile/android-app/settings.gradle.kts
new file mode 100644
index 0000000..ea665ed
--- /dev/null
+++ b/mobile/android-app/settings.gradle.kts
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "hermes-android"
+include(":app")