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:
@@ -1,2 +1,4 @@
|
|||||||
target/
|
target/
|
||||||
build/
|
build/
|
||||||
|
.gradle/
|
||||||
|
local.properties
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+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>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
+60
@@ -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" "$@"
|
||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user