complete Android build setup and study flow

Add a real Gradle wrapper, local SDK wiring, and the missing Android study screens so the module builds cleanly.
This commit is contained in:
2026-04-09 16:46:25 +02:00
parent 0cfd847d62
commit 260f27728b
38 changed files with 2286 additions and 0 deletions
+70
View File
@@ -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")
}
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:name=".HermesApplication"
android:allowBackup="true"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Hermes">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.Hermes">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -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<T : ViewModel>(private val creator: () -> T) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = creator() as T
}
@@ -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)
}
}
@@ -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
)
)
}
@@ -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)
}
}
}
}
@@ -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<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}")
}
}
@@ -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) }
}
@@ -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()
}
}
}
@@ -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)
}
}
@@ -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<String> = _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"
}
@@ -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<Boolean> = _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()
}
}
@@ -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 },
)
}
@@ -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<HermesMarket> = 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 <reified T> get(path: String): T = withContext(Dispatchers.IO) {
execute(path = path, method = "GET")
}
private suspend inline fun <reified T> post(path: String): T = withContext(Dispatchers.IO) {
execute(path = path, method = "POST")
}
private suspend inline fun <reified T, reified B> post(path: String, body: B): T = withContext(Dispatchers.IO) {
execute(path = path, method = "POST", bodyJson = json.encodeToString(body))
}
private suspend inline fun <reified B> postNoContent(path: String, body: B) {
withContext(Dispatchers.IO) {
executeNoContent(path = path, method = "POST", bodyJson = json.encodeToString(body))
}
}
private inline fun <reified T> 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")
}
@@ -0,0 +1,3 @@
package com.hermes.study.core.network
// Placeholder for future API-specific mapping helpers.
@@ -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<StudyRound> = roundState.asStateFlow()
fun observeRound(): Flow<StudyRound> = roundState
fun observeFeed(): Flow<StudyRound> = 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)
}
}
@@ -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,
)
}
}
@@ -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<HermesEventMedia>,
val markets: List<HermesMarket>,
)
@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<HermesOutcome>,
)
@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<HermesOutcomeOdds>,
)
@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<HermesAnalyticsAttributeInput>? = null,
)
@Serializable
data class HermesAnalyticsBatchRequest(
val events: List<HermesAnalyticsEventInput>,
)
@Serializable
data class HermesExperimentConfig(
val variant: String,
val featureFlags: Map<String, Boolean>,
)
@Serializable
data class HermesLocalizationBundle(
val localeCode: String,
val values: Map<String, String>,
)
data class StudyRound(
val event: HermesEvent,
val media: HermesEventMedia,
val market: HermesMarket,
val oddsVersion: HermesOddsVersion,
val settlement: HermesSettlement,
)
@@ -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)
}
}
}
@@ -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<FeedUiState> = 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(" / ")
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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,
)
}
}
}
}
@@ -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<SelectionOptionUi>,
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<String?>(null)
private val nowFlow = flow {
while (true) {
emit(Clock.System.now())
delay(1_000)
}
}
private var transitionJob: Job? = null
val uiState: StateFlow<RoundUiState> = 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<SelectionOptionUi> {
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<String, String> {
return mapOf("screen_name" to "round", "outcome_id" to outcomeId)
}
private fun roundAnalyticsAttributes(round: StudyRound): Map<String, String> {
return mapOf(
"screen_name" to "round",
"event_id" to round.event.id,
"market_id" to round.market.id,
)
}
}
@@ -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<SelectionOptionUi>,
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,
)
}
}
}
}
@@ -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,
)
}
}
}
@@ -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)
}
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Hermes" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="android:statusBarColor">@android:color/black</item>
<item name="android:navigationBarColor">@android:color/black</item>
</style>
</resources>
@@ -0,0 +1,57 @@
<?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="common_continue">Fortsätt</string>
<string name="common_cancel">Avbryt</string>
<string name="common_close">Stäng</string>
<string name="common_retry">Försök igen</string>
<string name="common_loading">Laddar</string>
<string name="errors_generic">Försök igen.</string>
<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_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>
<string name="onboarding_start_session">Starta session</string>
<string name="feed_title">Förhandsflöde</string>
<string name="feed_subtitle">Nästa runda är redo att granskas.</string>
<string name="feed_hero_title">Möjlighet till segermål</string>
<string name="feed_hero_subtitle">En kort förhandsvisning leder till ett tydligt val.</string>
<string name="feed_lock_label">Låses om</string>
<string name="feed_odds_label">Liveodds</string>
<string name="feed_cta">Titta på förhandsklipp</string>
<string name="round_title">Aktiv runda</string>
<string name="round_subtitle">Gör ditt val före låsning och vänta sedan på avslöjandet.</string>
<string name="round_video_placeholder">Videoförhandsvisning</string>
<string name="round_preview_label">Förhandsvisning spelas</string>
<string name="round_countdown_label">Låsning om</string>
<string name="round_odds_label">Odds</string>
<string name="round_selection_prompt">Välj ett utfall.</string>
<string name="round_primary_cta">Bekräfta valet</string>
<string name="round_locked_label">Låst</string>
<string name="round_home">Hemma</string>
<string name="round_away">Borta</string>
<string name="reveal_title">Avslöjande</string>
<string name="reveal_subtitle">Klippet visar nu utfallet.</string>
<string name="reveal_status">Avslöjningssegmentet spelas upp.</string>
<string name="reveal_cta">Se resultat</string>
<string name="result_title">Resultat</string>
<string name="result_subtitle">Ditt val jämfört med det faktiska utfallet.</string>
<string name="result_selection_label">Ditt val</string>
<string name="result_outcome_label">Utfall</string>
<string name="result_win">Vinnande val</string>
<string name="result_lose">Inte denna gång</string>
<string name="result_next_round">Nästa runda</string>
<string name="settings_title">Inställningar</string>
<string name="settings_subtitle">Språk-, haptik- och analysinställningar.</string>
<string name="settings_language">Språk</string>
<string name="settings_enabled">Aktiverad</string>
<string name="settings_haptics">Haptik</string>
<string name="settings_analytics">Analys</string>
<string name="session_title">Session</string>
<string name="session_subtitle">Sessionssynk och livscykelkontroller.</string>
<string name="session_note">Sessionsstatus visas här när backend-sessionen har startat.</string>
</resources>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="hermes_background">#0A0D14</color>
<color name="hermes_surface">#171B24</color>
<color name="hermes_surface_elevated">#222838</color>
<color name="hermes_accent">#DFBF5B</color>
<color name="hermes_positive">#56C28F</color>
<color name="hermes_warning">#F0B14C</color>
<color name="hermes_text_primary">#FFFFFF</color>
<color name="hermes_text_secondary">#B9C0D0</color>
</resources>
@@ -0,0 +1,57 @@
<?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="common_continue">Continue</string>
<string name="common_cancel">Cancel</string>
<string name="common_close">Close</string>
<string name="common_retry">Retry</string>
<string name="common_loading">Loading</string>
<string name="errors_generic">Please try again.</string>
<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_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>
<string name="onboarding_start_session">Start session</string>
<string name="feed_title">Preview feed</string>
<string name="feed_subtitle">The next round is ready for review.</string>
<string name="feed_hero_title">Late winner chance</string>
<string name="feed_hero_subtitle">A short preview leads into a single, clear choice.</string>
<string name="feed_lock_label">Locks in</string>
<string name="feed_odds_label">Live odds</string>
<string name="feed_cta">Watch preview</string>
<string name="round_title">Active round</string>
<string name="round_subtitle">Make your choice before lock, then wait for the reveal.</string>
<string name="round_video_placeholder">Video preview</string>
<string name="round_preview_label">Preview playing</string>
<string name="round_countdown_label">Lock in</string>
<string name="round_odds_label">Odds</string>
<string name="round_selection_prompt">Choose an outcome.</string>
<string name="round_primary_cta">Confirm selection</string>
<string name="round_locked_label">Locked</string>
<string name="round_home">Home</string>
<string name="round_away">Away</string>
<string name="reveal_title">Reveal</string>
<string name="reveal_subtitle">The clip is now showing the outcome.</string>
<string name="reveal_status">Reveal segment is playing.</string>
<string name="reveal_cta">See result</string>
<string name="result_title">Result</string>
<string name="result_subtitle">Your selection versus the actual outcome.</string>
<string name="result_selection_label">Your selection</string>
<string name="result_outcome_label">Outcome</string>
<string name="result_win">Winning selection</string>
<string name="result_lose">Not this time</string>
<string name="result_next_round">Next round</string>
<string name="settings_title">Settings</string>
<string name="settings_subtitle">Language, haptics and analytics preferences.</string>
<string name="settings_language">Language</string>
<string name="settings_enabled">Enabled</string>
<string name="settings_haptics">Haptics</string>
<string name="settings_analytics">Analytics</string>
<string name="session_title">Session</string>
<string name="session_subtitle">Session sync and lifecycle controls.</string>
<string name="session_note">Session state will appear here once the backend session is started.</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Hermes" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="android:statusBarColor">@android:color/black</item>
<item name="android:navigationBarColor">@android:color/black</item>
</style>
</resources>
+6
View File
@@ -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
}
+5
View File
@@ -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
@@ -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
Vendored Executable
+60
View File
@@ -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" "$@"
+18
View File
@@ -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")