scaffolding hermes flow and audit logging

This commit is contained in:
2026-04-09 18:54:10 +02:00
parent e401b6dbab
commit cf5316a2c1
59 changed files with 1830 additions and 593 deletions
+22
View File
@@ -1,5 +1,27 @@
# Project Template: Native Betting Study App # 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 ## 1. Purpose
Build a native mobile prototype for iOS and Android for a research Build a native mobile prototype for iOS and Android for a research
+23
View File
@@ -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`.
+243 -15
View File
@@ -16,6 +16,7 @@ pub use crate::sessions::{SessionSnapshot, SessionStartRequest};
use crate::{ use crate::{
analytics::AnalyticsStore, analytics::AnalyticsStore,
audit::AuditStore,
bets::{ bets::{
BetsStore, ACCEPTANCE_ACCEPTED, ACCEPTANCE_REJECTED_INVALID_MARKET, BetsStore, ACCEPTANCE_ACCEPTED, ACCEPTANCE_REJECTED_INVALID_MARKET,
ACCEPTANCE_REJECTED_INVALID_SESSION, ACCEPTANCE_REJECTED_TOO_LATE, ACCEPTANCE_REJECTED_INVALID_SESSION, ACCEPTANCE_REJECTED_TOO_LATE,
@@ -43,6 +44,7 @@ pub struct AppState {
experiments: ExperimentsStore, experiments: ExperimentsStore,
localization: LocalizationStore, localization: LocalizationStore,
analytics: AnalyticsStore, analytics: AnalyticsStore,
audit: AuditStore,
markets: MarketsStore, markets: MarketsStore,
users: UsersStore, users: UsersStore,
sessions: SessionsStore, sessions: SessionsStore,
@@ -65,6 +67,7 @@ impl AppState {
experiments: ExperimentsStore::new(), experiments: ExperimentsStore::new(),
localization: LocalizationStore::new(), localization: LocalizationStore::new(),
analytics: AnalyticsStore::new(), analytics: AnalyticsStore::new(),
audit: AuditStore::new(),
markets: MarketsStore::new(), markets: MarketsStore::new(),
users: UsersStore::new(), users: UsersStore::new(),
sessions: SessionsStore::new(), sessions: SessionsStore::new(),
@@ -87,7 +90,8 @@ impl AppState {
}) })
.await; .await;
self.sessions let session = self
.sessions
.start_session( .start_session(
user.id, user.id,
request, request,
@@ -98,7 +102,24 @@ impl AppState {
default_experiment_variant: self.config.default_experiment_variant.clone(), 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<SessionSnapshot> { pub async fn current_session(&self) -> Option<SessionSnapshot> {
@@ -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<Uuid>,
) {
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 { pub async fn submit_bet_intent(&self, request: BetIntentRequest) -> BetIntentResponse {
let Some(existing) = self let Some(existing) = self
.bets .bets
@@ -143,7 +198,7 @@ impl AppState {
async fn submit_bet_intent_fresh(&self, request: BetIntentRequest) -> BetIntentResponse { async fn submit_bet_intent_fresh(&self, request: BetIntentRequest) -> BetIntentResponse {
let Some(session) = self.current_session().await else { let Some(session) = self.current_session().await else {
return self let response = self
.bets .bets
.record(self.build_bet_intent_record( .record(self.build_bet_intent_record(
&request, &request,
@@ -153,10 +208,21 @@ impl AppState {
None, None,
)) ))
.await; .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 { if session.ended_at.is_some() || session.session_id != request.session_id {
return self let response = self
.bets .bets
.record(self.build_bet_intent_record( .record(self.build_bet_intent_record(
&request, &request,
@@ -166,10 +232,21 @@ impl AppState {
None, None,
)) ))
.await; .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 { let Some(market) = self.markets.market(request.market_id).await else {
return self let response = self
.bets .bets
.record(self.build_bet_intent_record( .record(self.build_bet_intent_record(
&request, &request,
@@ -179,10 +256,21 @@ impl AppState {
None, None,
)) ))
.await; .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 { if market.event_id != request.event_id {
return self let response = self
.bets .bets
.record(self.build_bet_intent_record( .record(self.build_bet_intent_record(
&request, &request,
@@ -192,10 +280,21 @@ impl AppState {
None, None,
)) ))
.await; .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 { let Some(outcome) = self.markets.outcome(request.outcome_id).await else {
return self let response = self
.bets .bets
.record(self.build_bet_intent_record( .record(self.build_bet_intent_record(
&request, &request,
@@ -205,10 +304,21 @@ impl AppState {
None, None,
)) ))
.await; .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 { if outcome.market_id != market.id || request.event_id != market.event_id {
return self let response = self
.bets .bets
.record(self.build_bet_intent_record( .record(self.build_bet_intent_record(
&request, &request,
@@ -218,10 +328,21 @@ impl AppState {
None, None,
)) ))
.await; .await;
self.record_bet_audit(
"bet_rejected",
Some(&session),
&request,
ACCEPTANCE_REJECTED_INVALID_MARKET,
None,
)
.await;
return response;
} }
if Utc::now() >= market.lock_at { if Utc::now() >= market.lock_at {
return self let response = self
.bets .bets
.record(self.build_bet_intent_record( .record(self.build_bet_intent_record(
&request, &request,
@@ -231,10 +352,21 @@ impl AppState {
None, None,
)) ))
.await; .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); 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( .record(self.build_bet_intent_record(
&request, &request,
Some(session.user_id), Some(session.user_id),
@@ -242,7 +374,18 @@ impl AppState {
ACCEPTANCE_ACCEPTED, ACCEPTANCE_ACCEPTED,
accepted_odds_version_id, 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<BetIntentResponse> { pub async fn bet_intent(&self, bet_intent_id: Uuid) -> Option<BetIntentResponse> {
@@ -250,10 +393,28 @@ impl AppState {
} }
pub async fn end_session(&self) -> Result<SessionSnapshot, AppError> { pub async fn end_session(&self) -> Result<SessionSnapshot, AppError> {
self.sessions let session = self
.sessions
.end_session() .end_session()
.await .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( pub async fn admin_create_event_manifest(
@@ -266,6 +427,20 @@ impl AppState {
self.markets.insert_market(market.clone()).await; 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) Ok(created)
} }
@@ -279,6 +454,22 @@ impl AppState {
self.markets.insert_market(market.clone()).await; self.markets.insert_market(market.clone()).await;
let _ = self.events.insert_market(market.event_id, 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) Ok(market)
} }
@@ -290,7 +481,23 @@ impl AppState {
return Err(AppError::not_found("Market not found")); 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( pub async fn admin_publish_settlement(
@@ -301,7 +508,24 @@ impl AppState {
return Err(AppError::not_found("Market not found")); 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<EventSnapshot> { pub async fn next_event(&self) -> Option<EventSnapshot> {
@@ -362,6 +586,10 @@ impl AppState {
self.analytics.counts().await 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<Vec<MarketSnapshot>> { pub async fn markets_for_event(&self, event_id: Uuid) -> Option<Vec<MarketSnapshot>> {
self.markets.markets_for_event(event_id).await self.markets.markets_for_event(event_id).await
} }
+87
View File
@@ -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<RwLock<AuditState>>,
}
#[derive(Default)]
struct AuditState {
event_types_by_name: HashMap<String, Uuid>,
events: Vec<AuditEventSnapshot>,
attributes: Vec<AuditEventAttributeSnapshot>,
}
#[derive(Clone)]
#[allow(dead_code)]
struct AuditEventSnapshot {
id: Uuid,
audit_event_type_id: Uuid,
session_id: Option<Uuid>,
user_id: Option<Uuid>,
occurred_at: DateTime<Utc>,
}
#[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<I, K, V>(
&self,
action: impl Into<String>,
session_id: Option<Uuid>,
user_id: Option<Uuid>,
occurred_at: DateTime<Utc>,
attributes: I,
) where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
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())
}
}
+1
View File
@@ -1,6 +1,7 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
pub mod analytics; pub mod analytics;
pub mod audit;
pub mod admin; pub mod admin;
pub mod bets; pub mod bets;
pub mod app_state; pub mod app_state;
+3
View File
@@ -1,4 +1,5 @@
use axum::{extract::Extension, Json}; use axum::{extract::Extension, Json};
use chrono::{DateTime, Utc};
use serde::Serialize; use serde::Serialize;
use crate::app_state::AppState; use crate::app_state::AppState;
@@ -10,6 +11,7 @@ pub struct HealthResponse {
pub environment: String, pub environment: String,
pub version: String, pub version: String,
pub uptime_ms: u128, pub uptime_ms: u128,
pub server_time: DateTime<Utc>,
pub database_ready: bool, pub database_ready: bool,
pub redis_ready: bool, pub redis_ready: bool,
} }
@@ -21,6 +23,7 @@ pub async fn handler(Extension(state): Extension<AppState>) -> Json<HealthRespon
environment: state.config.environment.clone(), environment: state.config.environment.clone(),
version: state.config.app_version.clone(), version: state.config.app_version.clone(),
uptime_ms: state.uptime_ms(), uptime_ms: state.uptime_ms(),
server_time: Utc::now(),
database_ready: state.database_ready(), database_ready: state.database_ready(),
redis_ready: state.redis_ready(), redis_ready: state.redis_ready(),
}) })
+82 -1
View File
@@ -11,10 +11,91 @@ async fn health_returns_ok() {
let response = app let response = app
.oneshot(Request::builder().uri("/health").body(Body::empty()).unwrap()) .oneshot(Request::builder().uri("/health").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: json::Value = json::from_slice(&body).unwrap();
assert!(json["server_time"].as_str().is_some());
}
#[tokio::test]
async fn audit_logging_records_session_and_bet() {
let state = AppState::new(AppConfig::default(), None, None);
let app = build_router(state.clone());
let session_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/session/start")
.header("content-type", "application/json")
.body(Body::from("{}"))
.unwrap(),
)
.await .await
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::OK); let session_body = to_bytes(session_response.into_body(), usize::MAX).await.unwrap();
let session_json: json::Value = json::from_slice(&session_body).unwrap();
let session_id = session_json["session_id"].as_str().unwrap().to_string();
let event_response = app
.clone()
.oneshot(Request::builder().uri("/api/v1/feed/next").body(Body::empty()).unwrap())
.await
.unwrap();
let event_body = to_bytes(event_response.into_body(), usize::MAX).await.unwrap();
let event_json: json::Value = json::from_slice(&event_body).unwrap();
let event_id = event_json["id"].as_str().unwrap().to_string();
let markets_response = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/api/v1/events/{event_id}/markets"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let markets_body = to_bytes(markets_response.into_body(), usize::MAX).await.unwrap();
let markets_json: json::Value = json::from_slice(&markets_body).unwrap();
let market_id = markets_json[0]["id"].as_str().unwrap().to_string();
let outcome_id = markets_json[0]["outcomes"][0]["id"].as_str().unwrap().to_string();
let request = json::json!({
"session_id": session_id,
"event_id": event_id,
"market_id": market_id,
"outcome_id": outcome_id,
"idempotency_key": "audit-bet-001",
"client_sent_at": Utc::now().to_rfc3339(),
})
.to_string();
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/bets/intent")
.header("content-type", "application/json")
.body(Body::from(request))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let (audit_events, audit_attributes) = state.audit_counts().await;
assert!(audit_events >= 2);
assert!(audit_attributes >= 10);
} }
#[tokio::test] #[tokio::test]
+4 -1
View File
@@ -259,7 +259,7 @@ components:
schemas: schemas:
HealthResponse: HealthResponse:
type: object 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: properties:
status: status:
type: string type: string
@@ -271,6 +271,9 @@ components:
type: string type: string
uptime_ms: uptime_ms:
type: integer type: integer
server_time:
type: string
format: date-time
database_ready: database_ready:
type: boolean type: boolean
redis_ready: redis_ready:
+2 -2
View File
@@ -6,11 +6,11 @@ plugins {
} }
android { android {
namespace = "com.hermes.study" namespace = "com.hermes.app"
compileSdk = 35 compileSdk = 35
defaultConfig { defaultConfig {
applicationId = "com.hermes.study" applicationId = "com.hermes.app"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 1 versionCode = 1
@@ -1,4 +1,4 @@
package com.hermes.study package com.hermes.app
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -21,21 +21,22 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import com.hermes.study.R import com.hermes.app.R
import com.hermes.study.core.designsystem.HermesPalette import com.hermes.app.core.designsystem.HermesPalette
import com.hermes.study.core.designsystem.HermesSecondaryButton import com.hermes.app.core.designsystem.HermesSecondaryButton
import com.hermes.study.core.designsystem.HermesStudyTheme import com.hermes.app.core.designsystem.HermesTheme
import com.hermes.study.feature.feed.FeedScreen import com.hermes.app.core.localization.HermesLocalizationStore
import com.hermes.study.feature.feed.FeedViewModel import com.hermes.app.feature.feed.FeedScreen
import com.hermes.study.feature.session.SessionView import com.hermes.app.feature.feed.FeedViewModel
import com.hermes.study.feature.session.SessionViewModel import com.hermes.app.feature.session.SessionView
import com.hermes.study.feature.settings.SettingsView import com.hermes.app.feature.session.SessionViewModel
import com.hermes.study.feature.round.RoundScreen import com.hermes.app.feature.settings.SettingsView
import com.hermes.study.feature.round.RoundViewModel import com.hermes.app.feature.round.RoundScreen
import com.hermes.app.feature.round.RoundViewModel
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@Composable @Composable
fun HermesStudyApp(container: HermesAppContainer) { fun HermesApp(container: HermesAppContainer) {
val feedViewModel: FeedViewModel = viewModel(factory = container.feedViewModelFactory()) val feedViewModel: FeedViewModel = viewModel(factory = container.feedViewModelFactory())
val roundViewModel: RoundViewModel = viewModel(factory = container.roundViewModelFactory()) val roundViewModel: RoundViewModel = viewModel(factory = container.roundViewModelFactory())
val sessionViewModel: SessionViewModel = viewModel(factory = container.sessionViewModelFactory()) val sessionViewModel: SessionViewModel = viewModel(factory = container.sessionViewModelFactory())
@@ -53,6 +54,7 @@ fun HermesStudyApp(container: HermesAppContainer) {
HermesAppBar( HermesAppBar(
title = container.localizationStore.string(localeCode, R.string.app_name), title = container.localizationStore.string(localeCode, R.string.app_name),
localeCode = localeCode, localeCode = localeCode,
localizationStore = container.localizationStore,
onLocaleSelected = container.localizationStore::setLocale, onLocaleSelected = container.localizationStore::setLocale,
) )
}, },
@@ -105,14 +107,23 @@ fun HermesStudyApp(container: HermesAppContainer) {
private fun HermesAppBar( private fun HermesAppBar(
title: String, title: String,
localeCode: String, localeCode: String,
localizationStore: HermesLocalizationStore,
onLocaleSelected: (String) -> Unit, onLocaleSelected: (String) -> Unit,
) { ) {
androidx.compose.material3.CenterAlignedTopAppBar( androidx.compose.material3.CenterAlignedTopAppBar(
title = { Text(text = title) }, title = { Text(text = title) },
actions = { 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)) 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( colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = HermesPalette.colors.background containerColor = HermesPalette.colors.background
@@ -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<T : ViewModel>(private val creator: () -> T) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = creator() as T
}
@@ -1,4 +1,4 @@
package com.hermes.study package com.hermes.app
import android.app.Application import android.app.Application
@@ -1,9 +1,9 @@
package com.hermes.study package com.hermes.app
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import com.hermes.study.core.designsystem.HermesStudyTheme import com.hermes.app.core.designsystem.HermesTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -12,8 +12,8 @@ class MainActivity : ComponentActivity() {
val app = application as HermesApplication val app = application as HermesApplication
setContent { setContent {
HermesStudyTheme { HermesTheme {
HermesStudyApp(app.container) HermesApp(app.container)
} }
} }
} }
@@ -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<String, String>,
val timestamp: Instant,
)
class HermesAnalyticsTracker {
private val eventsFlow = MutableSharedFlow<HermesTrackedEvent>(replay = 64, extraBufferCapacity = 64)
private val pendingEvents = mutableListOf<HermesTrackedEvent>()
val events: SharedFlow<HermesTrackedEvent> = eventsFlow.asSharedFlow()
@Synchronized
fun track(name: String, attributes: Map<String, String> = 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<HermesTrackedEvent> {
return pendingEvents.toList()
}
@Synchronized
fun markDelivered(deliveredIds: Set<UUID>) {
pendingEvents.removeAll { it.id in deliveredIds }
}
fun toBatchRequest(events: List<HermesTrackedEvent>): 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)
},
)
},
)
}
}
@@ -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.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -57,7 +57,7 @@ private val HermesShapes = androidx.compose.material3.Shapes(
) )
@Composable @Composable
fun HermesStudyTheme(content: @Composable () -> Unit) { fun HermesTheme(content: @Composable () -> Unit) {
MaterialTheme( MaterialTheme(
colorScheme = HermesColorScheme, colorScheme = HermesColorScheme,
typography = MaterialTheme.typography, typography = MaterialTheme.typography,
@@ -1,8 +1,8 @@
package com.hermes.study.core.errors package com.hermes.app.core.errors
import com.hermes.study.R import com.hermes.app.R
import com.hermes.study.core.localization.HermesLocalizationStore import com.hermes.app.core.localization.HermesLocalizationStore
import com.hermes.study.core.network.HermesApiException import com.hermes.app.core.network.HermesApiException
import java.io.IOException import java.io.IOException
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@@ -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.foundation.gestures.detectVerticalDragGestures
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -1,4 +1,4 @@
package com.hermes.study.core.haptics package com.hermes.app.core.haptics
import android.view.HapticFeedbackConstants import android.view.HapticFeedbackConstants
import android.view.View import android.view.View
@@ -1,7 +1,8 @@
package com.hermes.study.core.localization package com.hermes.app.core.localization
import android.content.Context import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration
import com.hermes.app.R
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow 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 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 { private fun localizedContext(localeCode: String): Context {
val configuration = Configuration(appContext.resources.configuration) val configuration = Configuration(appContext.resources.configuration)
configuration.setLocale(Locale.forLanguageTag(localeCode)) configuration.setLocale(Locale.forLanguageTag(localeCode))
@@ -1,4 +1,4 @@
package com.hermes.study.core.media package com.hermes.app.core.media
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
@@ -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.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
@@ -7,7 +7,7 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
@Composable @Composable
fun StudyVideoPlayerView( fun HermesVideoPlayerView(
coordinator: HermesPlayerCoordinator, coordinator: HermesPlayerCoordinator,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -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.app.domain.HermesAnalyticsBatchRequest
import com.hermes.study.domain.HermesBetIntentRequest import com.hermes.app.domain.HermesBetIntentRequest
import com.hermes.study.domain.HermesBetIntentResponse import com.hermes.app.domain.HermesBetIntentResponse
import com.hermes.study.domain.HermesEvent import com.hermes.app.domain.HermesHealthResponse
import com.hermes.study.domain.HermesExperimentConfig import com.hermes.app.domain.HermesEvent
import com.hermes.study.domain.HermesLocalizationBundle import com.hermes.app.domain.HermesEventManifest
import com.hermes.study.domain.HermesMarket import com.hermes.app.domain.HermesExperimentConfig
import com.hermes.study.domain.HermesOddsVersion import com.hermes.app.domain.HermesLocalizationBundle
import com.hermes.study.domain.HermesSessionResponse import com.hermes.app.domain.HermesMarket
import com.hermes.study.domain.HermesSessionStartRequest import com.hermes.app.domain.HermesOddsVersion
import com.hermes.study.domain.HermesSettlement 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.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
@@ -37,13 +39,15 @@ class HermesApiClient(
suspend fun startSession(request: HermesSessionStartRequest): HermesSessionResponse = post("api/v1/session/start", request) 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 endSession(): HermesSessionResponse = post("api/v1/session/end")
suspend fun currentSession(): HermesSessionResponse = get("api/v1/session/me") suspend fun currentSession(): HermesSessionResponse = get("api/v1/session/me")
suspend fun nextEvent(): HermesEvent = get("api/v1/feed/next") 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<HermesMarket> = get("api/v1/events/$eventId/markets") suspend fun markets(eventId: String): List<HermesMarket> = get("api/v1/events/$eventId/markets")
@@ -1,3 +1,3 @@
package com.hermes.study.core.network package com.hermes.app.core.network
// Placeholder for future API-specific mapping helpers. // Placeholder for future API-specific mapping helpers.
@@ -1,17 +1,19 @@
package com.hermes.study.data package com.hermes.app.data
import com.hermes.study.core.network.HermesApiClient import com.hermes.app.core.network.HermesApiClient
import com.hermes.study.domain.HermesAnalyticsBatchRequest import com.hermes.app.domain.HermesAnalyticsBatchRequest
import com.hermes.study.domain.HermesBetIntentRequest import com.hermes.app.domain.HermesBetIntentRequest
import com.hermes.study.domain.HermesBetIntentResponse import com.hermes.app.domain.HermesBetIntentResponse
import com.hermes.study.domain.HermesExperimentConfig import com.hermes.app.domain.HermesExperimentConfig
import com.hermes.study.domain.HermesLocalizationBundle import com.hermes.app.domain.HermesLocalizationBundle
import com.hermes.study.domain.HermesMarket import com.hermes.app.domain.HermesMarket
import com.hermes.study.domain.HermesOddsVersion import com.hermes.app.domain.HermesOddsVersion
import com.hermes.study.domain.HermesSessionResponse import com.hermes.app.domain.HermesRound
import com.hermes.study.domain.HermesSessionStartRequest import com.hermes.app.domain.HermesSessionResponse
import com.hermes.study.domain.HermesSettlement import com.hermes.app.domain.HermesSessionStartRequest
import com.hermes.study.domain.StudyRound import com.hermes.app.domain.HermesSettlement
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -22,18 +24,20 @@ import kotlinx.coroutines.sync.withLock
class HermesRepository(private val apiClient: HermesApiClient) { class HermesRepository(private val apiClient: HermesApiClient) {
private val sessionMutex = Mutex() private val sessionMutex = Mutex()
private val _currentSession = MutableStateFlow<HermesSessionResponse?>(null) private val _currentSession = MutableStateFlow<HermesSessionResponse?>(null)
private val _currentRound = MutableStateFlow<StudyRound?>(null) private val _currentRound = MutableStateFlow<HermesRound?>(null)
private val _isLoading = MutableStateFlow(true) private val _isLoading = MutableStateFlow(true)
private val _errorCause = MutableStateFlow<Throwable?>(null) private val _errorCause = MutableStateFlow<Throwable?>(null)
private val _serverClockOffsetMs = MutableStateFlow<Long?>(null)
val currentSession: StateFlow<HermesSessionResponse?> = _currentSession.asStateFlow() val currentSession: StateFlow<HermesSessionResponse?> = _currentSession.asStateFlow()
val currentRound: StateFlow<StudyRound?> = _currentRound.asStateFlow() val currentRound: StateFlow<HermesRound?> = _currentRound.asStateFlow()
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow() val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
val errorCause: StateFlow<Throwable?> = _errorCause.asStateFlow() val errorCause: StateFlow<Throwable?> = _errorCause.asStateFlow()
val serverClockOffsetMs: StateFlow<Long?> = _serverClockOffsetMs.asStateFlow()
fun observeRound(): Flow<StudyRound?> = currentRound fun observeRound(): Flow<HermesRound?> = currentRound
fun observeFeed(): Flow<StudyRound?> = currentRound fun observeFeed(): Flow<HermesRound?> = currentRound
suspend fun bootstrap(request: HermesSessionStartRequest): HermesSessionResponse { suspend fun bootstrap(request: HermesSessionStartRequest): HermesSessionResponse {
return sessionMutex.withLock { return sessionMutex.withLock {
@@ -41,6 +45,11 @@ class HermesRepository(private val apiClient: HermesApiClient) {
_errorCause.value = null _errorCause.value = null
try { try {
try {
syncClock()
} catch (_: Throwable) {
// Clock sync is best-effort.
}
val session = _currentSession.value ?: startSession(request) val session = _currentSession.value ?: startSession(request)
if (_currentRound.value == null) { if (_currentRound.value == null) {
_currentRound.value = loadRoundFromNetwork() _currentRound.value = loadRoundFromNetwork()
@@ -56,12 +65,17 @@ class HermesRepository(private val apiClient: HermesApiClient) {
} }
} }
suspend fun refreshRoundFromNetwork(): StudyRound { suspend fun refreshRoundFromNetwork(): HermesRound {
return sessionMutex.withLock { return sessionMutex.withLock {
_isLoading.value = true _isLoading.value = true
_errorCause.value = null _errorCause.value = null
try { try {
try {
syncClock()
} catch (_: Throwable) {
// Clock sync is best-effort.
}
val round = loadRoundFromNetwork() val round = loadRoundFromNetwork()
_currentRound.value = round _currentRound.value = round
round round
@@ -110,7 +124,17 @@ class HermesRepository(private val apiClient: HermesApiClient) {
apiClient.submitAnalyticsBatch(request) 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 event = apiClient.nextEvent()
val manifest = apiClient.eventManifest(event.id) val manifest = apiClient.eventManifest(event.id)
val media = manifest.media.firstOrNull { it.mediaType == "hls_main" } ?: manifest.media.firstOrNull() 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 oddsVersion = apiClient.currentOdds(market.id)
val settlement = apiClient.settlement(event.id) val settlement = apiClient.settlement(event.id)
return StudyRound( return HermesRound(
event = event, event = event,
media = media, media = media,
market = market, market = market,
@@ -1,4 +1,4 @@
package com.hermes.study.domain package com.hermes.app.domain
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -28,6 +28,18 @@ data class HermesSessionResponse(
val devicePlatform: String, 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 @Serializable
data class HermesEvent( data class HermesEvent(
val id: String, val id: String,
@@ -163,7 +175,7 @@ data class HermesLocalizationBundle(
val values: Map<String, String>, val values: Map<String, String>,
) )
data class StudyRound( data class HermesRound(
val event: HermesEvent, val event: HermesEvent,
val media: HermesEventMedia, val media: HermesEventMedia,
val market: HermesMarket, val market: HermesMarket,
@@ -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.background
import androidx.compose.foundation.layout.Arrangement 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.draw.clip
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.hermes.study.core.designsystem.HermesCard import com.hermes.app.core.designsystem.HermesCard
import com.hermes.study.core.designsystem.HermesColors import com.hermes.app.core.designsystem.HermesColors
import com.hermes.study.core.designsystem.HermesMetricChip import com.hermes.app.core.designsystem.HermesMetricChip
import com.hermes.study.core.designsystem.HermesPrimaryButton import com.hermes.app.core.designsystem.HermesPrimaryButton
import com.hermes.study.core.designsystem.HermesSectionHeader import com.hermes.app.core.designsystem.HermesSectionHeader
@Composable @Composable
fun FeedScreen( fun FeedScreen(
@@ -1,13 +1,13 @@
package com.hermes.study.feature.feed package com.hermes.app.feature.feed
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.hermes.study.R import com.hermes.app.R
import com.hermes.study.core.analytics.HermesAnalyticsTracker import com.hermes.app.core.analytics.HermesAnalyticsTracker
import com.hermes.study.core.errors.mapUserFacingError import com.hermes.app.core.errors.mapUserFacingError
import com.hermes.study.core.localization.HermesLocalizationStore import com.hermes.app.core.localization.HermesLocalizationStore
import com.hermes.study.data.HermesRepository import com.hermes.app.data.HermesRepository
import com.hermes.study.domain.StudyRound import com.hermes.app.domain.HermesRound
import java.util.Locale import java.util.Locale
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -15,7 +15,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
data class FeedUiState( data class FeedUiState(
@@ -35,7 +34,7 @@ data class FeedUiState(
) )
class FeedViewModel( class FeedViewModel(
repository: HermesRepository, private val repository: HermesRepository,
private val localizationStore: HermesLocalizationStore, private val localizationStore: HermesLocalizationStore,
private val analyticsTracker: HermesAnalyticsTracker, private val analyticsTracker: HermesAnalyticsTracker,
) : ViewModel() { ) : ViewModel() {
@@ -43,7 +42,7 @@ class FeedViewModel(
private val localeFlow = localizationStore.localeCode private val localeFlow = localizationStore.localeCode
private val nowFlow = flow { private val nowFlow = flow {
while (true) { while (true) {
emit(Clock.System.now()) emit(repository.serverNow())
delay(1_000) delay(1_000)
} }
} }
@@ -58,7 +57,7 @@ class FeedViewModel(
localeCode = localeFlow.value, localeCode = localeFlow.value,
isLoading = repository.isLoading.value, isLoading = repository.isLoading.value,
errorCause = repository.errorCause.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 bannerMessage = mapUserFacingError(localizationStore, localeCode, errorCause)
val hasRound = round != null val hasRound = round != null
val showLoading = isLoading && !hasRound val showLoading = isLoading && !hasRound
@@ -110,7 +109,7 @@ class FeedViewModel(
return localizationStore.string(localeCode, resId) 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 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) 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 } val oddsByOutcomeId = round.oddsVersion.odds.associateBy { it.outcomeId }
return round.market.outcomes return round.market.outcomes
.sortedBy { it.sortOrder } .sortedBy { it.sortOrder }
@@ -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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -14,10 +14,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.hermes.study.core.designsystem.HermesColors import com.hermes.app.core.designsystem.HermesColors
import com.hermes.study.core.designsystem.HermesMetricChip import com.hermes.app.core.designsystem.HermesMetricChip
import com.hermes.study.core.designsystem.HermesPrimaryButton import com.hermes.app.core.designsystem.HermesPrimaryButton
import com.hermes.study.core.designsystem.HermesSectionHeader import com.hermes.app.core.designsystem.HermesSectionHeader
@Composable @Composable
fun ResultPanel( fun ResultPanel(
@@ -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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -6,12 +6,12 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.hermes.study.core.designsystem.HermesMetricChip import com.hermes.app.core.designsystem.HermesMetricChip
import com.hermes.study.core.designsystem.HermesPrimaryButton import com.hermes.app.core.designsystem.HermesPrimaryButton
import com.hermes.study.core.designsystem.HermesSectionHeader import com.hermes.app.core.designsystem.HermesSectionHeader
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import com.hermes.study.core.designsystem.HermesColors import com.hermes.app.core.designsystem.HermesColors
@Composable @Composable
fun RevealPanel( fun RevealPanel(
@@ -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.background
import androidx.compose.foundation.layout.Arrangement 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.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.hermes.study.core.designsystem.HermesColors import com.hermes.app.core.designsystem.HermesColors
import com.hermes.study.core.designsystem.HermesCountdownBadge import com.hermes.app.core.designsystem.HermesCountdownBadge
import com.hermes.study.core.designsystem.HermesCard import com.hermes.app.core.designsystem.HermesCard
import com.hermes.study.core.designsystem.HermesMetricChip import com.hermes.app.core.designsystem.HermesMetricChip
import com.hermes.study.core.media.HermesPlayerCoordinator import com.hermes.app.core.media.HermesPlayerCoordinator
import com.hermes.study.core.media.StudyVideoPlayerView import com.hermes.app.core.media.HermesVideoPlayerView
import com.hermes.study.feature.reveal.RevealPanel import com.hermes.app.feature.reveal.RevealPanel
import com.hermes.study.feature.result.ResultPanel import com.hermes.app.feature.result.ResultPanel
import com.hermes.study.feature.selection.SelectionPanel import com.hermes.app.feature.selection.SelectionPanel
import com.hermes.study.core.designsystem.HermesPrimaryButton import com.hermes.app.core.designsystem.HermesPrimaryButton
@Composable @Composable
fun RoundScreen( fun RoundScreen(
@@ -42,7 +42,7 @@ fun RoundScreen(
) { ) {
HermesCard(modifier = modifier, elevated = true) { HermesCard(modifier = modifier, elevated = true) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { 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 { when {
uiState.isLoading && !uiState.hasRound -> RoundLoadingState( uiState.isLoading && !uiState.hasRound -> RoundLoadingState(
@@ -67,7 +67,7 @@ fun RoundScreen(
.height(220.dp), .height(220.dp),
) { ) {
if (uiState.hasRound) { if (uiState.hasRound) {
StudyVideoPlayerView( HermesVideoPlayerView(
coordinator = playerCoordinator, coordinator = playerCoordinator,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
@@ -1,17 +1,17 @@
package com.hermes.study.feature.round package com.hermes.app.feature.round
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.hermes.study.R import com.hermes.app.R
import com.hermes.study.core.analytics.HermesAnalyticsTracker import com.hermes.app.core.analytics.HermesAnalyticsTracker
import com.hermes.study.core.errors.mapUserFacingError import com.hermes.app.core.errors.mapUserFacingError
import com.hermes.study.core.localization.HermesLocalizationStore import com.hermes.app.core.localization.HermesLocalizationStore
import com.hermes.study.core.media.HermesPlayerCoordinator import com.hermes.app.core.media.HermesPlayerCoordinator
import com.hermes.study.data.HermesRepository import com.hermes.app.data.HermesRepository
import com.hermes.study.domain.HermesOutcome import com.hermes.app.domain.HermesOutcome
import com.hermes.study.domain.HermesBetIntentRequest import com.hermes.app.domain.HermesBetIntentRequest
import com.hermes.study.domain.StudyRound import com.hermes.app.domain.HermesRound
import com.hermes.study.feature.selection.SelectionOptionUi import com.hermes.app.feature.selection.SelectionOptionUi
import java.util.Locale import java.util.Locale
import java.util.UUID import java.util.UUID
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -74,7 +74,7 @@ data class RoundUiState(
) )
private data class RoundUiInputs( private data class RoundUiInputs(
val round: StudyRound?, val round: HermesRound?,
val localeCode: String, val localeCode: String,
val phase: RoundPhase, val phase: RoundPhase,
val selectedOutcomeId: String?, val selectedOutcomeId: String?,
@@ -98,7 +98,7 @@ class RoundViewModel(
private val isSubmittingSelectionFlow = MutableStateFlow(false) private val isSubmittingSelectionFlow = MutableStateFlow(false)
private val nowFlow = flow { private val nowFlow = flow {
while (true) { while (true) {
emit(Clock.System.now()) emit(repository.serverNow())
delay(1_000) delay(1_000)
} }
} }
@@ -128,7 +128,7 @@ class RoundViewModel(
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.Eagerly, started = SharingStarted.Eagerly,
initialValue = buildUiState( initialValue = buildUiState(
RoundUiInputs( RoundUiInputs(
round = roundFlow.value, round = roundFlow.value,
localeCode = localeFlow.value, localeCode = localeFlow.value,
@@ -139,7 +139,7 @@ class RoundViewModel(
actionMessage = actionMessageFlow.value, actionMessage = actionMessageFlow.value,
isSubmitting = isSubmittingSelectionFlow.value, isSubmitting = isSubmittingSelectionFlow.value,
), ),
Clock.System.now(), repository.serverNow(),
), ),
) )
@@ -160,7 +160,7 @@ class RoundViewModel(
return return
} }
if (isTimerLocked(round, Clock.System.now())) { if (isTimerLocked(round, repository.serverNow())) {
return return
} }
@@ -182,7 +182,7 @@ class RoundViewModel(
return return
} }
if (isTimerLocked(round, Clock.System.now())) { if (isTimerLocked(round, repository.serverNow())) {
return return
} }
@@ -280,7 +280,7 @@ class RoundViewModel(
} }
} }
private fun startPreview(round: StudyRound) { private fun startPreview(round: HermesRound) {
transitionJob?.cancel() transitionJob?.cancel()
phaseFlow.value = RoundPhase.PREVIEW phaseFlow.value = RoundPhase.PREVIEW
selectedOutcomeIdFlow.value = null selectedOutcomeIdFlow.value = null
@@ -368,7 +368,7 @@ class RoundViewModel(
return localizationStore.string(localeCode, resId) return localizationStore.string(localeCode, resId)
} }
private fun selectionOptions(round: StudyRound, localeCode: String): List<SelectionOptionUi> { private fun selectionOptions(round: HermesRound, localeCode: String): List<SelectionOptionUi> {
val oddsByOutcomeId = round.oddsVersion.odds.associateBy { it.outcomeId } val oddsByOutcomeId = round.oddsVersion.odds.associateBy { it.outcomeId }
return round.market.outcomes 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) { if (selectedOutcomeId == null) {
return localized(localeCode, R.string.round_selection_prompt) return localized(localeCode, R.string.round_selection_prompt)
} }
@@ -400,12 +400,12 @@ class RoundViewModel(
?: localized(localeCode, R.string.round_selection_prompt) ?: 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) } return round.market.outcomes.firstOrNull { it.id == round.settlement.winningOutcomeId }?.let { outcomeTitle(localeCode, it) }
?: localized(localeCode, R.string.round_selection_prompt) ?: 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 } val oddsByOutcomeId = round.oddsVersion.odds.associateBy { it.outcomeId }
return round.market.outcomes return round.market.outcomes
.sortedBy { it.sortOrder } .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() 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) return mapOf("screen_name" to "round", "outcome_id" to outcomeId)
} }
private fun roundAnalyticsAttributes(round: StudyRound): Map<String, String> { private fun roundAnalyticsAttributes(round: HermesRound): Map<String, String> {
return mapOf( return mapOf(
"screen_name" to "round", "screen_name" to "round",
"event_id" to round.event.id, "event_id" to round.event.id,
@@ -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.background
import androidx.compose.foundation.layout.Arrangement 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.draw.clip
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.hermes.study.core.designsystem.HermesColors import com.hermes.app.core.designsystem.HermesColors
import com.hermes.study.core.designsystem.HermesPrimaryButton import com.hermes.app.core.designsystem.HermesPrimaryButton
data class SelectionOptionUi( data class SelectionOptionUi(
val id: String, val id: String,
@@ -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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -7,14 +7,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.hermes.study.R import com.hermes.app.R
import com.hermes.study.core.designsystem.HermesCard import com.hermes.app.core.designsystem.HermesCard
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import com.hermes.study.core.designsystem.HermesColors import com.hermes.app.core.designsystem.HermesColors
import com.hermes.study.core.designsystem.HermesPrimaryButton import com.hermes.app.core.designsystem.HermesPrimaryButton
import com.hermes.study.core.designsystem.HermesSectionHeader import com.hermes.app.core.designsystem.HermesSectionHeader
import com.hermes.study.core.localization.HermesLocalizationStore import com.hermes.app.core.localization.HermesLocalizationStore
@Composable @Composable
fun SessionView( fun SessionView(
@@ -64,7 +64,7 @@ fun SessionView(
) )
SessionRow( SessionRow(
label = localizationStore.string(uiState.localeCode, R.string.session_locale_label), label = localizationStore.string(uiState.localeCode, R.string.session_locale_label),
value = uiState.localeCode.uppercase(), value = localizationStore.localeName(uiState.localeCode),
) )
SessionRow( SessionRow(
label = localizationStore.string(uiState.localeCode, R.string.session_started_label), label = localizationStore.string(uiState.localeCode, R.string.session_started_label),
@@ -1,13 +1,13 @@
package com.hermes.study.feature.session package com.hermes.app.feature.session
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.hermes.study.R import com.hermes.app.R
import com.hermes.study.core.analytics.HermesAnalyticsTracker import com.hermes.app.core.analytics.HermesAnalyticsTracker
import com.hermes.study.core.errors.mapUserFacingError import com.hermes.app.core.errors.mapUserFacingError
import com.hermes.study.core.localization.HermesLocalizationStore import com.hermes.app.core.localization.HermesLocalizationStore
import com.hermes.study.data.HermesRepository import com.hermes.app.data.HermesRepository
import com.hermes.study.domain.HermesSessionStartRequest import com.hermes.app.domain.HermesSessionStartRequest
import java.util.Locale import java.util.Locale
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -8,14 +8,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.hermes.study.R import com.hermes.app.R
import com.hermes.study.core.designsystem.HermesCard import com.hermes.app.core.designsystem.HermesCard
import com.hermes.study.core.designsystem.HermesColors import com.hermes.app.core.designsystem.HermesColors
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import com.hermes.study.core.localization.HermesLocalizationStore import com.hermes.app.core.localization.HermesLocalizationStore
import com.hermes.study.core.designsystem.HermesSectionHeader import com.hermes.app.core.designsystem.HermesSectionHeader
import java.util.Locale
@Composable @Composable
fun SettingsView( fun SettingsView(
@@ -32,7 +31,7 @@ fun SettingsView(
SettingRow( SettingRow(
label = localizationStore.string(localeCode, R.string.settings_language), label = localizationStore.string(localeCode, R.string.settings_language),
value = localeCode.uppercase(Locale.US), value = localizationStore.localeName(localeCode),
) )
SettingRow( SettingRow(
label = localizationStore.string(localeCode, R.string.settings_haptics), label = localizationStore.string(localeCode, R.string.settings_haptics),
@@ -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<T : ViewModel>(private val creator: () -> T) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = creator() as T
}
@@ -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<String, String>,
val timestamp: Instant,
)
class HermesAnalyticsTracker {
private val eventsFlow = MutableSharedFlow<HermesTrackedEvent>(extraBufferCapacity = 64)
val events: SharedFlow<HermesTrackedEvent> = eventsFlow.asSharedFlow()
fun track(name: String, attributes: Map<String, String> = emptyMap()) {
val event = HermesTrackedEvent(name, attributes, Clock.System.now())
eventsFlow.tryEmit(event)
Log.d("HermesAnalytics", "${event.name} ${event.attributes}")
}
}
@@ -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,
)
}
}
@@ -1,7 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">Hermes</string> <string name="app_name">Hermes</string>
<string name="app_subtitle">Native prototyp för studien</string> <string name="app_subtitle">Native Hermes-prototyp</string>
<string name="locale_english">Engelska</string>
<string name="locale_swedish">Svenska</string>
<string name="common_continue">Fortsätt</string> <string name="common_continue">Fortsätt</string>
<string name="common_cancel">Avbryt</string> <string name="common_cancel">Avbryt</string>
<string name="common_close">Stäng</string> <string name="common_close">Stäng</string>
@@ -11,7 +13,7 @@
<string name="errors_network">Nätverksfel. Kontrollera anslutningen.</string> <string name="errors_network">Nätverksfel. Kontrollera anslutningen.</string>
<string name="errors_playback">Videouppspelningen misslyckades.</string> <string name="errors_playback">Videouppspelningen misslyckades.</string>
<string name="errors_session_expired">Sessionen har gått ut. Starta igen.</string> <string name="errors_session_expired">Sessionen har gått ut. Starta igen.</string>
<string name="onboarding_title">Studieintro</string> <string name="onboarding_title">Välkommen till Hermes</string>
<string name="onboarding_subtitle">Titta på klippet, välj före låsning och se sedan avslöjandet.</string> <string name="onboarding_subtitle">Titta på klippet, välj före låsning och se sedan avslöjandet.</string>
<string name="onboarding_consent_body">Den här prototypen är för forskning och använder inga riktiga pengar.</string> <string name="onboarding_consent_body">Den här prototypen är för forskning och använder inga riktiga pengar.</string>
<string name="onboarding_consent_note">Du kan byta språk när som helst.</string> <string name="onboarding_consent_note">Du kan byta språk när som helst.</string>
@@ -1,7 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">Hermes</string> <string name="app_name">Hermes</string>
<string name="app_subtitle">Native study app prototype</string> <string name="app_subtitle">Native Hermes app prototype</string>
<string name="locale_english">English</string>
<string name="locale_swedish">Swedish</string>
<string name="common_continue">Continue</string> <string name="common_continue">Continue</string>
<string name="common_cancel">Cancel</string> <string name="common_cancel">Cancel</string>
<string name="common_close">Close</string> <string name="common_close">Close</string>
@@ -11,7 +13,7 @@
<string name="errors_network">Network error. Check your connection.</string> <string name="errors_network">Network error. Check your connection.</string>
<string name="errors_playback">Video playback failed.</string> <string name="errors_playback">Video playback failed.</string>
<string name="errors_session_expired">Session expired. Please start again.</string> <string name="errors_session_expired">Session expired. Please start again.</string>
<string name="onboarding_title">Study intro</string> <string name="onboarding_title">Welcome to Hermes</string>
<string name="onboarding_subtitle">Watch the clip, decide before lock, then see the reveal.</string> <string name="onboarding_subtitle">Watch the clip, decide before lock, then see the reveal.</string>
<string name="onboarding_consent_body">This prototype is for research and does not use real money.</string> <string name="onboarding_consent_body">This prototype is for research and does not use real money.</string>
<string name="onboarding_consent_note">You can switch languages at any time.</string> <string name="onboarding_consent_note">You can switch languages at any time.</string>
+45 -1
View File
@@ -1,15 +1,59 @@
import Foundation
import UIKit
import SwiftUI import SwiftUI
@main @main
struct HermesApp: App { 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 analytics = HermesAnalyticsClient()
@StateObject private var playerCoordinator = PlayerCoordinator()
@State private var isBootstrapping = false
var body: some Scene { var body: some Scene {
WindowGroup { 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) .preferredColorScheme(.dark)
.tint(HermesTheme.accent) .tint(HermesTheme.accent)
.environmentObject(analytics) .environmentObject(analytics)
.environmentObject(repository)
.environmentObject(playerCoordinator)
} }
} }
} }
@@ -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)
}
+145
View File
@@ -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
)
}
}
+18 -5
View File
@@ -3,15 +3,19 @@ import SwiftUI
struct RootView: View { struct RootView: View {
@StateObject private var localization = LocalizationStore() @StateObject private var localization = LocalizationStore()
@EnvironmentObject private var analytics: HermesAnalyticsClient @EnvironmentObject private var analytics: HermesAnalyticsClient
@EnvironmentObject private var repository: HermesRepository
let onStartSession: (String) -> Void
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 24) { VStack(alignment: .leading, spacing: 24) {
header header
OnboardingView() OnboardingView(onStartSession: { onStartSession(localization.localeCode) })
FeedView() FeedView(onWatchPreview: {}, onRetry: { onStartSession(localization.localeCode) })
RoundView() RoundView(onRetry: { onStartSession(localization.localeCode) })
SessionView(onRetry: { onStartSession(localization.localeCode) })
} }
.padding(.horizontal, HermesTheme.screenPadding) .padding(.horizontal, HermesTheme.screenPadding)
.padding(.vertical, 24) .padding(.vertical, 24)
@@ -25,6 +29,15 @@ struct RootView: View {
analytics.track("app_opened", attributes: ["screen_name": "home"]) analytics.track("app_opened", attributes: ["screen_name": "home"])
analytics.track("screen_viewed", 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 { private var header: some View {
@@ -46,8 +59,8 @@ struct RootView: View {
private var localeToggle: some View { private var localeToggle: some View {
HStack(spacing: 8) { HStack(spacing: 8) {
localeButton(title: "EN", localeCode: "en") localeButton(title: localization.localeName(for: "en"), localeCode: "en")
localeButton(title: "SV", localeCode: "sv") localeButton(title: localization.localeName(for: "sv"), localeCode: "sv")
} }
} }
@@ -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
}
}
} }
@@ -36,6 +36,11 @@ final class LocalizationStore: ObservableObject {
return value 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 { private func fallbackString(for key: String, localeCode: String) -> String {
guard localeCode != Self.fallbackLocaleCode else { guard localeCode != Self.fallbackLocaleCode else {
return key return key
@@ -1,7 +1,7 @@
import AVKit import AVKit
import SwiftUI import SwiftUI
struct StudyVideoPlayerView: View { struct HermesVideoPlayerView: View {
@ObservedObject var coordinator: PlayerCoordinator @ObservedObject var coordinator: PlayerCoordinator
var body: some View { var body: some View {
@@ -9,13 +9,15 @@ final class PlayerCoordinator: ObservableObject {
@Published var isPlaying = false @Published var isPlaying = false
@Published var playbackPositionMs: Int = 0 @Published var playbackPositionMs: Int = 0
init(previewURL: URL = URL(string: "https://cdn.example.com/hermes/sample-event/master.m3u8")!) { init() {
self.player = AVPlayer(url: previewURL) self.player = AVPlayer()
self.player.actionAtItemEnd = .pause self.player.actionAtItemEnd = .pause
} }
func prepareForPreview() { func prepareForPreview(url: URL, startTimeMs: Int = 0) {
player.seek(to: .zero) player.replaceCurrentItem(with: AVPlayerItem(url: url))
let startTime = CMTime(seconds: Double(startTimeMs) / 1_000.0, preferredTimescale: 1_000)
player.seek(to: startTime)
player.play() player.play()
isPlaying = true isPlaying = true
} }
@@ -30,8 +32,7 @@ final class PlayerCoordinator: ObservableObject {
isPlaying = false isPlaying = false
} }
func restart() { func restart(url: URL, startTimeMs: Int = 0) {
player.seek(to: .zero) prepareForPreview(url: url, startTimeMs: startTimeMs)
play()
} }
} }
@@ -42,6 +42,10 @@ struct HermesAPIClient {
try await send(path: "api/v1/session/start", method: "POST", body: payload) 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 { func endSession() async throws -> HermesSessionResponse {
try await send(path: "api/v1/session/end", method: "POST") try await send(path: "api/v1/session/end", method: "POST")
} }
@@ -23,6 +23,17 @@ struct HermesSessionResponse: Codable {
var devicePlatform: String 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 { struct HermesEvent: Codable {
var id: UUID var id: UUID
var sportType: String var sportType: String
@@ -94,6 +105,22 @@ struct HermesEventManifest: Codable {
var markets: [HermesMarket] 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 { struct HermesBetIntentRequest: Codable {
var sessionId: UUID var sessionId: UUID
var eventId: UUID var eventId: UUID
+155 -37
View File
@@ -3,55 +3,173 @@ import SwiftUI
struct FeedView: View { struct FeedView: View {
@EnvironmentObject private var localization: LocalizationStore @EnvironmentObject private var localization: LocalizationStore
@EnvironmentObject private var analytics: HermesAnalyticsClient @EnvironmentObject private var analytics: HermesAnalyticsClient
@EnvironmentObject private var repository: HermesRepository
let onWatchPreview: () -> Void = {}
let onRetry: () -> Void
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) { TimelineView(.periodic(from: Date(), by: 1)) { _ in
HermesSectionHeader( let round = repository.currentRound
title: localization.string(for: "feed.title"), let now = repository.serverNow()
subtitle: localization.string(for: "feed.subtitle") let bannerMessage = hermesUserFacingErrorMessage(
localization: localization,
localeCode: localization.localeCode,
error: repository.errorCause
) )
ZStack(alignment: .bottomLeading) { VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) {
RoundedRectangle(cornerRadius: HermesTheme.cornerRadius, style: .continuous) HermesSectionHeader(
.fill( title: localization.string(for: "feed.title"),
LinearGradient( subtitle: localization.string(for: "feed.subtitle")
colors: [HermesTheme.surfaceElevated, HermesTheme.background], )
startPoint: .topLeading,
endPoint: .bottomTrailing if round == nil {
if let bannerMessage {
feedErrorState(
message: bannerMessage,
retryText: localization.string(for: "common.retry"),
onRetry: onRetry
) )
) } else {
.frame(height: 220) 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) { heroCard(round: round)
Text(localization.string(for: "feed.hero_title"))
.font(.title2.weight(.bold))
.foregroundStyle(HermesTheme.textPrimary)
Text(localization.string(for: "feed.hero_subtitle")) HStack(spacing: 12) {
.font(.callout) HermesMetricPill(
.foregroundStyle(HermesTheme.textSecondary) label: localization.string(for: "feed.lock_label"),
.frame(maxWidth: 260, alignment: .leading) 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)
} }
.onAppear {
HStack(spacing: 12) { analytics.track("feed_viewed", attributes: ["screen_name": "feed"])
HermesMetricPill(label: localization.string(for: "feed.lock_label"), value: "01:42") analytics.track("screen_viewed", attributes: ["screen_name": "feed"])
HermesMetricPill(label: localization.string(for: "feed.odds_label"), value: "1.85 / 2.05")
} }
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) .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: " / ")
}
} }
@@ -4,6 +4,8 @@ struct OnboardingView: View {
@EnvironmentObject private var localization: LocalizationStore @EnvironmentObject private var localization: LocalizationStore
@EnvironmentObject private var analytics: HermesAnalyticsClient @EnvironmentObject private var analytics: HermesAnalyticsClient
let onStartSession: () -> Void
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) { VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) {
HermesSectionHeader( HermesSectionHeader(
@@ -27,6 +29,7 @@ struct OnboardingView: View {
Button { Button {
analytics.track("consent_accepted", attributes: ["screen_name": "onboarding"]) analytics.track("consent_accepted", attributes: ["screen_name": "onboarding"])
analytics.track("cta_pressed", attributes: ["screen_name": "onboarding", "action": "start_session"]) analytics.track("cta_pressed", attributes: ["screen_name": "onboarding", "action": "start_session"])
onStartSession()
} label: { } label: {
Text(localization.string(for: "onboarding.start_session")) Text(localization.string(for: "onboarding.start_session"))
} }
@@ -13,8 +13,6 @@ struct ResultView: View {
let nextRoundTitle: String let nextRoundTitle: String
let onNextRound: () -> Void let onNextRound: () -> Void
@EnvironmentObject private var analytics: HermesAnalyticsClient
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) { VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) {
HermesSectionHeader(title: title, subtitle: subtitle) HermesSectionHeader(title: title, subtitle: subtitle)
@@ -33,16 +31,11 @@ struct ResultView: View {
} }
Button { Button {
analytics.track("next_round_requested", attributes: ["screen_name": "result"])
onNextRound() onNextRound()
} label: { } label: {
Text(nextRoundTitle) Text(nextRoundTitle)
} }
.buttonStyle(HermesPrimaryButtonStyle()) .buttonStyle(HermesPrimaryButtonStyle())
} }
.onAppear {
analytics.track("screen_viewed", attributes: ["screen_name": "result"])
analytics.track("result_viewed", attributes: ["screen_name": "result", "selection": selectionValue, "outcome": outcomeValue])
}
} }
} }
@@ -9,8 +9,6 @@ struct RevealView: View {
let continueTitle: String let continueTitle: String
let onContinue: () -> Void let onContinue: () -> Void
@EnvironmentObject private var analytics: HermesAnalyticsClient
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) { VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) {
HermesSectionHeader(title: title, subtitle: subtitle) HermesSectionHeader(title: title, subtitle: subtitle)
@@ -24,16 +22,11 @@ struct RevealView: View {
} }
Button { Button {
analytics.track("reveal_completed", attributes: ["screen_name": "reveal", "selection": selectionValue])
onContinue() onContinue()
} label: { } label: {
Text(continueTitle) Text(continueTitle)
} }
.buttonStyle(HermesPrimaryButtonStyle()) .buttonStyle(HermesPrimaryButtonStyle())
} }
.onAppear {
analytics.track("screen_viewed", attributes: ["screen_name": "reveal"])
analytics.track("reveal_started", attributes: ["screen_name": "reveal", "selection": selectionValue])
}
} }
} }
+326 -135
View File
@@ -3,15 +3,17 @@ import SwiftUI
struct RoundView: View { struct RoundView: View {
@EnvironmentObject private var localization: LocalizationStore @EnvironmentObject private var localization: LocalizationStore
@EnvironmentObject private var analytics: HermesAnalyticsClient @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 phase: Phase = .preview
@State private var selectedOutcomeID: String? = nil @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<Void, Never>? @State private var transitionTask: Task<Void, Never>?
@State private var actionMessage: String?
private let previewDuration: TimeInterval = 47 @State private var isSubmitting = false
private let winningOutcomeID = "home"
private enum Phase { private enum Phase {
case preview case preview
@@ -20,40 +22,19 @@ struct RoundView: View {
case result 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 { var body: some View {
TimelineView(.periodic(from: Date(), by: 1)) { context in TimelineView(.periodic(from: Date(), by: 1)) { _ in
let remaining = max(lockAt.timeIntervalSince(context.date), 0) let round = repository.currentRound
let timerLocked = remaining <= 0 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 countdownText = Self.countdownText(for: remaining)
let bannerMessage = actionMessage ?? hermesUserFacingErrorMessage(
localization: localization,
localeCode: localization.localeCode,
error: repository.errorCause
)
VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) { VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) {
HermesSectionHeader( HermesSectionHeader(
@@ -61,15 +42,87 @@ struct RoundView: View {
subtitle: localization.string(for: "round.subtitle") 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) .hermesCard(elevated: true)
.onAppear {
startPreview()
}
.onDisappear { .onDisappear {
transitionTask?.cancel() transitionTask?.cancel()
playerCoordinator.pause() playerCoordinator.pause()
@@ -77,65 +130,36 @@ struct RoundView: View {
} }
@ViewBuilder @ViewBuilder
private func phaseContent(isTimerLocked: Bool) -> some View { private func videoSection(round: HermesRound?, countdownText: String, remaining: TimeInterval, 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 {
ZStack(alignment: .topTrailing) { ZStack(alignment: .topTrailing) {
StudyVideoPlayerView(coordinator: playerCoordinator) RoundedRectangle(cornerRadius: HermesTheme.cornerRadius, style: .continuous)
.fill(HermesTheme.surfaceElevated)
.frame(height: 220)
VStack(alignment: .trailing, spacing: 10) { if let round {
HermesCountdownBadge( HermesVideoPlayerView(coordinator: playerCoordinator)
label: localization.string(for: "round.countdown_label"), } else {
value: countdownText, Text(localization.string(for: "round.video_placeholder"))
warning: !isTimerLocked && remaining <= 10 .font(.headline.weight(.semibold))
) .foregroundStyle(HermesTheme.textSecondary)
}
HermesMetricPill(
label: localization.string(for: "round.odds_label"), if let round {
value: "1.85 / 2.05" 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)) Text(phaseLabel(isTimerLocked: isTimerLocked))
.font(.caption.weight(.bold)) .font(.caption.weight(.bold))
@@ -147,35 +171,25 @@ struct RoundView: View {
.padding(12) .padding(12)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
} }
.clipShape(RoundedRectangle(cornerRadius: HermesTheme.cornerRadius, style: .continuous))
} }
private func phaseLabel(isTimerLocked: Bool) -> String { private func startPreview(_ round: HermesRound) {
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() {
transitionTask?.cancel() transitionTask?.cancel()
phase = .preview phase = .preview
lockAt = Date().addingTimeInterval(previewDuration)
selectedOutcomeID = nil 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("round_loaded", attributes: roundAnalyticsAttributes(round))
analytics.track("preview_started", attributes: ["screen_name": "round"]) analytics.track("preview_started", attributes: roundAnalyticsAttributes(round))
analytics.track("screen_viewed", attributes: ["screen_name": "round"]) analytics.track("screen_viewed", attributes: ["screen_name": "round"])
} }
private func handleSelection(_ option: SelectionOption) { private func handleSelection(_ option: SelectionOption) {
guard phase == .preview else { guard phase == .preview, !isSubmitting else {
return return
} }
@@ -185,39 +199,204 @@ struct RoundView: View {
} }
private func confirmSelection() { private func confirmSelection() {
guard phase == .preview, let selectedOutcomeID else { guard phase == .preview, !isSubmitting, let round = repository.currentRound else {
return return
} }
analytics.track("selection_submitted", attributes: ["screen_name": "round", "outcome_id": selectedOutcomeID]) guard let selectedOutcomeID, let session = repository.currentSession else {
analytics.track("selection_accepted", attributes: ["screen_name": "round", "outcome_id": selectedOutcomeID]) actionMessage = localization.string(for: "errors.session_expired")
analytics.track("market_locked", attributes: ["screen_name": "round", "lock_reason": "manual_selection"]) return
}
phase = .locked guard let outcomeID = UUID(uuidString: selectedOutcomeID) ?? round.market.outcomes.first?.id else {
playerCoordinator.pause() actionMessage = localization.string(for: "errors.generic")
return
}
transitionTask?.cancel() if repository.serverNow() >= round.event.lockAt {
transitionTask = Task { @MainActor in return
try? await Task.sleep(nanoseconds: 750_000_000) }
guard !Task.isCancelled else {
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() { 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 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() transitionTask?.cancel()
selectedOutcomeID = nil actionMessage = nil
phase = .preview
lockAt = Date().addingTimeInterval(previewDuration) Task { @MainActor in
playerCoordinator.restart() 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 { private static func countdownText(for remaining: TimeInterval) -> String {
@@ -226,4 +405,16 @@ struct RoundView: View {
let seconds = totalSeconds % 60 let seconds = totalSeconds % 60
return String(format: "%02d:%02d", minutes, seconds) 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,
]
}
} }
@@ -1,7 +1,147 @@
import SwiftUI import SwiftUI
struct SessionView: View { 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 { 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
}()
} }
+1 -1
View File
@@ -1,6 +1,6 @@
# iOS App # iOS App
Native SwiftUI client scaffold for the Hermes study app. Native SwiftUI client scaffold for the Hermes app.
Planned structure: Planned structure:
@@ -1,6 +1,13 @@
"app.name" = "Hermes"; "app.name" = "Hermes";
"app.subtitle" = "Native study app prototype"; "app.subtitle" = "Native Hermes app prototype";
"onboarding.title" = "Study intro"; "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.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_body" = "This prototype is for research and does not use real money.";
"onboarding.consent_note" = "You can switch languages at any time."; "onboarding.consent_note" = "You can switch languages at any time.";
@@ -35,3 +42,17 @@
"result.win" = "Winning selection"; "result.win" = "Winning selection";
"result.lose" = "Not this time"; "result.lose" = "Not this time";
"result.next_round" = "Next round"; "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";
@@ -1,6 +1,13 @@
"app.name" = "Hermes"; "app.name" = "Hermes";
"app.subtitle" = "Native prototype för studien"; "app.subtitle" = "Native Hermes-prototyp";
"onboarding.title" = "Studieintro"; "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.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_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."; "onboarding.consent_note" = "Du kan byta språk när som helst.";
@@ -35,3 +42,17 @@
"result.win" = "Vinnande val"; "result.win" = "Vinnande val";
"result.lose" = "Inte denna gång"; "result.lose" = "Inte denna gång";
"result.next_round" = "Nästa runda"; "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";