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