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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -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}")
|
||||
}
|
||||
}
|
||||
+150
@@ -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)
|
||||
}
|
||||
}
|
||||
+33
@@ -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"
|
||||
}
|
||||
+42
@@ -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()
|
||||
}
|
||||
}
|
||||
+30
@@ -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 },
|
||||
)
|
||||
}
|
||||
+128
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+351
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+131
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
@@ -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>
|
||||
Reference in New Issue
Block a user