diff --git a/PLAN.md b/PLAN.md index 682dbab..db89137 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,5 +1,27 @@ # Project Template: Native Betting Study App +This is the canonical working plan for the project. Use this file for progress notes, next steps, and status updates. + +## Current Status + +### Done So Far + +- Android was renamed from the old study app into `com.hermes.app`, with the app entry, theme, repository, media, and feature screens rewritten around the backend-backed Hermes flow. +- Android no longer relies on the sample fixture; it boots from backend session and round data, uses `/health.server_time` for clock sync, buffers analytics, and flushes them to the backend. +- Android localization is in place for English and Swedish, including the remaining locale toggle labels and session language display. +- Backend now exposes `server_time` from `/health`, publishes the matching OpenAPI contract, and records audit events for session, bet, event, market, odds, and settlement actions. +- Backend smoke coverage was added for `server_time` and audit logging. +- iOS now follows the backend-backed flow, syncs clock from `/health`, flushes analytics, and uses localized language labels instead of hardcoded `EN` and `SV`. +- iOS source was updated for the backend-backed session and round flow, including the real preview cue points and localized session strings. +- Android debug build passes with `./gradlew :app:assembleDebug`. +- Backend tests pass with `cargo test`. + +### Still Open + +- Continue through the remaining plan phases and finish any leftover localization and polish work. +- Add iOS build validation once an Xcode project is available in `mobile/ios-app`. +- Keep expanding tests around session, odds, settlement, and analytics behavior. + ## 1. Purpose Build a native mobile prototype for iOS and Android for a research diff --git a/PROGRESS.md b/PROGRESS.md new file mode 100644 index 0000000..141ca40 --- /dev/null +++ b/PROGRESS.md @@ -0,0 +1,23 @@ +# Hermes Progress + +## Done + +- Android: switched the app to the Hermes package/name, backed the UI with the backend, added server clock sync, and buffered analytics flushing. +- Android: localized the remaining language labels so the locale toggle and session language row no longer show hardcoded `EN`/`SV`. +- Backend: added `server_time` to `/health`, added audit logging, and added smoke coverage. +- iOS: switched the source to the backend-backed flow, added clock sync, analytics flushing, and localized the remaining language labels. +- Verified Android with `./gradlew :app:assembleDebug`. +- Verified backend earlier with `cargo test`. + +## Next + +1. Continue from the current plan phase and keep closing out the remaining localization and polish items. +2. Keep Android and iOS behavior aligned with the backend contract. +3. Add or verify iOS build coverage if an Xcode project becomes available. +4. Continue expanding tests around session, odds, settlement, and analytics flow as the app grows. + +## Notes + +- English and Swedish are required from day one. +- The server remains authoritative for lock time, odds acceptance, and settlement. +- iOS build validation is still unavailable here because there is no Xcode project in `mobile/ios-app`. diff --git a/backend/src/app_state.rs b/backend/src/app_state.rs index 3ae56d6..133da0d 100644 --- a/backend/src/app_state.rs +++ b/backend/src/app_state.rs @@ -16,6 +16,7 @@ pub use crate::sessions::{SessionSnapshot, SessionStartRequest}; use crate::{ analytics::AnalyticsStore, + audit::AuditStore, bets::{ BetsStore, ACCEPTANCE_ACCEPTED, ACCEPTANCE_REJECTED_INVALID_MARKET, ACCEPTANCE_REJECTED_INVALID_SESSION, ACCEPTANCE_REJECTED_TOO_LATE, @@ -43,6 +44,7 @@ pub struct AppState { experiments: ExperimentsStore, localization: LocalizationStore, analytics: AnalyticsStore, + audit: AuditStore, markets: MarketsStore, users: UsersStore, sessions: SessionsStore, @@ -65,6 +67,7 @@ impl AppState { experiments: ExperimentsStore::new(), localization: LocalizationStore::new(), analytics: AnalyticsStore::new(), + audit: AuditStore::new(), markets: MarketsStore::new(), users: UsersStore::new(), sessions: SessionsStore::new(), @@ -87,7 +90,8 @@ impl AppState { }) .await; - self.sessions + let session = self + .sessions .start_session( user.id, request, @@ -98,7 +102,24 @@ impl AppState { default_experiment_variant: self.config.default_experiment_variant.clone(), }, ) - .await + .await; + + self.audit + .record( + "session_started", + Some(session.session_id), + Some(session.user_id), + Utc::now(), + [ + ("locale_code", session.locale_code.clone()), + ("device_platform", session.device_platform.clone()), + ("app_version", session.app_version.clone()), + ("experiment_variant", session.experiment_variant.clone()), + ], + ) + .await; + + session } pub async fn current_session(&self) -> Option { @@ -129,6 +150,40 @@ impl AppState { } } + async fn record_bet_audit( + &self, + action: &str, + session: Option<&SessionSnapshot>, + request: &BetIntentRequest, + reason: &str, + accepted_odds_version_id: Option, + ) { + let mut attributes = vec![ + ("reason".to_string(), reason.to_string()), + ("session_id".to_string(), request.session_id.to_string()), + ("event_id".to_string(), request.event_id.to_string()), + ("market_id".to_string(), request.market_id.to_string()), + ("outcome_id".to_string(), request.outcome_id.to_string()), + ]; + + if let Some(accepted_odds_version_id) = accepted_odds_version_id { + attributes.push(( + "accepted_odds_version_id".to_string(), + accepted_odds_version_id.to_string(), + )); + } + + self.audit + .record( + action, + session.map(|session| session.session_id), + session.map(|session| session.user_id), + Utc::now(), + attributes, + ) + .await; + } + pub async fn submit_bet_intent(&self, request: BetIntentRequest) -> BetIntentResponse { let Some(existing) = self .bets @@ -143,7 +198,7 @@ impl AppState { async fn submit_bet_intent_fresh(&self, request: BetIntentRequest) -> BetIntentResponse { let Some(session) = self.current_session().await else { - return self + let response = self .bets .record(self.build_bet_intent_record( &request, @@ -153,10 +208,21 @@ impl AppState { None, )) .await; + + self.record_bet_audit( + "bet_rejected", + None, + &request, + ACCEPTANCE_REJECTED_INVALID_SESSION, + None, + ) + .await; + + return response; }; if session.ended_at.is_some() || session.session_id != request.session_id { - return self + let response = self .bets .record(self.build_bet_intent_record( &request, @@ -166,10 +232,21 @@ impl AppState { None, )) .await; + + self.record_bet_audit( + "bet_rejected", + Some(&session), + &request, + ACCEPTANCE_REJECTED_INVALID_SESSION, + None, + ) + .await; + + return response; } let Some(market) = self.markets.market(request.market_id).await else { - return self + let response = self .bets .record(self.build_bet_intent_record( &request, @@ -179,10 +256,21 @@ impl AppState { None, )) .await; + + self.record_bet_audit( + "bet_rejected", + Some(&session), + &request, + ACCEPTANCE_REJECTED_INVALID_MARKET, + None, + ) + .await; + + return response; }; if market.event_id != request.event_id { - return self + let response = self .bets .record(self.build_bet_intent_record( &request, @@ -192,10 +280,21 @@ impl AppState { None, )) .await; + + self.record_bet_audit( + "bet_rejected", + Some(&session), + &request, + ACCEPTANCE_REJECTED_INVALID_MARKET, + None, + ) + .await; + + return response; } let Some(outcome) = self.markets.outcome(request.outcome_id).await else { - return self + let response = self .bets .record(self.build_bet_intent_record( &request, @@ -205,10 +304,21 @@ impl AppState { None, )) .await; + + self.record_bet_audit( + "bet_rejected", + Some(&session), + &request, + ACCEPTANCE_REJECTED_INVALID_MARKET, + None, + ) + .await; + + return response; }; if outcome.market_id != market.id || request.event_id != market.event_id { - return self + let response = self .bets .record(self.build_bet_intent_record( &request, @@ -218,10 +328,21 @@ impl AppState { None, )) .await; + + self.record_bet_audit( + "bet_rejected", + Some(&session), + &request, + ACCEPTANCE_REJECTED_INVALID_MARKET, + None, + ) + .await; + + return response; } if Utc::now() >= market.lock_at { - return self + let response = self .bets .record(self.build_bet_intent_record( &request, @@ -231,10 +352,21 @@ impl AppState { None, )) .await; + + self.record_bet_audit( + "bet_rejected", + Some(&session), + &request, + ACCEPTANCE_REJECTED_TOO_LATE, + None, + ) + .await; + + return response; } let accepted_odds_version_id = self.current_odds(market.id).await.map(|odds| odds.id); - self.bets + let response = self.bets .record(self.build_bet_intent_record( &request, Some(session.user_id), @@ -242,7 +374,18 @@ impl AppState { ACCEPTANCE_ACCEPTED, accepted_odds_version_id, )) - .await + .await; + + self.record_bet_audit( + "bet_accepted", + Some(&session), + &request, + ACCEPTANCE_ACCEPTED, + accepted_odds_version_id, + ) + .await; + + response } pub async fn bet_intent(&self, bet_intent_id: Uuid) -> Option { @@ -250,10 +393,28 @@ impl AppState { } pub async fn end_session(&self) -> Result { - self.sessions + let session = self + .sessions .end_session() .await - .map_err(|_| AppError::not_found("No active session")) + .map_err(|_| AppError::not_found("No active session"))?; + + self.audit + .record( + "session_ended", + Some(session.session_id), + Some(session.user_id), + Utc::now(), + [ + ("locale_code", session.locale_code.clone()), + ("device_platform", session.device_platform.clone()), + ("app_version", session.app_version.clone()), + ("experiment_variant", session.experiment_variant.clone()), + ], + ) + .await; + + Ok(session) } pub async fn admin_create_event_manifest( @@ -266,6 +427,20 @@ impl AppState { self.markets.insert_market(market.clone()).await; } + self.audit + .record( + "event_created", + None, + None, + Utc::now(), + [ + ("event_id", created.event.id.to_string()), + ("sport_type", created.event.sport_type.clone()), + ("status", created.event.status.clone()), + ], + ) + .await; + Ok(created) } @@ -279,6 +454,22 @@ impl AppState { self.markets.insert_market(market.clone()).await; let _ = self.events.insert_market(market.event_id, market.clone()).await; + + self.audit + .record( + "market_created", + None, + None, + Utc::now(), + [ + ("market_id", market.id.to_string()), + ("event_id", market.event_id.to_string()), + ("market_type", market.market_type.clone()), + ("status", market.status.clone()), + ], + ) + .await; + Ok(market) } @@ -290,7 +481,23 @@ impl AppState { return Err(AppError::not_found("Market not found")); }; - Ok(self.markets.publish_odds(odds).await) + let created = self.markets.publish_odds(odds).await; + + self.audit + .record( + "odds_version_published", + None, + None, + Utc::now(), + [ + ("odds_version_id", created.id.to_string()), + ("market_id", created.market_id.to_string()), + ("version_no", created.version_no.to_string()), + ], + ) + .await; + + Ok(created) } pub async fn admin_publish_settlement( @@ -301,7 +508,24 @@ impl AppState { return Err(AppError::not_found("Market not found")); }; - Ok(self.settlements.upsert(market.event_id, settlement).await) + let created = self.settlements.upsert(market.event_id, settlement).await; + + self.audit + .record( + "result_settled", + None, + None, + Utc::now(), + [ + ("settlement_id", created.id.to_string()), + ("market_id", created.market_id.to_string()), + ("event_id", market.event_id.to_string()), + ("winning_outcome_id", created.winning_outcome_id.to_string()), + ], + ) + .await; + + Ok(created) } pub async fn next_event(&self) -> Option { @@ -362,6 +586,10 @@ impl AppState { self.analytics.counts().await } + pub async fn audit_counts(&self) -> (usize, usize) { + self.audit.counts().await + } + pub async fn markets_for_event(&self, event_id: Uuid) -> Option> { self.markets.markets_for_event(event_id).await } diff --git a/backend/src/audit/mod.rs b/backend/src/audit/mod.rs new file mode 100644 index 0000000..d3f7820 --- /dev/null +++ b/backend/src/audit/mod.rs @@ -0,0 +1,87 @@ +use std::{collections::HashMap, sync::Arc}; + +use chrono::{DateTime, Utc}; +use tokio::sync::RwLock; +use uuid::Uuid; + +#[derive(Clone)] +pub struct AuditStore { + inner: Arc>, +} + +#[derive(Default)] +struct AuditState { + event_types_by_name: HashMap, + events: Vec, + attributes: Vec, +} + +#[derive(Clone)] +#[allow(dead_code)] +struct AuditEventSnapshot { + id: Uuid, + audit_event_type_id: Uuid, + session_id: Option, + user_id: Option, + occurred_at: DateTime, +} + +#[derive(Clone)] +#[allow(dead_code)] +struct AuditEventAttributeSnapshot { + id: Uuid, + audit_event_id: Uuid, + attribute_key: String, + attribute_value: String, +} + +impl AuditStore { + pub fn new() -> Self { + Self { + inner: Arc::new(RwLock::new(AuditState::default())), + } + } + + pub async fn record( + &self, + action: impl Into, + session_id: Option, + user_id: Option, + occurred_at: DateTime, + attributes: I, + ) where + I: IntoIterator, + K: Into, + V: Into, + { + let mut state = self.inner.write().await; + let action = action.into(); + let event_type_id = *state + .event_types_by_name + .entry(action) + .or_insert_with(Uuid::new_v4); + + let audit_event_id = Uuid::new_v4(); + state.events.push(AuditEventSnapshot { + id: audit_event_id, + audit_event_type_id: event_type_id, + session_id, + user_id, + occurred_at, + }); + + for (key, value) in attributes { + state.attributes.push(AuditEventAttributeSnapshot { + id: Uuid::new_v4(), + audit_event_id, + attribute_key: key.into(), + attribute_value: value.into(), + }); + } + } + + pub async fn counts(&self) -> (usize, usize) { + let state = self.inner.read().await; + (state.events.len(), state.attributes.len()) + } +} diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 88d6d62..f79b20f 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -1,6 +1,7 @@ #![forbid(unsafe_code)] pub mod analytics; +pub mod audit; pub mod admin; pub mod bets; pub mod app_state; diff --git a/backend/src/routes/health.rs b/backend/src/routes/health.rs index 7db59f0..899a9a1 100644 --- a/backend/src/routes/health.rs +++ b/backend/src/routes/health.rs @@ -1,4 +1,5 @@ use axum::{extract::Extension, Json}; +use chrono::{DateTime, Utc}; use serde::Serialize; use crate::app_state::AppState; @@ -10,6 +11,7 @@ pub struct HealthResponse { pub environment: String, pub version: String, pub uptime_ms: u128, + pub server_time: DateTime, pub database_ready: bool, pub redis_ready: bool, } @@ -21,6 +23,7 @@ pub async fn handler(Extension(state): Extension) -> Json= 2); + assert!(audit_attributes >= 10); } #[tokio::test] diff --git a/contracts/openapi/openapi.yaml b/contracts/openapi/openapi.yaml index dd3c11b..3e8f5a7 100644 --- a/contracts/openapi/openapi.yaml +++ b/contracts/openapi/openapi.yaml @@ -259,7 +259,7 @@ components: schemas: HealthResponse: type: object - required: [status, service_name, environment, version, uptime_ms, database_ready, redis_ready] + required: [status, service_name, environment, version, uptime_ms, server_time, database_ready, redis_ready] properties: status: type: string @@ -271,6 +271,9 @@ components: type: string uptime_ms: type: integer + server_time: + type: string + format: date-time database_ready: type: boolean redis_ready: diff --git a/mobile/android-app/app/build.gradle.kts b/mobile/android-app/app/build.gradle.kts index 15af2c8..33a7296 100644 --- a/mobile/android-app/app/build.gradle.kts +++ b/mobile/android-app/app/build.gradle.kts @@ -6,11 +6,11 @@ plugins { } android { - namespace = "com.hermes.study" + namespace = "com.hermes.app" compileSdk = 35 defaultConfig { - applicationId = "com.hermes.study" + applicationId = "com.hermes.app" minSdk = 26 targetSdk = 35 versionCode = 1 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/app/HermesApp.kt similarity index 77% rename from mobile/android-app/app/src/main/java/com/hermes/study/HermesStudyApp.kt rename to mobile/android-app/app/src/main/java/com/hermes/app/HermesApp.kt index 2f7338f..ee6417d 100644 --- a/mobile/android-app/app/src/main/java/com/hermes/study/HermesStudyApp.kt +++ b/mobile/android-app/app/src/main/java/com/hermes/app/HermesApp.kt @@ -1,4 +1,4 @@ -package com.hermes.study +package com.hermes.app import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -21,21 +21,22 @@ 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.session.SessionViewModel -import com.hermes.study.feature.settings.SettingsView -import com.hermes.study.feature.round.RoundScreen -import com.hermes.study.feature.round.RoundViewModel +import com.hermes.app.R +import com.hermes.app.core.designsystem.HermesPalette +import com.hermes.app.core.designsystem.HermesSecondaryButton +import com.hermes.app.core.designsystem.HermesTheme +import com.hermes.app.core.localization.HermesLocalizationStore +import com.hermes.app.feature.feed.FeedScreen +import com.hermes.app.feature.feed.FeedViewModel +import com.hermes.app.feature.session.SessionView +import com.hermes.app.feature.session.SessionViewModel +import com.hermes.app.feature.settings.SettingsView +import com.hermes.app.feature.round.RoundScreen +import com.hermes.app.feature.round.RoundViewModel import androidx.compose.material3.ExperimentalMaterial3Api @Composable -fun HermesStudyApp(container: HermesAppContainer) { +fun HermesApp(container: HermesAppContainer) { val feedViewModel: FeedViewModel = viewModel(factory = container.feedViewModelFactory()) val roundViewModel: RoundViewModel = viewModel(factory = container.roundViewModelFactory()) val sessionViewModel: SessionViewModel = viewModel(factory = container.sessionViewModelFactory()) @@ -53,6 +54,7 @@ fun HermesStudyApp(container: HermesAppContainer) { HermesAppBar( title = container.localizationStore.string(localeCode, R.string.app_name), localeCode = localeCode, + localizationStore = container.localizationStore, onLocaleSelected = container.localizationStore::setLocale, ) }, @@ -105,14 +107,23 @@ fun HermesStudyApp(container: HermesAppContainer) { private fun HermesAppBar( title: String, localeCode: String, + localizationStore: HermesLocalizationStore, onLocaleSelected: (String) -> Unit, ) { androidx.compose.material3.CenterAlignedTopAppBar( title = { Text(text = title) }, actions = { - HermesSecondaryButton(text = "EN", selected = localeCode == "en", onClick = { onLocaleSelected("en") }) + HermesSecondaryButton( + text = localizationStore.localeName("en", localeCode), + 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") }) + HermesSecondaryButton( + text = localizationStore.localeName("sv", localeCode), + 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/app/HermesAppContainer.kt b/mobile/android-app/app/src/main/java/com/hermes/app/HermesAppContainer.kt new file mode 100644 index 0000000..001b9c6 --- /dev/null +++ b/mobile/android-app/app/src/main/java/com/hermes/app/HermesAppContainer.kt @@ -0,0 +1,101 @@ +package com.hermes.app + +import android.content.Context +import android.os.Build +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.hermes.app.core.analytics.HermesAnalyticsTracker +import com.hermes.app.core.localization.HermesLocalizationStore +import com.hermes.app.core.media.HermesPlayerCoordinator +import com.hermes.app.core.network.HermesApiClient +import com.hermes.app.data.HermesRepository +import com.hermes.app.domain.HermesSessionStartRequest +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +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) + private val analyticsScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val analyticsFlushMutex = Mutex() + private val analyticsSessionJob: Job + private val analyticsTickerJob: Job + + init { + analyticsSessionJob = analyticsScope.launch { + repository.currentSession.filterNotNull().collect { + flushAnalytics() + } + } + + analyticsTickerJob = analyticsScope.launch { + while (isActive) { + delay(5_000) + flushAnalytics() + } + } + } + + fun feedViewModelFactory(): ViewModelProvider.Factory = HermesViewModelFactory { + com.hermes.app.feature.feed.FeedViewModel(repository, localizationStore, analyticsTracker) + } + + fun roundViewModelFactory(): ViewModelProvider.Factory = HermesViewModelFactory { + com.hermes.app.feature.round.RoundViewModel(repository, localizationStore, analyticsTracker, playerCoordinator) + } + + fun sessionViewModelFactory(): ViewModelProvider.Factory = HermesViewModelFactory { + com.hermes.app.feature.session.SessionViewModel( + repository = repository, + localizationStore = localizationStore, + analyticsTracker = analyticsTracker, + sessionRequestFactory = ::buildSessionStartRequest, + ) + } + + private fun buildSessionStartRequest(localeCode: String): HermesSessionStartRequest { + return HermesSessionStartRequest( + localeCode = localeCode, + devicePlatform = "android", + deviceModel = Build.MODEL, + osVersion = Build.VERSION.RELEASE, + appVersion = BuildConfig.VERSION_NAME, + ) + } + + suspend fun flushAnalytics() { + analyticsFlushMutex.withLock { + if (repository.currentSession.value == null) { + return + } + + val events = analyticsTracker.pendingEventsSnapshot() + if (events.isEmpty()) { + return + } + + runCatching { + repository.submitAnalyticsBatch(analyticsTracker.toBatchRequest(events)) + }.onSuccess { + analyticsTracker.markDelivered(events.map { it.id }.toSet()) + } + } + } +} + +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/app/HermesApplication.kt similarity index 91% rename from mobile/android-app/app/src/main/java/com/hermes/study/HermesApplication.kt rename to mobile/android-app/app/src/main/java/com/hermes/app/HermesApplication.kt index 914d0b2..76313fc 100644 --- a/mobile/android-app/app/src/main/java/com/hermes/study/HermesApplication.kt +++ b/mobile/android-app/app/src/main/java/com/hermes/app/HermesApplication.kt @@ -1,4 +1,4 @@ -package com.hermes.study +package com.hermes.app import android.app.Application 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/app/MainActivity.kt similarity index 69% rename from mobile/android-app/app/src/main/java/com/hermes/study/MainActivity.kt rename to mobile/android-app/app/src/main/java/com/hermes/app/MainActivity.kt index 54b15f2..301fd57 100644 --- a/mobile/android-app/app/src/main/java/com/hermes/study/MainActivity.kt +++ b/mobile/android-app/app/src/main/java/com/hermes/app/MainActivity.kt @@ -1,9 +1,9 @@ -package com.hermes.study +package com.hermes.app import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import com.hermes.study.core.designsystem.HermesStudyTheme +import com.hermes.app.core.designsystem.HermesTheme class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -12,8 +12,8 @@ class MainActivity : ComponentActivity() { val app = application as HermesApplication setContent { - HermesStudyTheme { - HermesStudyApp(app.container) + HermesTheme { + HermesApp(app.container) } } } diff --git a/mobile/android-app/app/src/main/java/com/hermes/app/core/analytics/HermesAnalyticsTracker.kt b/mobile/android-app/app/src/main/java/com/hermes/app/core/analytics/HermesAnalyticsTracker.kt new file mode 100644 index 0000000..9ad130a --- /dev/null +++ b/mobile/android-app/app/src/main/java/com/hermes/app/core/analytics/HermesAnalyticsTracker.kt @@ -0,0 +1,58 @@ +package com.hermes.app.core.analytics + +import android.util.Log +import com.hermes.app.domain.HermesAnalyticsAttributeInput +import com.hermes.app.domain.HermesAnalyticsBatchRequest +import com.hermes.app.domain.HermesAnalyticsEventInput +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import java.util.UUID + +data class HermesTrackedEvent( + val id: UUID, + val name: String, + val attributes: Map, + val timestamp: Instant, +) + +class HermesAnalyticsTracker { + private val eventsFlow = MutableSharedFlow(replay = 64, extraBufferCapacity = 64) + private val pendingEvents = mutableListOf() + + val events: SharedFlow = eventsFlow.asSharedFlow() + + @Synchronized + fun track(name: String, attributes: Map = emptyMap()) { + val event = HermesTrackedEvent(UUID.randomUUID(), name, attributes, Clock.System.now()) + pendingEvents += event + eventsFlow.tryEmit(event) + Log.d("HermesAnalytics", "${event.name} ${event.attributes}") + } + + @Synchronized + fun pendingEventsSnapshot(): List { + return pendingEvents.toList() + } + + @Synchronized + fun markDelivered(deliveredIds: Set) { + pendingEvents.removeAll { it.id in deliveredIds } + } + + fun toBatchRequest(events: List): HermesAnalyticsBatchRequest { + return HermesAnalyticsBatchRequest( + events = events.map { event -> + HermesAnalyticsEventInput( + eventName = event.name, + occurredAt = event.timestamp, + attributes = event.attributes.map { (key, value) -> + HermesAnalyticsAttributeInput(key = key, value = value) + }, + ) + }, + ) + } +} 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/app/core/designsystem/HermesTheme.kt similarity index 98% rename from mobile/android-app/app/src/main/java/com/hermes/study/core/designsystem/HermesTheme.kt rename to mobile/android-app/app/src/main/java/com/hermes/app/core/designsystem/HermesTheme.kt index 591ada4..eb83414 100644 --- 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/app/core/designsystem/HermesTheme.kt @@ -1,4 +1,4 @@ -package com.hermes.study.core.designsystem +package com.hermes.app.core.designsystem import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -57,7 +57,7 @@ private val HermesShapes = androidx.compose.material3.Shapes( ) @Composable -fun HermesStudyTheme(content: @Composable () -> Unit) { +fun HermesTheme(content: @Composable () -> Unit) { MaterialTheme( colorScheme = HermesColorScheme, typography = MaterialTheme.typography, diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/core/errors/HermesErrorMapper.kt b/mobile/android-app/app/src/main/java/com/hermes/app/core/errors/HermesErrorMapper.kt similarity index 75% rename from mobile/android-app/app/src/main/java/com/hermes/study/core/errors/HermesErrorMapper.kt rename to mobile/android-app/app/src/main/java/com/hermes/app/core/errors/HermesErrorMapper.kt index dbd1eaa..64caf31 100644 --- a/mobile/android-app/app/src/main/java/com/hermes/study/core/errors/HermesErrorMapper.kt +++ b/mobile/android-app/app/src/main/java/com/hermes/app/core/errors/HermesErrorMapper.kt @@ -1,8 +1,8 @@ -package com.hermes.study.core.errors +package com.hermes.app.core.errors -import com.hermes.study.R -import com.hermes.study.core.localization.HermesLocalizationStore -import com.hermes.study.core.network.HermesApiException +import com.hermes.app.R +import com.hermes.app.core.localization.HermesLocalizationStore +import com.hermes.app.core.network.HermesApiException import java.io.IOException import kotlinx.coroutines.CancellationException 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/app/core/gestures/HermesGestures.kt similarity index 90% rename from mobile/android-app/app/src/main/java/com/hermes/study/core/gestures/HermesGestures.kt rename to mobile/android-app/app/src/main/java/com/hermes/app/core/gestures/HermesGestures.kt index 9ab7794..6fa7784 100644 --- 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/app/core/gestures/HermesGestures.kt @@ -1,4 +1,4 @@ -package com.hermes.study.core.gestures +package com.hermes.app.core.gestures import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.ui.Modifier 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/app/core/haptics/HermesHaptics.kt similarity index 89% rename from mobile/android-app/app/src/main/java/com/hermes/study/core/haptics/HermesHaptics.kt rename to mobile/android-app/app/src/main/java/com/hermes/app/core/haptics/HermesHaptics.kt index 0c66efe..8ea672e 100644 --- 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/app/core/haptics/HermesHaptics.kt @@ -1,4 +1,4 @@ -package com.hermes.study.core.haptics +package com.hermes.app.core.haptics import android.view.HapticFeedbackConstants import android.view.View 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/app/core/localization/HermesLocalizationStore.kt similarity index 77% rename from mobile/android-app/app/src/main/java/com/hermes/study/core/localization/HermesLocalizationStore.kt rename to mobile/android-app/app/src/main/java/com/hermes/app/core/localization/HermesLocalizationStore.kt index 8419d65..29e18f3 100644 --- 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/app/core/localization/HermesLocalizationStore.kt @@ -1,7 +1,8 @@ -package com.hermes.study.core.localization +package com.hermes.app.core.localization import android.content.Context import android.content.res.Configuration +import com.hermes.app.R import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -21,6 +22,15 @@ class HermesLocalizationStore(private val appContext: Context) { fun string(localeCode: String, resId: Int): String = localizedContext(normalize(localeCode)).getString(resId) + fun localeName(targetLocaleCode: String, displayLocaleCode: String = localeCode.value): String { + val resId = when (normalize(targetLocaleCode)) { + "sv" -> R.string.locale_swedish + else -> R.string.locale_english + } + + return string(displayLocaleCode, resId) + } + private fun localizedContext(localeCode: String): Context { val configuration = Configuration(appContext.resources.configuration) configuration.setLocale(Locale.forLanguageTag(localeCode)) 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/app/core/media/HermesPlayerCoordinator.kt similarity index 96% rename from mobile/android-app/app/src/main/java/com/hermes/study/core/media/HermesPlayerCoordinator.kt rename to mobile/android-app/app/src/main/java/com/hermes/app/core/media/HermesPlayerCoordinator.kt index ff5afe9..9e0164f 100644 --- 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/app/core/media/HermesPlayerCoordinator.kt @@ -1,4 +1,4 @@ -package com.hermes.study.core.media +package com.hermes.app.core.media import android.content.Context import android.net.Uri 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/app/core/media/HermesVideoPlayerView.kt similarity index 92% rename from mobile/android-app/app/src/main/java/com/hermes/study/core/media/StudyVideoPlayerView.kt rename to mobile/android-app/app/src/main/java/com/hermes/app/core/media/HermesVideoPlayerView.kt index a997cff..3582f1c 100644 --- 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/app/core/media/HermesVideoPlayerView.kt @@ -1,4 +1,4 @@ -package com.hermes.study.core.media +package com.hermes.app.core.media import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -7,7 +7,7 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.media3.ui.PlayerView @Composable -fun StudyVideoPlayerView( +fun HermesVideoPlayerView( coordinator: HermesPlayerCoordinator, modifier: Modifier = Modifier, ) { 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/app/core/network/HermesApiClient.kt similarity index 84% rename from mobile/android-app/app/src/main/java/com/hermes/study/core/network/HermesApiClient.kt rename to mobile/android-app/app/src/main/java/com/hermes/app/core/network/HermesApiClient.kt index 6818376..15dfcc1 100644 --- 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/app/core/network/HermesApiClient.kt @@ -1,16 +1,18 @@ -package com.hermes.study.core.network +package com.hermes.app.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 com.hermes.app.domain.HermesAnalyticsBatchRequest +import com.hermes.app.domain.HermesBetIntentRequest +import com.hermes.app.domain.HermesBetIntentResponse +import com.hermes.app.domain.HermesHealthResponse +import com.hermes.app.domain.HermesEvent +import com.hermes.app.domain.HermesEventManifest +import com.hermes.app.domain.HermesExperimentConfig +import com.hermes.app.domain.HermesLocalizationBundle +import com.hermes.app.domain.HermesMarket +import com.hermes.app.domain.HermesOddsVersion +import com.hermes.app.domain.HermesSessionResponse +import com.hermes.app.domain.HermesSessionStartRequest +import com.hermes.app.domain.HermesSettlement import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.encodeToString @@ -37,13 +39,15 @@ class HermesApiClient( suspend fun startSession(request: HermesSessionStartRequest): HermesSessionResponse = post("api/v1/session/start", request) + suspend fun health(): HermesHealthResponse = get("health") + 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 eventManifest(eventId: String): HermesEventManifest = get("api/v1/events/$eventId/manifest") suspend fun markets(eventId: String): List = get("api/v1/events/$eventId/markets") 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/app/core/network/HermesApiModels.kt similarity index 60% rename from mobile/android-app/app/src/main/java/com/hermes/study/core/network/HermesApiModels.kt rename to mobile/android-app/app/src/main/java/com/hermes/app/core/network/HermesApiModels.kt index 2507eeb..8bb6fa9 100644 --- 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/app/core/network/HermesApiModels.kt @@ -1,3 +1,3 @@ -package com.hermes.study.core.network +package com.hermes.app.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/app/data/HermesRepository.kt similarity index 65% rename from mobile/android-app/app/src/main/java/com/hermes/study/data/HermesRepository.kt rename to mobile/android-app/app/src/main/java/com/hermes/app/data/HermesRepository.kt index 90b00b1..8eb47a1 100644 --- a/mobile/android-app/app/src/main/java/com/hermes/study/data/HermesRepository.kt +++ b/mobile/android-app/app/src/main/java/com/hermes/app/data/HermesRepository.kt @@ -1,17 +1,19 @@ -package com.hermes.study.data +package com.hermes.app.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.HermesSessionStartRequest -import com.hermes.study.domain.HermesSettlement -import com.hermes.study.domain.StudyRound +import com.hermes.app.core.network.HermesApiClient +import com.hermes.app.domain.HermesAnalyticsBatchRequest +import com.hermes.app.domain.HermesBetIntentRequest +import com.hermes.app.domain.HermesBetIntentResponse +import com.hermes.app.domain.HermesExperimentConfig +import com.hermes.app.domain.HermesLocalizationBundle +import com.hermes.app.domain.HermesMarket +import com.hermes.app.domain.HermesOddsVersion +import com.hermes.app.domain.HermesRound +import com.hermes.app.domain.HermesSessionResponse +import com.hermes.app.domain.HermesSessionStartRequest +import com.hermes.app.domain.HermesSettlement +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -22,18 +24,20 @@ import kotlinx.coroutines.sync.withLock class HermesRepository(private val apiClient: HermesApiClient) { private val sessionMutex = Mutex() private val _currentSession = MutableStateFlow(null) - private val _currentRound = MutableStateFlow(null) + private val _currentRound = MutableStateFlow(null) private val _isLoading = MutableStateFlow(true) private val _errorCause = MutableStateFlow(null) + private val _serverClockOffsetMs = MutableStateFlow(null) val currentSession: StateFlow = _currentSession.asStateFlow() - val currentRound: StateFlow = _currentRound.asStateFlow() + val currentRound: StateFlow = _currentRound.asStateFlow() val isLoading: StateFlow = _isLoading.asStateFlow() val errorCause: StateFlow = _errorCause.asStateFlow() + val serverClockOffsetMs: StateFlow = _serverClockOffsetMs.asStateFlow() - fun observeRound(): Flow = currentRound + fun observeRound(): Flow = currentRound - fun observeFeed(): Flow = currentRound + fun observeFeed(): Flow = currentRound suspend fun bootstrap(request: HermesSessionStartRequest): HermesSessionResponse { return sessionMutex.withLock { @@ -41,6 +45,11 @@ class HermesRepository(private val apiClient: HermesApiClient) { _errorCause.value = null try { + try { + syncClock() + } catch (_: Throwable) { + // Clock sync is best-effort. + } val session = _currentSession.value ?: startSession(request) if (_currentRound.value == null) { _currentRound.value = loadRoundFromNetwork() @@ -56,12 +65,17 @@ class HermesRepository(private val apiClient: HermesApiClient) { } } - suspend fun refreshRoundFromNetwork(): StudyRound { + suspend fun refreshRoundFromNetwork(): HermesRound { return sessionMutex.withLock { _isLoading.value = true _errorCause.value = null try { + try { + syncClock() + } catch (_: Throwable) { + // Clock sync is best-effort. + } val round = loadRoundFromNetwork() _currentRound.value = round round @@ -110,7 +124,17 @@ class HermesRepository(private val apiClient: HermesApiClient) { apiClient.submitAnalyticsBatch(request) } - private suspend fun loadRoundFromNetwork(): StudyRound { + fun serverNow(): Instant { + val offsetMs = _serverClockOffsetMs.value ?: 0L + return Instant.fromEpochMilliseconds(Clock.System.now().toEpochMilliseconds() + offsetMs) + } + + private suspend fun syncClock() { + val health = apiClient.health() + _serverClockOffsetMs.value = health.serverTime.toEpochMilliseconds() - Clock.System.now().toEpochMilliseconds() + } + + private suspend fun loadRoundFromNetwork(): HermesRound { val event = apiClient.nextEvent() val manifest = apiClient.eventManifest(event.id) val media = manifest.media.firstOrNull { it.mediaType == "hls_main" } ?: manifest.media.firstOrNull() @@ -120,7 +144,7 @@ class HermesRepository(private val apiClient: HermesApiClient) { val oddsVersion = apiClient.currentOdds(market.id) val settlement = apiClient.settlement(event.id) - return StudyRound( + return HermesRound( event = event, media = media, market = market, 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/app/domain/HermesModels.kt similarity index 92% rename from mobile/android-app/app/src/main/java/com/hermes/study/domain/HermesModels.kt rename to mobile/android-app/app/src/main/java/com/hermes/app/domain/HermesModels.kt index 28ea78a..efa216e 100644 --- a/mobile/android-app/app/src/main/java/com/hermes/study/domain/HermesModels.kt +++ b/mobile/android-app/app/src/main/java/com/hermes/app/domain/HermesModels.kt @@ -1,4 +1,4 @@ -package com.hermes.study.domain +package com.hermes.app.domain import kotlinx.datetime.Instant import kotlinx.serialization.Serializable @@ -28,6 +28,18 @@ data class HermesSessionResponse( val devicePlatform: String, ) +@Serializable +data class HermesHealthResponse( + val status: String, + val serviceName: String, + val environment: String, + val version: String, + val uptimeMs: Long, + val serverTime: Instant, + val databaseReady: Boolean, + val redisReady: Boolean, +) + @Serializable data class HermesEvent( val id: String, @@ -163,7 +175,7 @@ data class HermesLocalizationBundle( val values: Map, ) -data class StudyRound( +data class HermesRound( val event: HermesEvent, val media: HermesEventMedia, val market: HermesMarket, 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/app/feature/feed/FeedScreen.kt similarity index 94% rename from mobile/android-app/app/src/main/java/com/hermes/study/feature/feed/FeedScreen.kt rename to mobile/android-app/app/src/main/java/com/hermes/app/feature/feed/FeedScreen.kt index d3ed071..386e512 100644 --- 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/app/feature/feed/FeedScreen.kt @@ -1,4 +1,4 @@ -package com.hermes.study.feature.feed +package com.hermes.app.feature.feed import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -17,11 +17,11 @@ 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 +import com.hermes.app.core.designsystem.HermesCard +import com.hermes.app.core.designsystem.HermesColors +import com.hermes.app.core.designsystem.HermesMetricChip +import com.hermes.app.core.designsystem.HermesPrimaryButton +import com.hermes.app.core.designsystem.HermesSectionHeader @Composable fun FeedScreen( 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/app/feature/feed/FeedViewModel.kt similarity index 85% rename from mobile/android-app/app/src/main/java/com/hermes/study/feature/feed/FeedViewModel.kt rename to mobile/android-app/app/src/main/java/com/hermes/app/feature/feed/FeedViewModel.kt index 760f4db..2def518 100644 --- 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/app/feature/feed/FeedViewModel.kt @@ -1,13 +1,13 @@ -package com.hermes.study.feature.feed +package com.hermes.app.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.errors.mapUserFacingError -import com.hermes.study.core.localization.HermesLocalizationStore -import com.hermes.study.data.HermesRepository -import com.hermes.study.domain.StudyRound +import com.hermes.app.R +import com.hermes.app.core.analytics.HermesAnalyticsTracker +import com.hermes.app.core.errors.mapUserFacingError +import com.hermes.app.core.localization.HermesLocalizationStore +import com.hermes.app.data.HermesRepository +import com.hermes.app.domain.HermesRound import java.util.Locale import kotlinx.coroutines.delay import kotlinx.coroutines.flow.SharingStarted @@ -15,7 +15,6 @@ 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( @@ -35,7 +34,7 @@ data class FeedUiState( ) class FeedViewModel( - repository: HermesRepository, + private val repository: HermesRepository, private val localizationStore: HermesLocalizationStore, private val analyticsTracker: HermesAnalyticsTracker, ) : ViewModel() { @@ -43,7 +42,7 @@ class FeedViewModel( private val localeFlow = localizationStore.localeCode private val nowFlow = flow { while (true) { - emit(Clock.System.now()) + emit(repository.serverNow()) delay(1_000) } } @@ -58,7 +57,7 @@ class FeedViewModel( localeCode = localeFlow.value, isLoading = repository.isLoading.value, errorCause = repository.errorCause.value, - now = Clock.System.now(), + now = repository.serverNow(), ), ) @@ -79,7 +78,7 @@ class FeedViewModel( ) } - private fun buildUiState(round: StudyRound?, localeCode: String, isLoading: Boolean, errorCause: Throwable?, now: Instant): FeedUiState { + private fun buildUiState(round: HermesRound?, localeCode: String, isLoading: Boolean, errorCause: Throwable?, now: Instant): FeedUiState { val bannerMessage = mapUserFacingError(localizationStore, localeCode, errorCause) val hasRound = round != null val showLoading = isLoading && !hasRound @@ -110,7 +109,7 @@ class FeedViewModel( return localizationStore.string(localeCode, resId) } - private fun localizedEventTitle(round: StudyRound, localeCode: String): String { + private fun localizedEventTitle(round: HermesRound, localeCode: String): String { return if (localeCode == "sv") round.event.titleSv else round.event.titleEn } @@ -122,7 +121,7 @@ class FeedViewModel( return String.format(Locale.US, "%02d:%02d", minutes, seconds) } - private fun formatOdds(round: StudyRound): String { + private fun formatOdds(round: HermesRound): String { val oddsByOutcomeId = round.oddsVersion.odds.associateBy { it.outcomeId } return round.market.outcomes .sortedBy { it.sortOrder } 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/app/feature/result/ResultPanel.kt similarity index 88% rename from mobile/android-app/app/src/main/java/com/hermes/study/feature/result/ResultPanel.kt rename to mobile/android-app/app/src/main/java/com/hermes/app/feature/result/ResultPanel.kt index c112da3..f55fb3c 100644 --- 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/app/feature/result/ResultPanel.kt @@ -1,4 +1,4 @@ -package com.hermes.study.feature.result +package com.hermes.app.feature.result import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -14,10 +14,10 @@ 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 +import com.hermes.app.core.designsystem.HermesColors +import com.hermes.app.core.designsystem.HermesMetricChip +import com.hermes.app.core.designsystem.HermesPrimaryButton +import com.hermes.app.core.designsystem.HermesSectionHeader @Composable fun ResultPanel( 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/app/feature/reveal/RevealPanel.kt similarity index 79% rename from mobile/android-app/app/src/main/java/com/hermes/study/feature/reveal/RevealPanel.kt rename to mobile/android-app/app/src/main/java/com/hermes/app/feature/reveal/RevealPanel.kt index f73d01e..88ebc29 100644 --- 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/app/feature/reveal/RevealPanel.kt @@ -1,4 +1,4 @@ -package com.hermes.study.feature.reveal +package com.hermes.app.feature.reveal import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -6,12 +6,12 @@ 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 com.hermes.app.core.designsystem.HermesMetricChip +import com.hermes.app.core.designsystem.HermesPrimaryButton +import com.hermes.app.core.designsystem.HermesSectionHeader import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import com.hermes.study.core.designsystem.HermesColors +import com.hermes.app.core.designsystem.HermesColors @Composable fun RevealPanel( 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/app/feature/round/RoundScreen.kt similarity index 91% rename from mobile/android-app/app/src/main/java/com/hermes/study/feature/round/RoundScreen.kt rename to mobile/android-app/app/src/main/java/com/hermes/app/feature/round/RoundScreen.kt index cbba2a3..133e560 100644 --- 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/app/feature/round/RoundScreen.kt @@ -1,4 +1,4 @@ -package com.hermes.study.feature.round +package com.hermes.app.feature.round import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -18,16 +18,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip 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 -import com.hermes.study.core.designsystem.HermesPrimaryButton +import com.hermes.app.core.designsystem.HermesColors +import com.hermes.app.core.designsystem.HermesCountdownBadge +import com.hermes.app.core.designsystem.HermesCard +import com.hermes.app.core.designsystem.HermesMetricChip +import com.hermes.app.core.media.HermesPlayerCoordinator +import com.hermes.app.core.media.HermesVideoPlayerView +import com.hermes.app.feature.reveal.RevealPanel +import com.hermes.app.feature.result.ResultPanel +import com.hermes.app.feature.selection.SelectionPanel +import com.hermes.app.core.designsystem.HermesPrimaryButton @Composable fun RoundScreen( @@ -42,7 +42,7 @@ fun RoundScreen( ) { HermesCard(modifier = modifier, elevated = true) { Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - com.hermes.study.core.designsystem.HermesSectionHeader(title = uiState.title, subtitle = uiState.subtitle) + com.hermes.app.core.designsystem.HermesSectionHeader(title = uiState.title, subtitle = uiState.subtitle) when { uiState.isLoading && !uiState.hasRound -> RoundLoadingState( @@ -67,7 +67,7 @@ fun RoundScreen( .height(220.dp), ) { if (uiState.hasRound) { - StudyVideoPlayerView( + HermesVideoPlayerView( coordinator = playerCoordinator, modifier = Modifier.fillMaxSize(), ) 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/app/feature/round/RoundViewModel.kt similarity index 92% rename from mobile/android-app/app/src/main/java/com/hermes/study/feature/round/RoundViewModel.kt rename to mobile/android-app/app/src/main/java/com/hermes/app/feature/round/RoundViewModel.kt index 3ccd2b5..f4230f8 100644 --- 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/app/feature/round/RoundViewModel.kt @@ -1,17 +1,17 @@ -package com.hermes.study.feature.round +package com.hermes.app.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.errors.mapUserFacingError -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.HermesBetIntentRequest -import com.hermes.study.domain.StudyRound -import com.hermes.study.feature.selection.SelectionOptionUi +import com.hermes.app.R +import com.hermes.app.core.analytics.HermesAnalyticsTracker +import com.hermes.app.core.errors.mapUserFacingError +import com.hermes.app.core.localization.HermesLocalizationStore +import com.hermes.app.core.media.HermesPlayerCoordinator +import com.hermes.app.data.HermesRepository +import com.hermes.app.domain.HermesOutcome +import com.hermes.app.domain.HermesBetIntentRequest +import com.hermes.app.domain.HermesRound +import com.hermes.app.feature.selection.SelectionOptionUi import java.util.Locale import java.util.UUID import kotlinx.coroutines.Job @@ -74,7 +74,7 @@ data class RoundUiState( ) private data class RoundUiInputs( - val round: StudyRound?, + val round: HermesRound?, val localeCode: String, val phase: RoundPhase, val selectedOutcomeId: String?, @@ -98,7 +98,7 @@ class RoundViewModel( private val isSubmittingSelectionFlow = MutableStateFlow(false) private val nowFlow = flow { while (true) { - emit(Clock.System.now()) + emit(repository.serverNow()) delay(1_000) } } @@ -128,7 +128,7 @@ class RoundViewModel( }.stateIn( scope = viewModelScope, started = SharingStarted.Eagerly, - initialValue = buildUiState( + initialValue = buildUiState( RoundUiInputs( round = roundFlow.value, localeCode = localeFlow.value, @@ -139,7 +139,7 @@ class RoundViewModel( actionMessage = actionMessageFlow.value, isSubmitting = isSubmittingSelectionFlow.value, ), - Clock.System.now(), + repository.serverNow(), ), ) @@ -160,7 +160,7 @@ class RoundViewModel( return } - if (isTimerLocked(round, Clock.System.now())) { + if (isTimerLocked(round, repository.serverNow())) { return } @@ -182,7 +182,7 @@ class RoundViewModel( return } - if (isTimerLocked(round, Clock.System.now())) { + if (isTimerLocked(round, repository.serverNow())) { return } @@ -280,7 +280,7 @@ class RoundViewModel( } } - private fun startPreview(round: StudyRound) { + private fun startPreview(round: HermesRound) { transitionJob?.cancel() phaseFlow.value = RoundPhase.PREVIEW selectedOutcomeIdFlow.value = null @@ -368,7 +368,7 @@ class RoundViewModel( return localizationStore.string(localeCode, resId) } - private fun selectionOptions(round: StudyRound, localeCode: String): List { + private fun selectionOptions(round: HermesRound, localeCode: String): List { val oddsByOutcomeId = round.oddsVersion.odds.associateBy { it.outcomeId } return round.market.outcomes @@ -391,7 +391,7 @@ class RoundViewModel( } } - private fun resolveSelectedOutcomeTitle(round: StudyRound, localeCode: String, selectedOutcomeId: String?): String { + private fun resolveSelectedOutcomeTitle(round: HermesRound, localeCode: String, selectedOutcomeId: String?): String { if (selectedOutcomeId == null) { return localized(localeCode, R.string.round_selection_prompt) } @@ -400,12 +400,12 @@ class RoundViewModel( ?: localized(localeCode, R.string.round_selection_prompt) } - private fun resolveWinningOutcomeTitle(round: StudyRound, localeCode: String): String { + private fun resolveWinningOutcomeTitle(round: HermesRound, 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 { + private fun oddsSummary(round: HermesRound): String { val oddsByOutcomeId = round.oddsVersion.odds.associateBy { it.outcomeId } return round.market.outcomes .sortedBy { it.sortOrder } @@ -436,7 +436,7 @@ class RoundViewModel( } } - private fun isTimerLocked(round: StudyRound?, now: Instant): Boolean { + private fun isTimerLocked(round: HermesRound?, now: Instant): Boolean { return round != null && now.toEpochMilliseconds() >= round.event.lockAt.toEpochMilliseconds() } @@ -444,7 +444,7 @@ class RoundViewModel( return mapOf("screen_name" to "round", "outcome_id" to outcomeId) } - private fun roundAnalyticsAttributes(round: StudyRound): Map { + private fun roundAnalyticsAttributes(round: HermesRound): Map { return mapOf( "screen_name" to "round", "event_id" to round.event.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/app/feature/selection/SelectionPanel.kt similarity index 96% rename from mobile/android-app/app/src/main/java/com/hermes/study/feature/selection/SelectionPanel.kt rename to mobile/android-app/app/src/main/java/com/hermes/app/feature/selection/SelectionPanel.kt index d297e6d..543f495 100644 --- 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/app/feature/selection/SelectionPanel.kt @@ -1,4 +1,4 @@ -package com.hermes.study.feature.selection +package com.hermes.app.feature.selection import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -23,8 +23,8 @@ 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 +import com.hermes.app.core.designsystem.HermesColors +import com.hermes.app.core.designsystem.HermesPrimaryButton data class SelectionOptionUi( val id: String, 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/app/feature/session/SessionView.kt similarity index 91% rename from mobile/android-app/app/src/main/java/com/hermes/study/feature/session/SessionView.kt rename to mobile/android-app/app/src/main/java/com/hermes/app/feature/session/SessionView.kt index 80f1158..68f7056 100644 --- 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/app/feature/session/SessionView.kt @@ -1,4 +1,4 @@ -package com.hermes.study.feature.session +package com.hermes.app.feature.session import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -7,14 +7,14 @@ 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.R -import com.hermes.study.core.designsystem.HermesCard +import com.hermes.app.R +import com.hermes.app.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.HermesPrimaryButton -import com.hermes.study.core.designsystem.HermesSectionHeader -import com.hermes.study.core.localization.HermesLocalizationStore +import com.hermes.app.core.designsystem.HermesColors +import com.hermes.app.core.designsystem.HermesPrimaryButton +import com.hermes.app.core.designsystem.HermesSectionHeader +import com.hermes.app.core.localization.HermesLocalizationStore @Composable fun SessionView( @@ -64,7 +64,7 @@ fun SessionView( ) SessionRow( label = localizationStore.string(uiState.localeCode, R.string.session_locale_label), - value = uiState.localeCode.uppercase(), + value = localizationStore.localeName(uiState.localeCode), ) SessionRow( label = localizationStore.string(uiState.localeCode, R.string.session_started_label), diff --git a/mobile/android-app/app/src/main/java/com/hermes/study/feature/session/SessionViewModel.kt b/mobile/android-app/app/src/main/java/com/hermes/app/feature/session/SessionViewModel.kt similarity index 93% rename from mobile/android-app/app/src/main/java/com/hermes/study/feature/session/SessionViewModel.kt rename to mobile/android-app/app/src/main/java/com/hermes/app/feature/session/SessionViewModel.kt index 01d5ee3..e14a070 100644 --- a/mobile/android-app/app/src/main/java/com/hermes/study/feature/session/SessionViewModel.kt +++ b/mobile/android-app/app/src/main/java/com/hermes/app/feature/session/SessionViewModel.kt @@ -1,13 +1,13 @@ -package com.hermes.study.feature.session +package com.hermes.app.feature.session 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.errors.mapUserFacingError -import com.hermes.study.core.localization.HermesLocalizationStore -import com.hermes.study.data.HermesRepository -import com.hermes.study.domain.HermesSessionStartRequest +import com.hermes.app.R +import com.hermes.app.core.analytics.HermesAnalyticsTracker +import com.hermes.app.core.errors.mapUserFacingError +import com.hermes.app.core.localization.HermesLocalizationStore +import com.hermes.app.data.HermesRepository +import com.hermes.app.domain.HermesSessionStartRequest import java.util.Locale import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job 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/app/feature/settings/SettingsView.kt similarity index 83% rename from mobile/android-app/app/src/main/java/com/hermes/study/feature/settings/SettingsView.kt rename to mobile/android-app/app/src/main/java/com/hermes/app/feature/settings/SettingsView.kt index 635b870..f532f6c 100644 --- 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/app/feature/settings/SettingsView.kt @@ -1,4 +1,4 @@ -package com.hermes.study.feature.settings +package com.hermes.app.feature.settings import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -8,14 +8,13 @@ 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 com.hermes.app.R +import com.hermes.app.core.designsystem.HermesCard +import com.hermes.app.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 +import com.hermes.app.core.localization.HermesLocalizationStore +import com.hermes.app.core.designsystem.HermesSectionHeader @Composable fun SettingsView( @@ -32,7 +31,7 @@ fun SettingsView( SettingRow( label = localizationStore.string(localeCode, R.string.settings_language), - value = localeCode.uppercase(Locale.US), + value = localizationStore.localeName(localeCode), ) SettingRow( label = localizationStore.string(localeCode, R.string.settings_haptics), 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 deleted file mode 100644 index 9c7834c..0000000 --- a/mobile/android-app/app/src/main/java/com/hermes/study/HermesAppContainer.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.hermes.study - -import android.content.Context -import android.os.Build -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 com.hermes.study.domain.HermesSessionStartRequest -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) - } - - fun sessionViewModelFactory(): ViewModelProvider.Factory = HermesViewModelFactory { - com.hermes.study.feature.session.SessionViewModel( - repository = repository, - localizationStore = localizationStore, - analyticsTracker = analyticsTracker, - sessionRequestFactory = ::buildSessionStartRequest, - ) - } - - private fun buildSessionStartRequest(localeCode: String): HermesSessionStartRequest { - return HermesSessionStartRequest( - localeCode = localeCode, - devicePlatform = "android", - deviceModel = Build.MODEL, - osVersion = Build.VERSION.RELEASE, - appVersion = BuildConfig.VERSION_NAME, - ) - } -} - -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/core/analytics/HermesAnalyticsTracker.kt b/mobile/android-app/app/src/main/java/com/hermes/study/core/analytics/HermesAnalyticsTracker.kt deleted file mode 100644 index b21002b..0000000 --- a/mobile/android-app/app/src/main/java/com/hermes/study/core/analytics/HermesAnalyticsTracker.kt +++ /dev/null @@ -1,26 +0,0 @@ -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/data/SampleStudyData.kt b/mobile/android-app/app/src/main/java/com/hermes/study/data/SampleStudyData.kt deleted file mode 100644 index 4155633..0000000 --- a/mobile/android-app/app/src/main/java/com/hermes/study/data/SampleStudyData.kt +++ /dev/null @@ -1,124 +0,0 @@ -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/res/values-sv/strings.xml b/mobile/android-app/app/src/main/res/values-sv/strings.xml index 9fa4cc6..04612a3 100644 --- a/mobile/android-app/app/src/main/res/values-sv/strings.xml +++ b/mobile/android-app/app/src/main/res/values-sv/strings.xml @@ -1,7 +1,9 @@ Hermes - Native prototyp för studien + Native Hermes-prototyp + Engelska + Svenska Fortsätt Avbryt Stäng @@ -11,7 +13,7 @@ Nätverksfel. Kontrollera anslutningen. Videouppspelningen misslyckades. Sessionen har gått ut. Starta igen. - Studieintro + Välkommen till Hermes 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. diff --git a/mobile/android-app/app/src/main/res/values/strings.xml b/mobile/android-app/app/src/main/res/values/strings.xml index 26c3e2a..63e5521 100644 --- a/mobile/android-app/app/src/main/res/values/strings.xml +++ b/mobile/android-app/app/src/main/res/values/strings.xml @@ -1,7 +1,9 @@ Hermes - Native study app prototype + Native Hermes app prototype + English + Swedish Continue Cancel Close @@ -11,7 +13,7 @@ Network error. Check your connection. Video playback failed. Session expired. Please start again. - Study intro + Welcome to Hermes 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. diff --git a/mobile/ios-app/App/HermesApp.swift b/mobile/ios-app/App/HermesApp.swift index 6d881be..52ad70b 100644 --- a/mobile/ios-app/App/HermesApp.swift +++ b/mobile/ios-app/App/HermesApp.swift @@ -1,15 +1,59 @@ +import Foundation +import UIKit import SwiftUI @main struct HermesApp: App { + @StateObject private var repository = HermesRepository( + apiClient: HermesAPIClient( + environment: APIEnvironment(baseURL: URL(string: "http://localhost:3000/")!) + ) + ) @StateObject private var analytics = HermesAnalyticsClient() + @StateObject private var playerCoordinator = PlayerCoordinator() + @State private var isBootstrapping = false var body: some Scene { WindowGroup { - RootView() + RootView(onStartSession: { localeCode in + if repository.currentSession != nil, repository.currentRound != nil { + return + } + + guard !isBootstrapping else { + return + } + + isBootstrapping = true + let request = HermesSessionStartRequest( + localeCode: localeCode, + devicePlatform: "ios", + deviceModel: UIDevice.current.model, + osVersion: UIDevice.current.systemVersion, + appVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.1.0" + ) + + analytics.track("session_start_requested", attributes: ["screen_name": "session", "locale_code": localeCode]) + + Task { @MainActor in + defer { + isBootstrapping = false + } + + do { + _ = try await repository.bootstrap(request) + analytics.track("session_started", attributes: ["screen_name": "session", "locale_code": localeCode]) + await analytics.flush(using: repository) + } catch { + analytics.track("session_start_failed", attributes: ["screen_name": "session", "locale_code": localeCode]) + } + } + }) .preferredColorScheme(.dark) .tint(HermesTheme.accent) .environmentObject(analytics) + .environmentObject(repository) + .environmentObject(playerCoordinator) } } } diff --git a/mobile/ios-app/App/HermesErrorMapper.swift b/mobile/ios-app/App/HermesErrorMapper.swift new file mode 100644 index 0000000..82d3084 --- /dev/null +++ b/mobile/ios-app/App/HermesErrorMapper.swift @@ -0,0 +1,21 @@ +import Foundation + +func hermesUserFacingErrorMessage(localization: LocalizationStore, localeCode: String, error: Error?) -> String? { + guard let error else { + return nil + } + + if error is CancellationError { + return nil + } + + if error is URLError { + return localization.string(for: "errors.network", localeCode: localeCode) + } + + if error is HermesAPIError { + return localization.string(for: "errors.generic", localeCode: localeCode) + } + + return localization.string(for: "errors.generic", localeCode: localeCode) +} diff --git a/mobile/ios-app/App/HermesRepository.swift b/mobile/ios-app/App/HermesRepository.swift new file mode 100644 index 0000000..d4a5af0 --- /dev/null +++ b/mobile/ios-app/App/HermesRepository.swift @@ -0,0 +1,145 @@ +import Combine +import Foundation + +@MainActor +final class HermesRepository: ObservableObject { + @Published private(set) var currentSession: HermesSessionResponse? + @Published private(set) var currentRound: HermesRound? + @Published private(set) var isLoading = true + @Published private(set) var errorCause: Error? + @Published private(set) var serverClockOffset: TimeInterval? + + private let apiClient: HermesAPIClient + + init(apiClient: HermesAPIClient) { + self.apiClient = apiClient + } + + func bootstrap(_ request: HermesSessionStartRequest) async throws -> HermesSessionResponse { + isLoading = true + errorCause = nil + + do { + await syncClock() + + let session: HermesSessionResponse + if let existingSession = currentSession { + session = existingSession + } else { + session = try await startSession(request) + } + + if currentRound == nil { + currentRound = try await loadRoundFromNetwork() + } + + isLoading = false + return session + } catch { + errorCause = error + isLoading = false + throw error + } + } + + func refreshRoundFromNetwork() async throws -> HermesRound { + isLoading = true + errorCause = nil + + do { + await syncClock() + + let round = try await loadRoundFromNetwork() + currentRound = round + isLoading = false + return round + } catch { + errorCause = error + isLoading = false + throw error + } + } + + func startSession(_ request: HermesSessionStartRequest) async throws -> HermesSessionResponse { + let session = try await apiClient.startSession(request) + currentSession = session + return session + } + + func endSession() async throws -> HermesSessionResponse { + let session = try await apiClient.endSession() + currentSession = session + return session + } + + func submitBetIntent(_ request: HermesBetIntentRequest) async throws -> HermesBetIntentResponse { + try await apiClient.submitBetIntent(request) + } + + func currentOdds(marketID: UUID) async throws -> HermesOddsVersion { + try await apiClient.currentOdds(marketID: marketID) + } + + func settlement(eventID: UUID) async throws -> HermesSettlement { + try await apiClient.settlement(eventID: eventID) + } + + func experimentConfig() async throws -> HermesExperimentConfig { + try await apiClient.experimentConfig() + } + + func localization(localeCode: String) async throws -> HermesLocalizationBundle { + try await apiClient.localization(localeCode: localeCode) + } + + func submitAnalyticsBatch(_ payload: HermesAnalyticsBatchRequest) async throws { + try await apiClient.submitAnalyticsBatch(payload) + } + + func serverNow() -> Date { + guard let serverClockOffset else { + return Date() + } + + return Date().addingTimeInterval(serverClockOffset) + } + + private func syncClock() async { + do { + let health = try await apiClient.health() + serverClockOffset = health.serverTime.timeIntervalSince(Date()) + } catch { + return + } + } + + private func loadRoundFromNetwork() async throws -> HermesRound { + let event = try await apiClient.nextEvent() + let manifest = try await apiClient.eventManifest(eventID: event.id) + guard let media = manifest.media.first(where: { $0.mediaType == "hls_main" }) ?? manifest.media.first else { + throw HermesAPIError.invalidResponse + } + + let market: HermesMarket + if let manifestMarket = manifest.markets.first { + market = manifestMarket + } else { + let markets = try await apiClient.markets(eventID: event.id) + guard let fallbackMarket = markets.first else { + throw HermesAPIError.invalidResponse + } + market = fallbackMarket + } + + let oddsVersion = try await apiClient.currentOdds(marketID: market.id) + let settlement = try await apiClient.settlement(eventID: event.id) + + return HermesRound( + event: event, + media: media, + market: market, + oddsVersion: oddsVersion, + settlement: settlement + ) + } +} diff --git a/mobile/ios-app/App/RootView.swift b/mobile/ios-app/App/RootView.swift index 65d173c..20340fd 100644 --- a/mobile/ios-app/App/RootView.swift +++ b/mobile/ios-app/App/RootView.swift @@ -3,15 +3,19 @@ import SwiftUI struct RootView: View { @StateObject private var localization = LocalizationStore() @EnvironmentObject private var analytics: HermesAnalyticsClient + @EnvironmentObject private var repository: HermesRepository + + let onStartSession: (String) -> Void var body: some View { NavigationStack { ScrollView { VStack(alignment: .leading, spacing: 24) { header - OnboardingView() - FeedView() - RoundView() + OnboardingView(onStartSession: { onStartSession(localization.localeCode) }) + FeedView(onWatchPreview: {}, onRetry: { onStartSession(localization.localeCode) }) + RoundView(onRetry: { onStartSession(localization.localeCode) }) + SessionView(onRetry: { onStartSession(localization.localeCode) }) } .padding(.horizontal, HermesTheme.screenPadding) .padding(.vertical, 24) @@ -25,6 +29,15 @@ struct RootView: View { analytics.track("app_opened", attributes: ["screen_name": "home"]) analytics.track("screen_viewed", attributes: ["screen_name": "home"]) } + .task(id: localization.localeCode) { + onStartSession(localization.localeCode) + } + .task { + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 5_000_000_000) + await analytics.flush(using: repository) + } + } } private var header: some View { @@ -46,8 +59,8 @@ struct RootView: View { private var localeToggle: some View { HStack(spacing: 8) { - localeButton(title: "EN", localeCode: "en") - localeButton(title: "SV", localeCode: "sv") + localeButton(title: localization.localeName(for: "en"), localeCode: "en") + localeButton(title: localization.localeName(for: "sv"), localeCode: "sv") } } diff --git a/mobile/ios-app/Core/Analytics/AnalyticsClient.swift b/mobile/ios-app/Core/Analytics/AnalyticsClient.swift index 03b10f5..09308e0 100644 --- a/mobile/ios-app/Core/Analytics/AnalyticsClient.swift +++ b/mobile/ios-app/Core/Analytics/AnalyticsClient.swift @@ -25,4 +25,34 @@ final class HermesAnalyticsClient: ObservableObject, AnalyticsTracking { ) ) } + + func flush(using repository: HermesRepository) async { + guard repository.currentSession != nil else { + return + } + + let pendingEvents = trackedEvents + guard !pendingEvents.isEmpty else { + return + } + + do { + try await repository.submitAnalyticsBatch( + HermesAnalyticsBatchRequest( + events: pendingEvents.map { event in + HermesAnalyticsEventInput( + eventName: event.event, + occurredAt: event.timestamp, + attributes: event.attributes.map { HermesAnalyticsAttributeInput(key: $0.key, value: $0.value) } + ) + } + ) + ) + + let deliveredIds = Set(pendingEvents.map(\.id)) + trackedEvents.removeAll { deliveredIds.contains($0.id) } + } catch { + return + } + } } diff --git a/mobile/ios-app/Core/Localization/LocalizationStore.swift b/mobile/ios-app/Core/Localization/LocalizationStore.swift index 174a173..d486f87 100644 --- a/mobile/ios-app/Core/Localization/LocalizationStore.swift +++ b/mobile/ios-app/Core/Localization/LocalizationStore.swift @@ -36,6 +36,11 @@ final class LocalizationStore: ObservableObject { return value } + func localeName(for targetLocaleCode: String, displayLocaleCode: String? = nil) -> String { + let key = Self.normalize(targetLocaleCode) == "sv" ? "locale_swedish" : "locale_english" + return string(for: key, localeCode: displayLocaleCode ?? localeCode) + } + private func fallbackString(for key: String, localeCode: String) -> String { guard localeCode != Self.fallbackLocaleCode else { return key diff --git a/mobile/ios-app/Core/Media/StudyVideoPlayerView.swift b/mobile/ios-app/Core/Media/HermesVideoPlayerView.swift similarity index 94% rename from mobile/ios-app/Core/Media/StudyVideoPlayerView.swift rename to mobile/ios-app/Core/Media/HermesVideoPlayerView.swift index bb0a469..95cab70 100644 --- a/mobile/ios-app/Core/Media/StudyVideoPlayerView.swift +++ b/mobile/ios-app/Core/Media/HermesVideoPlayerView.swift @@ -1,7 +1,7 @@ import AVKit import SwiftUI -struct StudyVideoPlayerView: View { +struct HermesVideoPlayerView: View { @ObservedObject var coordinator: PlayerCoordinator var body: some View { diff --git a/mobile/ios-app/Core/Media/PlayerCoordinator.swift b/mobile/ios-app/Core/Media/PlayerCoordinator.swift index d6ca520..8ab2801 100644 --- a/mobile/ios-app/Core/Media/PlayerCoordinator.swift +++ b/mobile/ios-app/Core/Media/PlayerCoordinator.swift @@ -9,13 +9,15 @@ final class PlayerCoordinator: ObservableObject { @Published var isPlaying = false @Published var playbackPositionMs: Int = 0 - init(previewURL: URL = URL(string: "https://cdn.example.com/hermes/sample-event/master.m3u8")!) { - self.player = AVPlayer(url: previewURL) + init() { + self.player = AVPlayer() self.player.actionAtItemEnd = .pause } - func prepareForPreview() { - player.seek(to: .zero) + func prepareForPreview(url: URL, startTimeMs: Int = 0) { + player.replaceCurrentItem(with: AVPlayerItem(url: url)) + let startTime = CMTime(seconds: Double(startTimeMs) / 1_000.0, preferredTimescale: 1_000) + player.seek(to: startTime) player.play() isPlaying = true } @@ -30,8 +32,7 @@ final class PlayerCoordinator: ObservableObject { isPlaying = false } - func restart() { - player.seek(to: .zero) - play() + func restart(url: URL, startTimeMs: Int = 0) { + prepareForPreview(url: url, startTimeMs: startTimeMs) } } diff --git a/mobile/ios-app/Core/Networking/APIClient.swift b/mobile/ios-app/Core/Networking/APIClient.swift index dd808e4..b901648 100644 --- a/mobile/ios-app/Core/Networking/APIClient.swift +++ b/mobile/ios-app/Core/Networking/APIClient.swift @@ -42,6 +42,10 @@ struct HermesAPIClient { try await send(path: "api/v1/session/start", method: "POST", body: payload) } + func health() async throws -> HermesHealthResponse { + try await send(path: "health") + } + func endSession() async throws -> HermesSessionResponse { try await send(path: "api/v1/session/end", method: "POST") } diff --git a/mobile/ios-app/Core/Networking/APIModels.swift b/mobile/ios-app/Core/Networking/APIModels.swift index 2ed187e..a296e4c 100644 --- a/mobile/ios-app/Core/Networking/APIModels.swift +++ b/mobile/ios-app/Core/Networking/APIModels.swift @@ -23,6 +23,17 @@ struct HermesSessionResponse: Codable { var devicePlatform: String } +struct HermesHealthResponse: Codable { + var status: String + var serviceName: String + var environment: String + var version: String + var uptimeMs: Int + var serverTime: Date + var databaseReady: Bool + var redisReady: Bool +} + struct HermesEvent: Codable { var id: UUID var sportType: String @@ -94,6 +105,22 @@ struct HermesEventManifest: Codable { var markets: [HermesMarket] } +struct HermesRound: Codable { + var event: HermesEvent + var media: HermesEventMedia + var market: HermesMarket + var oddsVersion: HermesOddsVersion + var settlement: HermesSettlement +} + +struct HermesRound: Codable { + var event: HermesEvent + var media: HermesEventMedia + var market: HermesMarket + var oddsVersion: HermesOddsVersion + var settlement: HermesSettlement +} + struct HermesBetIntentRequest: Codable { var sessionId: UUID var eventId: UUID diff --git a/mobile/ios-app/Features/Feed/FeedView.swift b/mobile/ios-app/Features/Feed/FeedView.swift index ef74137..ca3b857 100644 --- a/mobile/ios-app/Features/Feed/FeedView.swift +++ b/mobile/ios-app/Features/Feed/FeedView.swift @@ -3,55 +3,173 @@ import SwiftUI struct FeedView: View { @EnvironmentObject private var localization: LocalizationStore @EnvironmentObject private var analytics: HermesAnalyticsClient + @EnvironmentObject private var repository: HermesRepository + + let onWatchPreview: () -> Void = {} + let onRetry: () -> Void var body: some View { - VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) { - HermesSectionHeader( - title: localization.string(for: "feed.title"), - subtitle: localization.string(for: "feed.subtitle") + TimelineView(.periodic(from: Date(), by: 1)) { _ in + let round = repository.currentRound + let now = repository.serverNow() + let bannerMessage = hermesUserFacingErrorMessage( + localization: localization, + localeCode: localization.localeCode, + error: repository.errorCause ) - ZStack(alignment: .bottomLeading) { - RoundedRectangle(cornerRadius: HermesTheme.cornerRadius, style: .continuous) - .fill( - LinearGradient( - colors: [HermesTheme.surfaceElevated, HermesTheme.background], - startPoint: .topLeading, - endPoint: .bottomTrailing + VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) { + HermesSectionHeader( + title: localization.string(for: "feed.title"), + subtitle: localization.string(for: "feed.subtitle") + ) + + if round == nil { + if let bannerMessage { + feedErrorState( + message: bannerMessage, + retryText: localization.string(for: "common.retry"), + onRetry: onRetry ) - ) - .frame(height: 220) + } else { + feedLoadingState( + title: localization.string(for: "common.loading"), + subtitle: localization.string(for: "feed.subtitle") + ) + } + } else { + if let bannerMessage { + feedBanner(message: bannerMessage) + } - VStack(alignment: .leading, spacing: 8) { - Text(localization.string(for: "feed.hero_title")) - .font(.title2.weight(.bold)) - .foregroundStyle(HermesTheme.textPrimary) + heroCard(round: round) - Text(localization.string(for: "feed.hero_subtitle")) - .font(.callout) - .foregroundStyle(HermesTheme.textSecondary) - .frame(maxWidth: 260, alignment: .leading) + HStack(spacing: 12) { + HermesMetricPill( + label: localization.string(for: "feed.lock_label"), + value: round.map { Self.countdownText(for: $0.event.lockAt.timeIntervalSince(now)) } ?? "--:--" + ) + HermesMetricPill( + label: localization.string(for: "feed.odds_label"), + value: round.map { Self.formatOdds($0) } ?? "--" + ) + } + + Button { + analytics.track("next_round_requested", attributes: ["screen_name": "feed"]) + analytics.track("cta_pressed", attributes: ["screen_name": "feed", "action": "watch_preview"]) + onWatchPreview() + } label: { + Text(localization.string(for: "feed.cta")) + } + .buttonStyle(HermesPrimaryButtonStyle()) + .disabled(round == nil) } - .padding(HermesTheme.contentPadding) } - - HStack(spacing: 12) { - HermesMetricPill(label: localization.string(for: "feed.lock_label"), value: "01:42") - HermesMetricPill(label: localization.string(for: "feed.odds_label"), value: "1.85 / 2.05") + .onAppear { + analytics.track("feed_viewed", attributes: ["screen_name": "feed"]) + analytics.track("screen_viewed", attributes: ["screen_name": "feed"]) } - - Button { - analytics.track("next_round_requested", attributes: ["screen_name": "feed"]) - analytics.track("cta_pressed", attributes: ["screen_name": "feed", "action": "watch_preview"]) - } label: { - Text(localization.string(for: "feed.cta")) - } - .buttonStyle(HermesPrimaryButtonStyle()) } .hermesCard(elevated: true) - .onAppear { - analytics.track("feed_viewed", attributes: ["screen_name": "feed"]) - analytics.track("screen_viewed", attributes: ["screen_name": "feed"]) + } + + @ViewBuilder + private func heroCard(round: HermesRound?) -> some View { + ZStack(alignment: .bottomLeading) { + RoundedRectangle(cornerRadius: HermesTheme.cornerRadius, style: .continuous) + .fill( + LinearGradient( + colors: [HermesTheme.surfaceElevated, HermesTheme.background], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(height: 220) + + VStack(alignment: .leading, spacing: 8) { + Text(round.map { localizedEventTitle($0) } ?? localization.string(for: "feed.hero_title")) + .font(.title2.weight(.bold)) + .foregroundStyle(HermesTheme.textPrimary) + + Text(round.map { localization.string(for: "feed.hero_subtitle") } ?? localization.string(for: "feed.hero_subtitle")) + .font(.callout) + .foregroundStyle(HermesTheme.textSecondary) + .frame(maxWidth: 260, alignment: .leading) + } + .padding(HermesTheme.contentPadding) } } + + @ViewBuilder + private func feedLoadingState(title: String, subtitle: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.title2.weight(.bold)) + .foregroundStyle(HermesTheme.textPrimary) + Text(subtitle) + .font(.callout) + .foregroundStyle(HermesTheme.textSecondary) + } + .padding(HermesTheme.contentPadding) + .frame(maxWidth: .infinity, minHeight: 220, alignment: .center) + .background( + RoundedRectangle(cornerRadius: HermesTheme.cornerRadius, style: .continuous) + .fill( + LinearGradient( + colors: [HermesTheme.surfaceElevated, HermesTheme.background], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + ) + } + + @ViewBuilder + private func feedErrorState(message: String, retryText: String, onRetry: @escaping () -> Void) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text(message) + .font(.callout) + .foregroundStyle(HermesTheme.warning) + + Button { + onRetry() + } label: { + Text(retryText) + } + .buttonStyle(HermesSecondaryButtonStyle()) + } + } + + @ViewBuilder + private func feedBanner(message: String) -> some View { + Text(message) + .font(.callout) + .foregroundStyle(HermesTheme.warning) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(HermesTheme.warning.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: HermesTheme.insetRadius, style: .continuous)) + } + + private func localizedEventTitle(_ round: HermesRound) -> String { + localization.localeCode == "sv" ? round.event.titleSv : round.event.titleEn + } + + private static func countdownText(for remaining: TimeInterval) -> String { + let totalSeconds = max(Int(remaining.rounded(.down)), 0) + let minutes = totalSeconds / 60 + let seconds = totalSeconds % 60 + return String(format: "%02d:%02d", minutes, seconds) + } + + private static func formatOdds(_ round: HermesRound) -> String { + let oddsByOutcomeId = Dictionary(uniqueKeysWithValues: round.oddsVersion.odds.map { ($0.outcomeId, $0) }) + return round.market.outcomes + .sorted(by: { $0.sortOrder < $1.sortOrder }) + .compactMap { oddsByOutcomeId[$0.id]?.decimalOdds } + .map { String(format: "%.2f", $0) } + .joined(separator: " / ") + } } diff --git a/mobile/ios-app/Features/Onboarding/OnboardingView.swift b/mobile/ios-app/Features/Onboarding/OnboardingView.swift index 30be9a7..f0b1a80 100644 --- a/mobile/ios-app/Features/Onboarding/OnboardingView.swift +++ b/mobile/ios-app/Features/Onboarding/OnboardingView.swift @@ -4,6 +4,8 @@ struct OnboardingView: View { @EnvironmentObject private var localization: LocalizationStore @EnvironmentObject private var analytics: HermesAnalyticsClient + let onStartSession: () -> Void + var body: some View { VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) { HermesSectionHeader( @@ -27,6 +29,7 @@ struct OnboardingView: View { Button { analytics.track("consent_accepted", attributes: ["screen_name": "onboarding"]) analytics.track("cta_pressed", attributes: ["screen_name": "onboarding", "action": "start_session"]) + onStartSession() } label: { Text(localization.string(for: "onboarding.start_session")) } diff --git a/mobile/ios-app/Features/Result/ResultView.swift b/mobile/ios-app/Features/Result/ResultView.swift index bd85753..c1d253f 100644 --- a/mobile/ios-app/Features/Result/ResultView.swift +++ b/mobile/ios-app/Features/Result/ResultView.swift @@ -13,8 +13,6 @@ struct ResultView: View { let nextRoundTitle: String let onNextRound: () -> Void - @EnvironmentObject private var analytics: HermesAnalyticsClient - var body: some View { VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) { HermesSectionHeader(title: title, subtitle: subtitle) @@ -33,16 +31,11 @@ struct ResultView: View { } Button { - analytics.track("next_round_requested", attributes: ["screen_name": "result"]) onNextRound() } label: { Text(nextRoundTitle) } .buttonStyle(HermesPrimaryButtonStyle()) } - .onAppear { - analytics.track("screen_viewed", attributes: ["screen_name": "result"]) - analytics.track("result_viewed", attributes: ["screen_name": "result", "selection": selectionValue, "outcome": outcomeValue]) - } } } diff --git a/mobile/ios-app/Features/Reveal/RevealView.swift b/mobile/ios-app/Features/Reveal/RevealView.swift index 3621539..e920951 100644 --- a/mobile/ios-app/Features/Reveal/RevealView.swift +++ b/mobile/ios-app/Features/Reveal/RevealView.swift @@ -9,8 +9,6 @@ struct RevealView: View { let continueTitle: String let onContinue: () -> Void - @EnvironmentObject private var analytics: HermesAnalyticsClient - var body: some View { VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) { HermesSectionHeader(title: title, subtitle: subtitle) @@ -24,16 +22,11 @@ struct RevealView: View { } Button { - analytics.track("reveal_completed", attributes: ["screen_name": "reveal", "selection": selectionValue]) onContinue() } label: { Text(continueTitle) } .buttonStyle(HermesPrimaryButtonStyle()) } - .onAppear { - analytics.track("screen_viewed", attributes: ["screen_name": "reveal"]) - analytics.track("reveal_started", attributes: ["screen_name": "reveal", "selection": selectionValue]) - } } } diff --git a/mobile/ios-app/Features/Round/RoundView.swift b/mobile/ios-app/Features/Round/RoundView.swift index c6b7911..dc0477e 100644 --- a/mobile/ios-app/Features/Round/RoundView.swift +++ b/mobile/ios-app/Features/Round/RoundView.swift @@ -3,15 +3,17 @@ import SwiftUI struct RoundView: View { @EnvironmentObject private var localization: LocalizationStore @EnvironmentObject private var analytics: HermesAnalyticsClient + @EnvironmentObject private var repository: HermesRepository + @EnvironmentObject private var playerCoordinator: PlayerCoordinator + + let onRetry: () -> Void - @StateObject private var playerCoordinator = PlayerCoordinator() @State private var phase: Phase = .preview @State private var selectedOutcomeID: String? = nil - @State private var lockAt: Date = Date().addingTimeInterval(47) + @State private var lockAt: Date = .now @State private var transitionTask: Task? - - private let previewDuration: TimeInterval = 47 - private let winningOutcomeID = "home" + @State private var actionMessage: String? + @State private var isSubmitting = false private enum Phase { case preview @@ -20,40 +22,19 @@ struct RoundView: View { case result } - private var selectionOptions: [SelectionOption] { - [ - SelectionOption( - id: "home", - title: localization.string(for: "round.home"), - subtitle: localization.string(for: "round.selection_prompt"), - odds: "1.85" - ), - SelectionOption( - id: "away", - title: localization.string(for: "round.away"), - subtitle: localization.string(for: "round.selection_prompt"), - odds: "2.05" - ), - ] - } - - private var selectedOutcome: SelectionOption? { - guard let selectedOutcomeID else { - return nil - } - - return selectionOptions.first { $0.id == selectedOutcomeID } - } - - private var winningOutcome: SelectionOption { - selectionOptions.first { $0.id == winningOutcomeID } ?? selectionOptions[0] - } - var body: some View { - TimelineView(.periodic(from: Date(), by: 1)) { context in - let remaining = max(lockAt.timeIntervalSince(context.date), 0) - let timerLocked = remaining <= 0 + TimelineView(.periodic(from: Date(), by: 1)) { _ in + let round = repository.currentRound + let now = repository.serverNow() + let hasRound = round != nil + let remaining = max(lockAt.timeIntervalSince(now), 0) + let timerLocked = round != nil && remaining <= 0 let countdownText = Self.countdownText(for: remaining) + let bannerMessage = actionMessage ?? hermesUserFacingErrorMessage( + localization: localization, + localeCode: localization.localeCode, + error: repository.errorCause + ) VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) { HermesSectionHeader( @@ -61,15 +42,87 @@ struct RoundView: View { subtitle: localization.string(for: "round.subtitle") ) - videoSection(countdownText: countdownText, remaining: remaining, isTimerLocked: timerLocked) + if !hasRound { + if let bannerMessage { + roundErrorState( + message: bannerMessage, + retryText: localization.string(for: "common.retry"), + onRetry: onRetry + ) + } else { + roundLoadingState( + title: localization.string(for: "common.loading"), + subtitle: localization.string(for: "round.subtitle") + ) + } + } else { + if let bannerMessage { + roundBanner(message: bannerMessage) + } - phaseContent(isTimerLocked: timerLocked) + videoSection( + round: round, + countdownText: countdownText, + remaining: remaining, + isTimerLocked: timerLocked + ) + + if let round { + switch phase { + case .preview, .locked: + SelectionView( + statusText: phase == .preview && !timerLocked ? localization.string(for: "round.selection_prompt") : localization.string(for: "round.locked_label"), + options: selectionOptions(for: round), + selectedOptionID: selectedOutcomeID, + isLocked: phase != .preview || timerLocked || isSubmitting, + confirmTitle: localization.string(for: "round.primary_cta"), + onSelect: handleSelection, + onConfirm: confirmSelection + ) + + case .reveal: + RevealView( + title: localization.string(for: "reveal.title"), + subtitle: localization.string(for: "reveal.subtitle"), + statusText: localization.string(for: "reveal.status"), + selectionLabel: localization.string(for: "result.selection_label"), + selectionValue: selectedOutcomeTitle(for: round), + continueTitle: localization.string(for: "reveal.cta"), + onContinue: showResult + ) + + case .result: + ResultView( + title: localization.string(for: "result.title"), + subtitle: localization.string(for: "result.subtitle"), + selectionLabel: localization.string(for: "result.selection_label"), + selectionValue: selectedOutcomeTitle(for: round), + outcomeLabel: localization.string(for: "result.outcome_label"), + outcomeValue: winningOutcomeTitle(for: round), + didWin: selectedOutcomeID == round.settlement.winningOutcomeId.uuidString, + winLabel: localization.string(for: "result.win"), + loseLabel: localization.string(for: "result.lose"), + nextRoundTitle: localization.string(for: "result.next_round"), + onNextRound: nextRound + ) + } + } + } + } + .onAppear { + if let round { + startPreview(round) + } + } + .onChange(of: round?.event.id) { _, newValue in + guard newValue != nil, let round else { + return + } + + startPreview(round) } } .hermesCard(elevated: true) - .onAppear { - startPreview() - } .onDisappear { transitionTask?.cancel() playerCoordinator.pause() @@ -77,65 +130,36 @@ struct RoundView: View { } @ViewBuilder - private func phaseContent(isTimerLocked: Bool) -> some View { - switch phase { - case .preview, .locked: - SelectionView( - statusText: isTimerLocked || phase != .preview ? localization.string(for: "round.locked_label") : localization.string(for: "round.selection_prompt"), - options: selectionOptions, - selectedOptionID: selectedOutcomeID, - isLocked: isTimerLocked || phase != .preview, - confirmTitle: localization.string(for: "round.primary_cta"), - onSelect: handleSelection, - onConfirm: confirmSelection - ) - - case .reveal: - RevealView( - title: localization.string(for: "reveal.title"), - subtitle: localization.string(for: "reveal.subtitle"), - statusText: localization.string(for: "reveal.status"), - selectionLabel: localization.string(for: "result.selection_label"), - selectionValue: selectedOutcome?.title ?? localization.string(for: "round.selection_prompt"), - continueTitle: localization.string(for: "reveal.cta"), - onContinue: showResult - ) - - case .result: - ResultView( - title: localization.string(for: "result.title"), - subtitle: localization.string(for: "result.subtitle"), - selectionLabel: localization.string(for: "result.selection_label"), - selectionValue: selectedOutcome?.title ?? localization.string(for: "round.selection_prompt"), - outcomeLabel: localization.string(for: "result.outcome_label"), - outcomeValue: winningOutcome.title, - didWin: selectedOutcomeID == winningOutcomeID, - winLabel: localization.string(for: "result.win"), - loseLabel: localization.string(for: "result.lose"), - nextRoundTitle: localization.string(for: "result.next_round"), - onNextRound: resetRound - ) - } - } - - @ViewBuilder - private func videoSection(countdownText: String, remaining: TimeInterval, isTimerLocked: Bool) -> some View { + private func videoSection(round: HermesRound?, countdownText: String, remaining: TimeInterval, isTimerLocked: Bool) -> some View { ZStack(alignment: .topTrailing) { - StudyVideoPlayerView(coordinator: playerCoordinator) + RoundedRectangle(cornerRadius: HermesTheme.cornerRadius, style: .continuous) + .fill(HermesTheme.surfaceElevated) + .frame(height: 220) - VStack(alignment: .trailing, spacing: 10) { - HermesCountdownBadge( - label: localization.string(for: "round.countdown_label"), - value: countdownText, - warning: !isTimerLocked && remaining <= 10 - ) - - HermesMetricPill( - label: localization.string(for: "round.odds_label"), - value: "1.85 / 2.05" - ) + if let round { + HermesVideoPlayerView(coordinator: playerCoordinator) + } else { + Text(localization.string(for: "round.video_placeholder")) + .font(.headline.weight(.semibold)) + .foregroundStyle(HermesTheme.textSecondary) + } + + if let round { + VStack(alignment: .trailing, spacing: 10) { + HermesCountdownBadge( + label: localization.string(for: "round.countdown_label"), + value: countdownText, + warning: !isTimerLocked && remaining <= 10 + ) + + HermesMetricPill( + label: localization.string(for: "round.odds_label"), + value: formatOdds(round) + ) + .frame(maxWidth: 160) + } + .padding(12) } - .padding(12) Text(phaseLabel(isTimerLocked: isTimerLocked)) .font(.caption.weight(.bold)) @@ -147,35 +171,25 @@ struct RoundView: View { .padding(12) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) } + .clipShape(RoundedRectangle(cornerRadius: HermesTheme.cornerRadius, style: .continuous)) } - private func phaseLabel(isTimerLocked: Bool) -> String { - switch phase { - case .preview: - return isTimerLocked ? localization.string(for: "round.locked_label") : localization.string(for: "round.preview_label") - case .locked: - return localization.string(for: "round.locked_label") - case .reveal: - return localization.string(for: "reveal.title") - case .result: - return localization.string(for: "result.title") - } - } - - private func startPreview() { + private func startPreview(_ round: HermesRound) { transitionTask?.cancel() phase = .preview - lockAt = Date().addingTimeInterval(previewDuration) selectedOutcomeID = nil - playerCoordinator.restart() + actionMessage = nil + isSubmitting = false + lockAt = round.event.lockAt + playerCoordinator.prepareForPreview(url: round.media.hlsMasterUrl, startTimeMs: round.media.previewStartMs) - analytics.track("round_loaded", attributes: ["screen_name": "round"]) - analytics.track("preview_started", attributes: ["screen_name": "round"]) + analytics.track("round_loaded", attributes: roundAnalyticsAttributes(round)) + analytics.track("preview_started", attributes: roundAnalyticsAttributes(round)) analytics.track("screen_viewed", attributes: ["screen_name": "round"]) } private func handleSelection(_ option: SelectionOption) { - guard phase == .preview else { + guard phase == .preview, !isSubmitting else { return } @@ -185,39 +199,204 @@ struct RoundView: View { } private func confirmSelection() { - guard phase == .preview, let selectedOutcomeID else { + guard phase == .preview, !isSubmitting, let round = repository.currentRound else { return } - analytics.track("selection_submitted", attributes: ["screen_name": "round", "outcome_id": selectedOutcomeID]) - analytics.track("selection_accepted", attributes: ["screen_name": "round", "outcome_id": selectedOutcomeID]) - analytics.track("market_locked", attributes: ["screen_name": "round", "lock_reason": "manual_selection"]) + guard let selectedOutcomeID, let session = repository.currentSession else { + actionMessage = localization.string(for: "errors.session_expired") + return + } - phase = .locked - playerCoordinator.pause() + guard let outcomeID = UUID(uuidString: selectedOutcomeID) ?? round.market.outcomes.first?.id else { + actionMessage = localization.string(for: "errors.generic") + return + } - transitionTask?.cancel() - transitionTask = Task { @MainActor in - try? await Task.sleep(nanoseconds: 750_000_000) - guard !Task.isCancelled else { - return + if repository.serverNow() >= round.event.lockAt { + return + } + + isSubmitting = true + actionMessage = nil + + Task { @MainActor in + do { + let request = HermesBetIntentRequest( + sessionId: session.sessionId, + eventId: round.event.id, + marketId: round.market.id, + outcomeId: outcomeID, + idempotencyKey: UUID().uuidString, + clientSentAt: Date() + ) + + let response = try await repository.submitBetIntent(request) + + guard response.accepted else { + actionMessage = localization.string(for: "errors.generic") + phase = .preview + isSubmitting = false + return + } + + analytics.track("selection_submitted", attributes: baseSelectionAttributes(selectedOutcomeID)) + analytics.track("selection_accepted", attributes: baseSelectionAttributes(selectedOutcomeID)) + analytics.track("market_locked", attributes: baseSelectionAttributes(selectedOutcomeID).merging(["lock_reason": "manual_selection"]) { _, new in new }) + + phase = .locked + playerCoordinator.pause() + + transitionTask?.cancel() + transitionTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 750_000_000) + guard !Task.isCancelled, phase == .locked else { + return + } + + phase = .reveal + analytics.track("reveal_started", attributes: roundAnalyticsAttributes(round).merging(baseSelectionAttributes(selectedOutcomeID)) { _, new in new }) + } + } catch { + actionMessage = hermesUserFacingErrorMessage(localization: localization, localeCode: localization.localeCode, error: error) ?? localization.string(for: "errors.generic") + phase = .preview } - phase = .reveal + isSubmitting = false } } private func showResult() { - analytics.track("reveal_completed", attributes: ["screen_name": "round"]) + guard let round = repository.currentRound, let selectedOutcomeID else { + return + } + + analytics.track("reveal_completed", attributes: roundAnalyticsAttributes(round).merging(baseSelectionAttributes(selectedOutcomeID)) { _, new in new }) phase = .result + analytics.track( + "result_viewed", + attributes: roundAnalyticsAttributes(round) + .merging(baseSelectionAttributes(selectedOutcomeID)) { _, new in new } + .merging(["outcome": winningOutcomeTitle(for: round)]) { _, new in new } + ) } - private func resetRound() { + private func nextRound() { + analytics.track("next_round_requested", attributes: ["screen_name": "result"]) transitionTask?.cancel() - selectedOutcomeID = nil - phase = .preview - lockAt = Date().addingTimeInterval(previewDuration) - playerCoordinator.restart() + actionMessage = nil + + Task { @MainActor in + do { + _ = try await repository.refreshRoundFromNetwork() + } catch { + actionMessage = hermesUserFacingErrorMessage(localization: localization, localeCode: localization.localeCode, error: error) ?? localization.string(for: "errors.generic") + } + } + } + + private func roundLoadingState(title: String, subtitle: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.headline.weight(.semibold)) + .foregroundStyle(HermesTheme.textPrimary) + Text(subtitle) + .font(.callout) + .foregroundStyle(HermesTheme.textSecondary) + } + } + + private func roundErrorState(message: String, retryText: String, onRetry: @escaping () -> Void) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text(message) + .font(.callout) + .foregroundStyle(HermesTheme.warning) + + Button { + onRetry() + } label: { + Text(retryText) + } + .buttonStyle(HermesSecondaryButtonStyle()) + } + } + + private func roundBanner(message: String) -> some View { + Text(message) + .font(.callout) + .foregroundStyle(HermesTheme.warning) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(HermesTheme.warning.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: HermesTheme.insetRadius, style: .continuous)) + } + + private func selectionOptions(for round: HermesRound) -> [SelectionOption] { + let oddsByOutcomeId = Dictionary(uniqueKeysWithValues: round.oddsVersion.odds.map { ($0.outcomeId, $0) }) + return round.market.outcomes + .sorted(by: { $0.sortOrder < $1.sortOrder }) + .map { outcome in + SelectionOption( + id: outcome.id.uuidString, + title: outcomeTitle(outcome), + subtitle: localization.string(for: "round.selection_prompt"), + odds: oddsByOutcomeId[outcome.id].map { String(format: "%.2f", $0.decimalOdds) } ?? "--" + ) + } + } + + private func outcomeTitle(_ outcome: HermesOutcome) -> String { + switch outcome.labelKey { + case "round.home": + return localization.string(for: "round.home") + case "round.away": + return localization.string(for: "round.away") + default: + return outcome.outcomeCode.capitalized + } + } + + private func selectedOutcomeTitle(for round: HermesRound) -> String { + guard let selectedOutcomeID, + let outcome = round.market.outcomes.first(where: { $0.id.uuidString == selectedOutcomeID }) else { + return localization.string(for: "round.selection_prompt") + } + + return outcomeTitle(outcome) + } + + private func winningOutcomeTitle(for round: HermesRound) -> String { + guard let outcome = round.market.outcomes.first(where: { $0.id == round.settlement.winningOutcomeId }) else { + return localization.string(for: "round.selection_prompt") + } + + return outcomeTitle(outcome) + } + + private func formatOdds(_ round: HermesRound) -> String { + let oddsByOutcomeId = Dictionary(uniqueKeysWithValues: round.oddsVersion.odds.map { ($0.outcomeId, $0) }) + return round.market.outcomes + .sorted(by: { $0.sortOrder < $1.sortOrder }) + .compactMap { oddsByOutcomeId[$0.id]?.decimalOdds } + .map { String(format: "%.2f", $0) } + .joined(separator: " / ") + } + + private func phaseLabel(isTimerLocked: Bool) -> String { + switch phase { + case .preview: + if isTimerLocked { + return localization.string(for: "round.locked_label") + } + return localization.string(for: "round.preview_label") + case .locked: + return localization.string(for: "round.locked_label") + case .reveal: + return localization.string(for: "reveal.title") + case .result: + return localization.string(for: "result.title") + } } private static func countdownText(for remaining: TimeInterval) -> String { @@ -226,4 +405,16 @@ struct RoundView: View { let seconds = totalSeconds % 60 return String(format: "%02d:%02d", minutes, seconds) } + + private func baseSelectionAttributes(_ outcomeId: String) -> [String: String] { + ["screen_name": "round", "outcome_id": outcomeId] + } + + private func roundAnalyticsAttributes(_ round: HermesRound) -> [String: String] { + [ + "screen_name": "round", + "event_id": round.event.id.uuidString, + "market_id": round.market.id.uuidString, + ] + } } diff --git a/mobile/ios-app/Features/Session/SessionView.swift b/mobile/ios-app/Features/Session/SessionView.swift index 57152ae..0e2edb2 100644 --- a/mobile/ios-app/Features/Session/SessionView.swift +++ b/mobile/ios-app/Features/Session/SessionView.swift @@ -1,7 +1,147 @@ import SwiftUI struct SessionView: View { + @EnvironmentObject private var analytics: HermesAnalyticsClient + @EnvironmentObject private var localization: LocalizationStore + @EnvironmentObject private var repository: HermesRepository + + let onRetry: () -> Void + var body: some View { - Text("Session scaffold") + let session = repository.currentSession + let bannerMessage = hermesUserFacingErrorMessage( + localization: localization, + localeCode: localization.localeCode, + error: repository.errorCause + ) + let statusText: String + if session != nil { + statusText = localization.string(for: "session.status_ready") + } else if bannerMessage != nil { + statusText = localization.string(for: "session.status_error") + } else { + statusText = localization.string(for: "session.status_loading") + } + + return HermesCard { + VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) { + HermesSectionHeader( + title: localization.string(for: "session.title"), + subtitle: localization.string(for: "session.subtitle") + ) + + if session == nil { + if let bannerMessage { + sessionErrorState( + message: bannerMessage, + retryText: localization.string(for: "common.retry"), + onRetry: onRetry + ) + } else { + sessionLoadingState( + title: statusText, + subtitle: localization.string(for: "session.note") + ) + } + } else { + sessionStatusBadge(text: statusText, warning: bannerMessage != nil) + + if let bannerMessage { + sessionBanner(message: bannerMessage) + } + + sessionRow(label: localization.string(for: "session.id_label"), value: session?.sessionId.uuidString ?? "--") + sessionRow(label: localization.string(for: "session.user_id_label"), value: session?.userId.uuidString ?? "--") + sessionRow( + label: localization.string(for: "session.locale_label"), + value: localization.localeName(for: session?.localeCode ?? localization.localeCode) + ) + sessionRow(label: localization.string(for: "session.started_label"), value: session.map { Self.compactDateFormatter.string(from: $0.startedAt) } ?? "--") + sessionRow(label: localization.string(for: "session.variant_label"), value: session?.experimentVariant ?? "--") + sessionRow(label: localization.string(for: "session.app_version_label"), value: session?.appVersion ?? "--") + sessionRow(label: localization.string(for: "session.device_model_label"), value: session?.deviceModel ?? "--") + sessionRow(label: localization.string(for: "session.os_version_label"), value: session?.osVersion ?? "--") + + Text(localization.string(for: "session.note")) + .font(.callout) + .foregroundStyle(HermesTheme.textSecondary) + } + } + } + .onAppear { + analytics.track("screen_viewed", attributes: ["screen_name": "session"]) + } } + + @ViewBuilder + private func sessionLoadingState(title: String, subtitle: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.headline.weight(.semibold)) + .foregroundStyle(HermesTheme.textPrimary) + Text(subtitle) + .font(.callout) + .foregroundStyle(HermesTheme.textSecondary) + } + } + + @ViewBuilder + private func sessionErrorState(message: String, retryText: String, onRetry: @escaping () -> Void) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text(message) + .font(.callout) + .foregroundStyle(HermesTheme.warning) + + Button { + onRetry() + } label: { + Text(retryText) + } + .buttonStyle(HermesSecondaryButtonStyle()) + } + } + + @ViewBuilder + private func sessionStatusBadge(text: String, warning: Bool) -> some View { + Text(text) + .font(.caption.weight(.semibold)) + .foregroundStyle(warning ? HermesTheme.warning : HermesTheme.accent) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background((warning ? HermesTheme.warning : HermesTheme.accent).opacity(0.16)) + .clipShape(Capsule()) + } + + @ViewBuilder + private func sessionBanner(message: String) -> some View { + Text(message) + .font(.callout) + .foregroundStyle(HermesTheme.warning) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(HermesTheme.warning.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: HermesTheme.insetRadius, style: .continuous)) + } + + @ViewBuilder + private func sessionRow(label: String, value: String) -> some View { + HStack { + Text(label) + .font(.callout) + .foregroundStyle(HermesTheme.textSecondary) + Spacer(minLength: 12) + Text(value) + .font(.callout.weight(.semibold)) + .foregroundStyle(HermesTheme.textPrimary) + } + } + + private static let compactDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = .current + formatter.dateFormat = "yyyy-MM-dd HH:mm" + return formatter + }() } diff --git a/mobile/ios-app/README.md b/mobile/ios-app/README.md index 4acd44a..2abdb59 100644 --- a/mobile/ios-app/README.md +++ b/mobile/ios-app/README.md @@ -1,6 +1,6 @@ # iOS App -Native SwiftUI client scaffold for the Hermes study app. +Native SwiftUI client scaffold for the Hermes app. Planned structure: diff --git a/mobile/ios-app/Resources/Localization/en.lproj/Localizable.strings b/mobile/ios-app/Resources/Localization/en.lproj/Localizable.strings index dd2c03c..52c7c1d 100644 --- a/mobile/ios-app/Resources/Localization/en.lproj/Localizable.strings +++ b/mobile/ios-app/Resources/Localization/en.lproj/Localizable.strings @@ -1,6 +1,13 @@ "app.name" = "Hermes"; -"app.subtitle" = "Native study app prototype"; -"onboarding.title" = "Study intro"; +"app.subtitle" = "Native Hermes app prototype"; +"locale_english" = "English"; +"locale_swedish" = "Swedish"; +"common.loading" = "Loading"; +"common.retry" = "Retry"; +"errors.generic" = "Please try again."; +"errors.network" = "Network error. Check your connection."; +"errors.session_expired" = "Session expired. Please start again."; +"onboarding.title" = "Welcome to Hermes"; "onboarding.subtitle" = "Watch the clip, decide before lock, then see the reveal."; "onboarding.consent_body" = "This prototype is for research and does not use real money."; "onboarding.consent_note" = "You can switch languages at any time."; @@ -35,3 +42,17 @@ "result.win" = "Winning selection"; "result.lose" = "Not this time"; "result.next_round" = "Next round"; +"session.title" = "Session"; +"session.subtitle" = "Session sync and lifecycle controls."; +"session.note" = "Session state will appear here once the backend session is started."; +"session.status_loading" = "Starting session"; +"session.status_ready" = "Session active"; +"session.status_error" = "Session unavailable"; +"session.id_label" = "Session ID"; +"session.user_id_label" = "User ID"; +"session.locale_label" = "Locale"; +"session.started_label" = "Started"; +"session.variant_label" = "Variant"; +"session.app_version_label" = "App version"; +"session.device_model_label" = "Device model"; +"session.os_version_label" = "OS version"; diff --git a/mobile/ios-app/Resources/Localization/sv.lproj/Localizable.strings b/mobile/ios-app/Resources/Localization/sv.lproj/Localizable.strings index aa36ce6..3426546 100644 --- a/mobile/ios-app/Resources/Localization/sv.lproj/Localizable.strings +++ b/mobile/ios-app/Resources/Localization/sv.lproj/Localizable.strings @@ -1,6 +1,13 @@ "app.name" = "Hermes"; -"app.subtitle" = "Native prototype för studien"; -"onboarding.title" = "Studieintro"; +"app.subtitle" = "Native Hermes-prototyp"; +"locale_english" = "Engelska"; +"locale_swedish" = "Svenska"; +"common.loading" = "Laddar"; +"common.retry" = "Försök igen"; +"errors.generic" = "Försök igen."; +"errors.network" = "Nätverksfel. Kontrollera anslutningen."; +"errors.session_expired" = "Sessionen har gått ut. Starta igen."; +"onboarding.title" = "Välkommen till Hermes"; "onboarding.subtitle" = "Titta på klippet, välj före låsning och se sedan avslöjandet."; "onboarding.consent_body" = "Den här prototypen är för forskning och använder inga riktiga pengar."; "onboarding.consent_note" = "Du kan byta språk när som helst."; @@ -35,3 +42,17 @@ "result.win" = "Vinnande val"; "result.lose" = "Inte denna gång"; "result.next_round" = "Nästa runda"; +"session.title" = "Session"; +"session.subtitle" = "Sessionssynk och livscykelkontroller."; +"session.note" = "Sessionsstatus visas här när backend-sessionen har startat."; +"session.status_loading" = "Startar session"; +"session.status_ready" = "Session aktiv"; +"session.status_error" = "Sessionen är otillgänglig"; +"session.id_label" = "Sessions-ID"; +"session.user_id_label" = "Användar-ID"; +"session.locale_label" = "Språk"; +"session.started_label" = "Startad"; +"session.variant_label" = "Variant"; +"session.app_version_label" = "Appversion"; +"session.device_model_label" = "Enhetsmodell"; +"session.os_version_label" = "OS-version";