scaffolding hermes flow and audit logging
This commit is contained in:
@@ -1,5 +1,27 @@
|
||||
# Project Template: Native Betting Study App
|
||||
|
||||
This is the canonical working plan for the project. Use this file for progress notes, next steps, and status updates.
|
||||
|
||||
## Current Status
|
||||
|
||||
### Done So Far
|
||||
|
||||
- Android was renamed from the old study app into `com.hermes.app`, with the app entry, theme, repository, media, and feature screens rewritten around the backend-backed Hermes flow.
|
||||
- Android no longer relies on the sample fixture; it boots from backend session and round data, uses `/health.server_time` for clock sync, buffers analytics, and flushes them to the backend.
|
||||
- Android localization is in place for English and Swedish, including the remaining locale toggle labels and session language display.
|
||||
- Backend now exposes `server_time` from `/health`, publishes the matching OpenAPI contract, and records audit events for session, bet, event, market, odds, and settlement actions.
|
||||
- Backend smoke coverage was added for `server_time` and audit logging.
|
||||
- iOS now follows the backend-backed flow, syncs clock from `/health`, flushes analytics, and uses localized language labels instead of hardcoded `EN` and `SV`.
|
||||
- iOS source was updated for the backend-backed session and round flow, including the real preview cue points and localized session strings.
|
||||
- Android debug build passes with `./gradlew :app:assembleDebug`.
|
||||
- Backend tests pass with `cargo test`.
|
||||
|
||||
### Still Open
|
||||
|
||||
- Continue through the remaining plan phases and finish any leftover localization and polish work.
|
||||
- Add iOS build validation once an Xcode project is available in `mobile/ios-app`.
|
||||
- Keep expanding tests around session, odds, settlement, and analytics behavior.
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
Build a native mobile prototype for iOS and Android for a research
|
||||
|
||||
+23
@@ -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
@@ -16,6 +16,7 @@ pub use crate::sessions::{SessionSnapshot, SessionStartRequest};
|
||||
|
||||
use crate::{
|
||||
analytics::AnalyticsStore,
|
||||
audit::AuditStore,
|
||||
bets::{
|
||||
BetsStore, ACCEPTANCE_ACCEPTED, ACCEPTANCE_REJECTED_INVALID_MARKET,
|
||||
ACCEPTANCE_REJECTED_INVALID_SESSION, ACCEPTANCE_REJECTED_TOO_LATE,
|
||||
@@ -43,6 +44,7 @@ pub struct AppState {
|
||||
experiments: ExperimentsStore,
|
||||
localization: LocalizationStore,
|
||||
analytics: AnalyticsStore,
|
||||
audit: AuditStore,
|
||||
markets: MarketsStore,
|
||||
users: UsersStore,
|
||||
sessions: SessionsStore,
|
||||
@@ -65,6 +67,7 @@ impl AppState {
|
||||
experiments: ExperimentsStore::new(),
|
||||
localization: LocalizationStore::new(),
|
||||
analytics: AnalyticsStore::new(),
|
||||
audit: AuditStore::new(),
|
||||
markets: MarketsStore::new(),
|
||||
users: UsersStore::new(),
|
||||
sessions: SessionsStore::new(),
|
||||
@@ -87,7 +90,8 @@ impl AppState {
|
||||
})
|
||||
.await;
|
||||
|
||||
self.sessions
|
||||
let session = self
|
||||
.sessions
|
||||
.start_session(
|
||||
user.id,
|
||||
request,
|
||||
@@ -98,7 +102,24 @@ impl AppState {
|
||||
default_experiment_variant: self.config.default_experiment_variant.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.await;
|
||||
|
||||
self.audit
|
||||
.record(
|
||||
"session_started",
|
||||
Some(session.session_id),
|
||||
Some(session.user_id),
|
||||
Utc::now(),
|
||||
[
|
||||
("locale_code", session.locale_code.clone()),
|
||||
("device_platform", session.device_platform.clone()),
|
||||
("app_version", session.app_version.clone()),
|
||||
("experiment_variant", session.experiment_variant.clone()),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
session
|
||||
}
|
||||
|
||||
pub async fn current_session(&self) -> Option<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 {
|
||||
let Some(existing) = self
|
||||
.bets
|
||||
@@ -143,7 +198,7 @@ impl AppState {
|
||||
|
||||
async fn submit_bet_intent_fresh(&self, request: BetIntentRequest) -> BetIntentResponse {
|
||||
let Some(session) = self.current_session().await else {
|
||||
return self
|
||||
let response = self
|
||||
.bets
|
||||
.record(self.build_bet_intent_record(
|
||||
&request,
|
||||
@@ -153,10 +208,21 @@ impl AppState {
|
||||
None,
|
||||
))
|
||||
.await;
|
||||
|
||||
self.record_bet_audit(
|
||||
"bet_rejected",
|
||||
None,
|
||||
&request,
|
||||
ACCEPTANCE_REJECTED_INVALID_SESSION,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
if session.ended_at.is_some() || session.session_id != request.session_id {
|
||||
return self
|
||||
let response = self
|
||||
.bets
|
||||
.record(self.build_bet_intent_record(
|
||||
&request,
|
||||
@@ -166,10 +232,21 @@ impl AppState {
|
||||
None,
|
||||
))
|
||||
.await;
|
||||
|
||||
self.record_bet_audit(
|
||||
"bet_rejected",
|
||||
Some(&session),
|
||||
&request,
|
||||
ACCEPTANCE_REJECTED_INVALID_SESSION,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
let Some(market) = self.markets.market(request.market_id).await else {
|
||||
return self
|
||||
let response = self
|
||||
.bets
|
||||
.record(self.build_bet_intent_record(
|
||||
&request,
|
||||
@@ -179,10 +256,21 @@ impl AppState {
|
||||
None,
|
||||
))
|
||||
.await;
|
||||
|
||||
self.record_bet_audit(
|
||||
"bet_rejected",
|
||||
Some(&session),
|
||||
&request,
|
||||
ACCEPTANCE_REJECTED_INVALID_MARKET,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
if market.event_id != request.event_id {
|
||||
return self
|
||||
let response = self
|
||||
.bets
|
||||
.record(self.build_bet_intent_record(
|
||||
&request,
|
||||
@@ -192,10 +280,21 @@ impl AppState {
|
||||
None,
|
||||
))
|
||||
.await;
|
||||
|
||||
self.record_bet_audit(
|
||||
"bet_rejected",
|
||||
Some(&session),
|
||||
&request,
|
||||
ACCEPTANCE_REJECTED_INVALID_MARKET,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
let Some(outcome) = self.markets.outcome(request.outcome_id).await else {
|
||||
return self
|
||||
let response = self
|
||||
.bets
|
||||
.record(self.build_bet_intent_record(
|
||||
&request,
|
||||
@@ -205,10 +304,21 @@ impl AppState {
|
||||
None,
|
||||
))
|
||||
.await;
|
||||
|
||||
self.record_bet_audit(
|
||||
"bet_rejected",
|
||||
Some(&session),
|
||||
&request,
|
||||
ACCEPTANCE_REJECTED_INVALID_MARKET,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
if outcome.market_id != market.id || request.event_id != market.event_id {
|
||||
return self
|
||||
let response = self
|
||||
.bets
|
||||
.record(self.build_bet_intent_record(
|
||||
&request,
|
||||
@@ -218,10 +328,21 @@ impl AppState {
|
||||
None,
|
||||
))
|
||||
.await;
|
||||
|
||||
self.record_bet_audit(
|
||||
"bet_rejected",
|
||||
Some(&session),
|
||||
&request,
|
||||
ACCEPTANCE_REJECTED_INVALID_MARKET,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
if Utc::now() >= market.lock_at {
|
||||
return self
|
||||
let response = self
|
||||
.bets
|
||||
.record(self.build_bet_intent_record(
|
||||
&request,
|
||||
@@ -231,10 +352,21 @@ impl AppState {
|
||||
None,
|
||||
))
|
||||
.await;
|
||||
|
||||
self.record_bet_audit(
|
||||
"bet_rejected",
|
||||
Some(&session),
|
||||
&request,
|
||||
ACCEPTANCE_REJECTED_TOO_LATE,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
let accepted_odds_version_id = self.current_odds(market.id).await.map(|odds| odds.id);
|
||||
self.bets
|
||||
let response = self.bets
|
||||
.record(self.build_bet_intent_record(
|
||||
&request,
|
||||
Some(session.user_id),
|
||||
@@ -242,7 +374,18 @@ impl AppState {
|
||||
ACCEPTANCE_ACCEPTED,
|
||||
accepted_odds_version_id,
|
||||
))
|
||||
.await
|
||||
.await;
|
||||
|
||||
self.record_bet_audit(
|
||||
"bet_accepted",
|
||||
Some(&session),
|
||||
&request,
|
||||
ACCEPTANCE_ACCEPTED,
|
||||
accepted_odds_version_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
pub async fn bet_intent(&self, bet_intent_id: Uuid) -> Option<BetIntentResponse> {
|
||||
@@ -250,10 +393,28 @@ impl AppState {
|
||||
}
|
||||
|
||||
pub async fn end_session(&self) -> Result<SessionSnapshot, AppError> {
|
||||
self.sessions
|
||||
let session = self
|
||||
.sessions
|
||||
.end_session()
|
||||
.await
|
||||
.map_err(|_| AppError::not_found("No active session"))
|
||||
.map_err(|_| AppError::not_found("No active session"))?;
|
||||
|
||||
self.audit
|
||||
.record(
|
||||
"session_ended",
|
||||
Some(session.session_id),
|
||||
Some(session.user_id),
|
||||
Utc::now(),
|
||||
[
|
||||
("locale_code", session.locale_code.clone()),
|
||||
("device_platform", session.device_platform.clone()),
|
||||
("app_version", session.app_version.clone()),
|
||||
("experiment_variant", session.experiment_variant.clone()),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
pub async fn admin_create_event_manifest(
|
||||
@@ -266,6 +427,20 @@ impl AppState {
|
||||
self.markets.insert_market(market.clone()).await;
|
||||
}
|
||||
|
||||
self.audit
|
||||
.record(
|
||||
"event_created",
|
||||
None,
|
||||
None,
|
||||
Utc::now(),
|
||||
[
|
||||
("event_id", created.event.id.to_string()),
|
||||
("sport_type", created.event.sport_type.clone()),
|
||||
("status", created.event.status.clone()),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(created)
|
||||
}
|
||||
|
||||
@@ -279,6 +454,22 @@ impl AppState {
|
||||
|
||||
self.markets.insert_market(market.clone()).await;
|
||||
let _ = self.events.insert_market(market.event_id, market.clone()).await;
|
||||
|
||||
self.audit
|
||||
.record(
|
||||
"market_created",
|
||||
None,
|
||||
None,
|
||||
Utc::now(),
|
||||
[
|
||||
("market_id", market.id.to_string()),
|
||||
("event_id", market.event_id.to_string()),
|
||||
("market_type", market.market_type.clone()),
|
||||
("status", market.status.clone()),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(market)
|
||||
}
|
||||
|
||||
@@ -290,7 +481,23 @@ impl AppState {
|
||||
return Err(AppError::not_found("Market not found"));
|
||||
};
|
||||
|
||||
Ok(self.markets.publish_odds(odds).await)
|
||||
let created = self.markets.publish_odds(odds).await;
|
||||
|
||||
self.audit
|
||||
.record(
|
||||
"odds_version_published",
|
||||
None,
|
||||
None,
|
||||
Utc::now(),
|
||||
[
|
||||
("odds_version_id", created.id.to_string()),
|
||||
("market_id", created.market_id.to_string()),
|
||||
("version_no", created.version_no.to_string()),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(created)
|
||||
}
|
||||
|
||||
pub async fn admin_publish_settlement(
|
||||
@@ -301,7 +508,24 @@ impl AppState {
|
||||
return Err(AppError::not_found("Market not found"));
|
||||
};
|
||||
|
||||
Ok(self.settlements.upsert(market.event_id, settlement).await)
|
||||
let created = self.settlements.upsert(market.event_id, settlement).await;
|
||||
|
||||
self.audit
|
||||
.record(
|
||||
"result_settled",
|
||||
None,
|
||||
None,
|
||||
Utc::now(),
|
||||
[
|
||||
("settlement_id", created.id.to_string()),
|
||||
("market_id", created.market_id.to_string()),
|
||||
("event_id", market.event_id.to_string()),
|
||||
("winning_outcome_id", created.winning_outcome_id.to_string()),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(created)
|
||||
}
|
||||
|
||||
pub async fn next_event(&self) -> Option<EventSnapshot> {
|
||||
@@ -362,6 +586,10 @@ impl AppState {
|
||||
self.analytics.counts().await
|
||||
}
|
||||
|
||||
pub async fn audit_counts(&self) -> (usize, usize) {
|
||||
self.audit.counts().await
|
||||
}
|
||||
|
||||
pub async fn markets_for_event(&self, event_id: Uuid) -> Option<Vec<MarketSnapshot>> {
|
||||
self.markets.markets_for_event(event_id).await
|
||||
}
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod analytics;
|
||||
pub mod audit;
|
||||
pub mod admin;
|
||||
pub mod bets;
|
||||
pub mod app_state;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use axum::{extract::Extension, Json};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::app_state::AppState;
|
||||
@@ -10,6 +11,7 @@ pub struct HealthResponse {
|
||||
pub environment: String,
|
||||
pub version: String,
|
||||
pub uptime_ms: u128,
|
||||
pub server_time: DateTime<Utc>,
|
||||
pub database_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(),
|
||||
version: state.config.app_version.clone(),
|
||||
uptime_ms: state.uptime_ms(),
|
||||
server_time: Utc::now(),
|
||||
database_ready: state.database_ready(),
|
||||
redis_ready: state.redis_ready(),
|
||||
})
|
||||
|
||||
@@ -11,10 +11,91 @@ async fn health_returns_ok() {
|
||||
|
||||
let response = app
|
||||
.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
|
||||
.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]
|
||||
|
||||
@@ -259,7 +259,7 @@ components:
|
||||
schemas:
|
||||
HealthResponse:
|
||||
type: object
|
||||
required: [status, service_name, environment, version, uptime_ms, database_ready, redis_ready]
|
||||
required: [status, service_name, environment, version, uptime_ms, server_time, database_ready, redis_ready]
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
@@ -271,6 +271,9 @@ components:
|
||||
type: string
|
||||
uptime_ms:
|
||||
type: integer
|
||||
server_time:
|
||||
type: string
|
||||
format: date-time
|
||||
database_ready:
|
||||
type: boolean
|
||||
redis_ready:
|
||||
|
||||
@@ -6,11 +6,11 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.hermes.study"
|
||||
namespace = "com.hermes.app"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.hermes.study"
|
||||
applicationId = "com.hermes.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
|
||||
+26
-15
@@ -1,4 +1,4 @@
|
||||
package com.hermes.study
|
||||
package com.hermes.app
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -21,21 +21,22 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.compose.foundation.layout.width
|
||||
import com.hermes.study.R
|
||||
import com.hermes.study.core.designsystem.HermesPalette
|
||||
import com.hermes.study.core.designsystem.HermesSecondaryButton
|
||||
import com.hermes.study.core.designsystem.HermesStudyTheme
|
||||
import com.hermes.study.feature.feed.FeedScreen
|
||||
import com.hermes.study.feature.feed.FeedViewModel
|
||||
import com.hermes.study.feature.session.SessionView
|
||||
import com.hermes.study.feature.session.SessionViewModel
|
||||
import com.hermes.study.feature.settings.SettingsView
|
||||
import com.hermes.study.feature.round.RoundScreen
|
||||
import com.hermes.study.feature.round.RoundViewModel
|
||||
import com.hermes.app.R
|
||||
import com.hermes.app.core.designsystem.HermesPalette
|
||||
import com.hermes.app.core.designsystem.HermesSecondaryButton
|
||||
import com.hermes.app.core.designsystem.HermesTheme
|
||||
import com.hermes.app.core.localization.HermesLocalizationStore
|
||||
import com.hermes.app.feature.feed.FeedScreen
|
||||
import com.hermes.app.feature.feed.FeedViewModel
|
||||
import com.hermes.app.feature.session.SessionView
|
||||
import com.hermes.app.feature.session.SessionViewModel
|
||||
import com.hermes.app.feature.settings.SettingsView
|
||||
import com.hermes.app.feature.round.RoundScreen
|
||||
import com.hermes.app.feature.round.RoundViewModel
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
|
||||
@Composable
|
||||
fun HermesStudyApp(container: HermesAppContainer) {
|
||||
fun HermesApp(container: HermesAppContainer) {
|
||||
val feedViewModel: FeedViewModel = viewModel(factory = container.feedViewModelFactory())
|
||||
val roundViewModel: RoundViewModel = viewModel(factory = container.roundViewModelFactory())
|
||||
val sessionViewModel: SessionViewModel = viewModel(factory = container.sessionViewModelFactory())
|
||||
@@ -53,6 +54,7 @@ fun HermesStudyApp(container: HermesAppContainer) {
|
||||
HermesAppBar(
|
||||
title = container.localizationStore.string(localeCode, R.string.app_name),
|
||||
localeCode = localeCode,
|
||||
localizationStore = container.localizationStore,
|
||||
onLocaleSelected = container.localizationStore::setLocale,
|
||||
)
|
||||
},
|
||||
@@ -105,14 +107,23 @@ fun HermesStudyApp(container: HermesAppContainer) {
|
||||
private fun HermesAppBar(
|
||||
title: String,
|
||||
localeCode: String,
|
||||
localizationStore: HermesLocalizationStore,
|
||||
onLocaleSelected: (String) -> Unit,
|
||||
) {
|
||||
androidx.compose.material3.CenterAlignedTopAppBar(
|
||||
title = { Text(text = title) },
|
||||
actions = {
|
||||
HermesSecondaryButton(text = "EN", selected = localeCode == "en", onClick = { onLocaleSelected("en") })
|
||||
HermesSecondaryButton(
|
||||
text = localizationStore.localeName("en", localeCode),
|
||||
selected = localeCode == "en",
|
||||
onClick = { onLocaleSelected("en") },
|
||||
)
|
||||
androidx.compose.foundation.layout.Spacer(modifier = Modifier.width(8.dp))
|
||||
HermesSecondaryButton(text = "SV", selected = localeCode == "sv", onClick = { onLocaleSelected("sv") })
|
||||
HermesSecondaryButton(
|
||||
text = localizationStore.localeName("sv", localeCode),
|
||||
selected = localeCode == "sv",
|
||||
onClick = { onLocaleSelected("sv") },
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
|
||||
containerColor = HermesPalette.colors.background
|
||||
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package com.hermes.study
|
||||
package com.hermes.app
|
||||
|
||||
import android.app.Application
|
||||
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
package com.hermes.study
|
||||
package com.hermes.app
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import com.hermes.study.core.designsystem.HermesStudyTheme
|
||||
import com.hermes.app.core.designsystem.HermesTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -12,8 +12,8 @@ class MainActivity : ComponentActivity() {
|
||||
val app = application as HermesApplication
|
||||
|
||||
setContent {
|
||||
HermesStudyTheme {
|
||||
HermesStudyApp(app.container)
|
||||
HermesTheme {
|
||||
HermesApp(app.container)
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
@@ -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)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package com.hermes.study.core.designsystem
|
||||
package com.hermes.app.core.designsystem
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -57,7 +57,7 @@ private val HermesShapes = androidx.compose.material3.Shapes(
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun HermesStudyTheme(content: @Composable () -> Unit) {
|
||||
fun HermesTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(
|
||||
colorScheme = HermesColorScheme,
|
||||
typography = MaterialTheme.typography,
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
package com.hermes.study.core.errors
|
||||
package com.hermes.app.core.errors
|
||||
|
||||
import com.hermes.study.R
|
||||
import com.hermes.study.core.localization.HermesLocalizationStore
|
||||
import com.hermes.study.core.network.HermesApiException
|
||||
import com.hermes.app.R
|
||||
import com.hermes.app.core.localization.HermesLocalizationStore
|
||||
import com.hermes.app.core.network.HermesApiException
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.hermes.study.core.gestures
|
||||
package com.hermes.app.core.gestures
|
||||
|
||||
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||
import androidx.compose.ui.Modifier
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.hermes.study.core.haptics
|
||||
package com.hermes.app.core.haptics
|
||||
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.View
|
||||
+11
-1
@@ -1,7 +1,8 @@
|
||||
package com.hermes.study.core.localization
|
||||
package com.hermes.app.core.localization
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import com.hermes.app.R
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -21,6 +22,15 @@ class HermesLocalizationStore(private val appContext: Context) {
|
||||
|
||||
fun string(localeCode: String, resId: Int): String = localizedContext(normalize(localeCode)).getString(resId)
|
||||
|
||||
fun localeName(targetLocaleCode: String, displayLocaleCode: String = localeCode.value): String {
|
||||
val resId = when (normalize(targetLocaleCode)) {
|
||||
"sv" -> R.string.locale_swedish
|
||||
else -> R.string.locale_english
|
||||
}
|
||||
|
||||
return string(displayLocaleCode, resId)
|
||||
}
|
||||
|
||||
private fun localizedContext(localeCode: String): Context {
|
||||
val configuration = Configuration(appContext.resources.configuration)
|
||||
configuration.setLocale(Locale.forLanguageTag(localeCode))
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.hermes.study.core.media
|
||||
package com.hermes.app.core.media
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package com.hermes.study.core.media
|
||||
package com.hermes.app.core.media
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
@@ -7,7 +7,7 @@ import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.media3.ui.PlayerView
|
||||
|
||||
@Composable
|
||||
fun StudyVideoPlayerView(
|
||||
fun HermesVideoPlayerView(
|
||||
coordinator: HermesPlayerCoordinator,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
+17
-13
@@ -1,16 +1,18 @@
|
||||
package com.hermes.study.core.network
|
||||
package com.hermes.app.core.network
|
||||
|
||||
import com.hermes.study.domain.HermesAnalyticsBatchRequest
|
||||
import com.hermes.study.domain.HermesBetIntentRequest
|
||||
import com.hermes.study.domain.HermesBetIntentResponse
|
||||
import com.hermes.study.domain.HermesEvent
|
||||
import com.hermes.study.domain.HermesExperimentConfig
|
||||
import com.hermes.study.domain.HermesLocalizationBundle
|
||||
import com.hermes.study.domain.HermesMarket
|
||||
import com.hermes.study.domain.HermesOddsVersion
|
||||
import com.hermes.study.domain.HermesSessionResponse
|
||||
import com.hermes.study.domain.HermesSessionStartRequest
|
||||
import com.hermes.study.domain.HermesSettlement
|
||||
import com.hermes.app.domain.HermesAnalyticsBatchRequest
|
||||
import com.hermes.app.domain.HermesBetIntentRequest
|
||||
import com.hermes.app.domain.HermesBetIntentResponse
|
||||
import com.hermes.app.domain.HermesHealthResponse
|
||||
import com.hermes.app.domain.HermesEvent
|
||||
import com.hermes.app.domain.HermesEventManifest
|
||||
import com.hermes.app.domain.HermesExperimentConfig
|
||||
import com.hermes.app.domain.HermesLocalizationBundle
|
||||
import com.hermes.app.domain.HermesMarket
|
||||
import com.hermes.app.domain.HermesOddsVersion
|
||||
import com.hermes.app.domain.HermesSessionResponse
|
||||
import com.hermes.app.domain.HermesSessionStartRequest
|
||||
import com.hermes.app.domain.HermesSettlement
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
@@ -37,13 +39,15 @@ class HermesApiClient(
|
||||
|
||||
suspend fun startSession(request: HermesSessionStartRequest): HermesSessionResponse = post("api/v1/session/start", request)
|
||||
|
||||
suspend fun health(): HermesHealthResponse = get("health")
|
||||
|
||||
suspend fun endSession(): HermesSessionResponse = post("api/v1/session/end")
|
||||
|
||||
suspend fun currentSession(): HermesSessionResponse = get("api/v1/session/me")
|
||||
|
||||
suspend fun nextEvent(): HermesEvent = get("api/v1/feed/next")
|
||||
|
||||
suspend fun eventManifest(eventId: String): com.hermes.study.domain.HermesEventManifest = get("api/v1/events/$eventId/manifest")
|
||||
suspend fun eventManifest(eventId: String): HermesEventManifest = get("api/v1/events/$eventId/manifest")
|
||||
|
||||
suspend fun markets(eventId: String): List<HermesMarket> = get("api/v1/events/$eventId/markets")
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
package com.hermes.study.core.network
|
||||
package com.hermes.app.core.network
|
||||
|
||||
// Placeholder for future API-specific mapping helpers.
|
||||
+44
-20
@@ -1,17 +1,19 @@
|
||||
package com.hermes.study.data
|
||||
package com.hermes.app.data
|
||||
|
||||
import com.hermes.study.core.network.HermesApiClient
|
||||
import com.hermes.study.domain.HermesAnalyticsBatchRequest
|
||||
import com.hermes.study.domain.HermesBetIntentRequest
|
||||
import com.hermes.study.domain.HermesBetIntentResponse
|
||||
import com.hermes.study.domain.HermesExperimentConfig
|
||||
import com.hermes.study.domain.HermesLocalizationBundle
|
||||
import com.hermes.study.domain.HermesMarket
|
||||
import com.hermes.study.domain.HermesOddsVersion
|
||||
import com.hermes.study.domain.HermesSessionResponse
|
||||
import com.hermes.study.domain.HermesSessionStartRequest
|
||||
import com.hermes.study.domain.HermesSettlement
|
||||
import com.hermes.study.domain.StudyRound
|
||||
import com.hermes.app.core.network.HermesApiClient
|
||||
import com.hermes.app.domain.HermesAnalyticsBatchRequest
|
||||
import com.hermes.app.domain.HermesBetIntentRequest
|
||||
import com.hermes.app.domain.HermesBetIntentResponse
|
||||
import com.hermes.app.domain.HermesExperimentConfig
|
||||
import com.hermes.app.domain.HermesLocalizationBundle
|
||||
import com.hermes.app.domain.HermesMarket
|
||||
import com.hermes.app.domain.HermesOddsVersion
|
||||
import com.hermes.app.domain.HermesRound
|
||||
import com.hermes.app.domain.HermesSessionResponse
|
||||
import com.hermes.app.domain.HermesSessionStartRequest
|
||||
import com.hermes.app.domain.HermesSettlement
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -22,18 +24,20 @@ import kotlinx.coroutines.sync.withLock
|
||||
class HermesRepository(private val apiClient: HermesApiClient) {
|
||||
private val sessionMutex = Mutex()
|
||||
private val _currentSession = MutableStateFlow<HermesSessionResponse?>(null)
|
||||
private val _currentRound = MutableStateFlow<StudyRound?>(null)
|
||||
private val _currentRound = MutableStateFlow<HermesRound?>(null)
|
||||
private val _isLoading = MutableStateFlow(true)
|
||||
private val _errorCause = MutableStateFlow<Throwable?>(null)
|
||||
private val _serverClockOffsetMs = MutableStateFlow<Long?>(null)
|
||||
|
||||
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 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 {
|
||||
return sessionMutex.withLock {
|
||||
@@ -41,6 +45,11 @@ class HermesRepository(private val apiClient: HermesApiClient) {
|
||||
_errorCause.value = null
|
||||
|
||||
try {
|
||||
try {
|
||||
syncClock()
|
||||
} catch (_: Throwable) {
|
||||
// Clock sync is best-effort.
|
||||
}
|
||||
val session = _currentSession.value ?: startSession(request)
|
||||
if (_currentRound.value == null) {
|
||||
_currentRound.value = loadRoundFromNetwork()
|
||||
@@ -56,12 +65,17 @@ class HermesRepository(private val apiClient: HermesApiClient) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun refreshRoundFromNetwork(): StudyRound {
|
||||
suspend fun refreshRoundFromNetwork(): HermesRound {
|
||||
return sessionMutex.withLock {
|
||||
_isLoading.value = true
|
||||
_errorCause.value = null
|
||||
|
||||
try {
|
||||
try {
|
||||
syncClock()
|
||||
} catch (_: Throwable) {
|
||||
// Clock sync is best-effort.
|
||||
}
|
||||
val round = loadRoundFromNetwork()
|
||||
_currentRound.value = round
|
||||
round
|
||||
@@ -110,7 +124,17 @@ class HermesRepository(private val apiClient: HermesApiClient) {
|
||||
apiClient.submitAnalyticsBatch(request)
|
||||
}
|
||||
|
||||
private suspend fun loadRoundFromNetwork(): StudyRound {
|
||||
fun serverNow(): Instant {
|
||||
val offsetMs = _serverClockOffsetMs.value ?: 0L
|
||||
return Instant.fromEpochMilliseconds(Clock.System.now().toEpochMilliseconds() + offsetMs)
|
||||
}
|
||||
|
||||
private suspend fun syncClock() {
|
||||
val health = apiClient.health()
|
||||
_serverClockOffsetMs.value = health.serverTime.toEpochMilliseconds() - Clock.System.now().toEpochMilliseconds()
|
||||
}
|
||||
|
||||
private suspend fun loadRoundFromNetwork(): HermesRound {
|
||||
val event = apiClient.nextEvent()
|
||||
val manifest = apiClient.eventManifest(event.id)
|
||||
val media = manifest.media.firstOrNull { it.mediaType == "hls_main" } ?: manifest.media.firstOrNull()
|
||||
@@ -120,7 +144,7 @@ class HermesRepository(private val apiClient: HermesApiClient) {
|
||||
val oddsVersion = apiClient.currentOdds(market.id)
|
||||
val settlement = apiClient.settlement(event.id)
|
||||
|
||||
return StudyRound(
|
||||
return HermesRound(
|
||||
event = event,
|
||||
media = media,
|
||||
market = market,
|
||||
+14
-2
@@ -1,4 +1,4 @@
|
||||
package com.hermes.study.domain
|
||||
package com.hermes.app.domain
|
||||
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.Serializable
|
||||
@@ -28,6 +28,18 @@ data class HermesSessionResponse(
|
||||
val devicePlatform: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HermesHealthResponse(
|
||||
val status: String,
|
||||
val serviceName: String,
|
||||
val environment: String,
|
||||
val version: String,
|
||||
val uptimeMs: Long,
|
||||
val serverTime: Instant,
|
||||
val databaseReady: Boolean,
|
||||
val redisReady: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HermesEvent(
|
||||
val id: String,
|
||||
@@ -163,7 +175,7 @@ data class HermesLocalizationBundle(
|
||||
val values: Map<String, String>,
|
||||
)
|
||||
|
||||
data class StudyRound(
|
||||
data class HermesRound(
|
||||
val event: HermesEvent,
|
||||
val media: HermesEventMedia,
|
||||
val market: HermesMarket,
|
||||
+6
-6
@@ -1,4 +1,4 @@
|
||||
package com.hermes.study.feature.feed
|
||||
package com.hermes.app.feature.feed
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -17,11 +17,11 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermes.study.core.designsystem.HermesCard
|
||||
import com.hermes.study.core.designsystem.HermesColors
|
||||
import com.hermes.study.core.designsystem.HermesMetricChip
|
||||
import com.hermes.study.core.designsystem.HermesPrimaryButton
|
||||
import com.hermes.study.core.designsystem.HermesSectionHeader
|
||||
import com.hermes.app.core.designsystem.HermesCard
|
||||
import com.hermes.app.core.designsystem.HermesColors
|
||||
import com.hermes.app.core.designsystem.HermesMetricChip
|
||||
import com.hermes.app.core.designsystem.HermesPrimaryButton
|
||||
import com.hermes.app.core.designsystem.HermesSectionHeader
|
||||
|
||||
@Composable
|
||||
fun FeedScreen(
|
||||
+13
-14
@@ -1,13 +1,13 @@
|
||||
package com.hermes.study.feature.feed
|
||||
package com.hermes.app.feature.feed
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.hermes.study.R
|
||||
import com.hermes.study.core.analytics.HermesAnalyticsTracker
|
||||
import com.hermes.study.core.errors.mapUserFacingError
|
||||
import com.hermes.study.core.localization.HermesLocalizationStore
|
||||
import com.hermes.study.data.HermesRepository
|
||||
import com.hermes.study.domain.StudyRound
|
||||
import com.hermes.app.R
|
||||
import com.hermes.app.core.analytics.HermesAnalyticsTracker
|
||||
import com.hermes.app.core.errors.mapUserFacingError
|
||||
import com.hermes.app.core.localization.HermesLocalizationStore
|
||||
import com.hermes.app.data.HermesRepository
|
||||
import com.hermes.app.domain.HermesRound
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -15,7 +15,6 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
data class FeedUiState(
|
||||
@@ -35,7 +34,7 @@ data class FeedUiState(
|
||||
)
|
||||
|
||||
class FeedViewModel(
|
||||
repository: HermesRepository,
|
||||
private val repository: HermesRepository,
|
||||
private val localizationStore: HermesLocalizationStore,
|
||||
private val analyticsTracker: HermesAnalyticsTracker,
|
||||
) : ViewModel() {
|
||||
@@ -43,7 +42,7 @@ class FeedViewModel(
|
||||
private val localeFlow = localizationStore.localeCode
|
||||
private val nowFlow = flow {
|
||||
while (true) {
|
||||
emit(Clock.System.now())
|
||||
emit(repository.serverNow())
|
||||
delay(1_000)
|
||||
}
|
||||
}
|
||||
@@ -58,7 +57,7 @@ class FeedViewModel(
|
||||
localeCode = localeFlow.value,
|
||||
isLoading = repository.isLoading.value,
|
||||
errorCause = repository.errorCause.value,
|
||||
now = Clock.System.now(),
|
||||
now = repository.serverNow(),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -79,7 +78,7 @@ class FeedViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildUiState(round: StudyRound?, localeCode: String, isLoading: Boolean, errorCause: Throwable?, now: Instant): FeedUiState {
|
||||
private fun buildUiState(round: HermesRound?, localeCode: String, isLoading: Boolean, errorCause: Throwable?, now: Instant): FeedUiState {
|
||||
val bannerMessage = mapUserFacingError(localizationStore, localeCode, errorCause)
|
||||
val hasRound = round != null
|
||||
val showLoading = isLoading && !hasRound
|
||||
@@ -110,7 +109,7 @@ class FeedViewModel(
|
||||
return localizationStore.string(localeCode, resId)
|
||||
}
|
||||
|
||||
private fun localizedEventTitle(round: StudyRound, localeCode: String): String {
|
||||
private fun localizedEventTitle(round: HermesRound, localeCode: String): String {
|
||||
return if (localeCode == "sv") round.event.titleSv else round.event.titleEn
|
||||
}
|
||||
|
||||
@@ -122,7 +121,7 @@ class FeedViewModel(
|
||||
return String.format(Locale.US, "%02d:%02d", minutes, seconds)
|
||||
}
|
||||
|
||||
private fun formatOdds(round: StudyRound): String {
|
||||
private fun formatOdds(round: HermesRound): String {
|
||||
val oddsByOutcomeId = round.oddsVersion.odds.associateBy { it.outcomeId }
|
||||
return round.market.outcomes
|
||||
.sortedBy { it.sortOrder }
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
package com.hermes.study.feature.result
|
||||
package com.hermes.app.feature.result
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -14,10 +14,10 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermes.study.core.designsystem.HermesColors
|
||||
import com.hermes.study.core.designsystem.HermesMetricChip
|
||||
import com.hermes.study.core.designsystem.HermesPrimaryButton
|
||||
import com.hermes.study.core.designsystem.HermesSectionHeader
|
||||
import com.hermes.app.core.designsystem.HermesColors
|
||||
import com.hermes.app.core.designsystem.HermesMetricChip
|
||||
import com.hermes.app.core.designsystem.HermesPrimaryButton
|
||||
import com.hermes.app.core.designsystem.HermesSectionHeader
|
||||
|
||||
@Composable
|
||||
fun ResultPanel(
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
package com.hermes.study.feature.reveal
|
||||
package com.hermes.app.feature.reveal
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -6,12 +6,12 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermes.study.core.designsystem.HermesMetricChip
|
||||
import com.hermes.study.core.designsystem.HermesPrimaryButton
|
||||
import com.hermes.study.core.designsystem.HermesSectionHeader
|
||||
import com.hermes.app.core.designsystem.HermesMetricChip
|
||||
import com.hermes.app.core.designsystem.HermesPrimaryButton
|
||||
import com.hermes.app.core.designsystem.HermesSectionHeader
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import com.hermes.study.core.designsystem.HermesColors
|
||||
import com.hermes.app.core.designsystem.HermesColors
|
||||
|
||||
@Composable
|
||||
fun RevealPanel(
|
||||
+13
-13
@@ -1,4 +1,4 @@
|
||||
package com.hermes.study.feature.round
|
||||
package com.hermes.app.feature.round
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -18,16 +18,16 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermes.study.core.designsystem.HermesColors
|
||||
import com.hermes.study.core.designsystem.HermesCountdownBadge
|
||||
import com.hermes.study.core.designsystem.HermesCard
|
||||
import com.hermes.study.core.designsystem.HermesMetricChip
|
||||
import com.hermes.study.core.media.HermesPlayerCoordinator
|
||||
import com.hermes.study.core.media.StudyVideoPlayerView
|
||||
import com.hermes.study.feature.reveal.RevealPanel
|
||||
import com.hermes.study.feature.result.ResultPanel
|
||||
import com.hermes.study.feature.selection.SelectionPanel
|
||||
import com.hermes.study.core.designsystem.HermesPrimaryButton
|
||||
import com.hermes.app.core.designsystem.HermesColors
|
||||
import com.hermes.app.core.designsystem.HermesCountdownBadge
|
||||
import com.hermes.app.core.designsystem.HermesCard
|
||||
import com.hermes.app.core.designsystem.HermesMetricChip
|
||||
import com.hermes.app.core.media.HermesPlayerCoordinator
|
||||
import com.hermes.app.core.media.HermesVideoPlayerView
|
||||
import com.hermes.app.feature.reveal.RevealPanel
|
||||
import com.hermes.app.feature.result.ResultPanel
|
||||
import com.hermes.app.feature.selection.SelectionPanel
|
||||
import com.hermes.app.core.designsystem.HermesPrimaryButton
|
||||
|
||||
@Composable
|
||||
fun RoundScreen(
|
||||
@@ -42,7 +42,7 @@ fun RoundScreen(
|
||||
) {
|
||||
HermesCard(modifier = modifier, elevated = true) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
com.hermes.study.core.designsystem.HermesSectionHeader(title = uiState.title, subtitle = uiState.subtitle)
|
||||
com.hermes.app.core.designsystem.HermesSectionHeader(title = uiState.title, subtitle = uiState.subtitle)
|
||||
|
||||
when {
|
||||
uiState.isLoading && !uiState.hasRound -> RoundLoadingState(
|
||||
@@ -67,7 +67,7 @@ fun RoundScreen(
|
||||
.height(220.dp),
|
||||
) {
|
||||
if (uiState.hasRound) {
|
||||
StudyVideoPlayerView(
|
||||
HermesVideoPlayerView(
|
||||
coordinator = playerCoordinator,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
+24
-24
@@ -1,17 +1,17 @@
|
||||
package com.hermes.study.feature.round
|
||||
package com.hermes.app.feature.round
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.hermes.study.R
|
||||
import com.hermes.study.core.analytics.HermesAnalyticsTracker
|
||||
import com.hermes.study.core.errors.mapUserFacingError
|
||||
import com.hermes.study.core.localization.HermesLocalizationStore
|
||||
import com.hermes.study.core.media.HermesPlayerCoordinator
|
||||
import com.hermes.study.data.HermesRepository
|
||||
import com.hermes.study.domain.HermesOutcome
|
||||
import com.hermes.study.domain.HermesBetIntentRequest
|
||||
import com.hermes.study.domain.StudyRound
|
||||
import com.hermes.study.feature.selection.SelectionOptionUi
|
||||
import com.hermes.app.R
|
||||
import com.hermes.app.core.analytics.HermesAnalyticsTracker
|
||||
import com.hermes.app.core.errors.mapUserFacingError
|
||||
import com.hermes.app.core.localization.HermesLocalizationStore
|
||||
import com.hermes.app.core.media.HermesPlayerCoordinator
|
||||
import com.hermes.app.data.HermesRepository
|
||||
import com.hermes.app.domain.HermesOutcome
|
||||
import com.hermes.app.domain.HermesBetIntentRequest
|
||||
import com.hermes.app.domain.HermesRound
|
||||
import com.hermes.app.feature.selection.SelectionOptionUi
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -74,7 +74,7 @@ data class RoundUiState(
|
||||
)
|
||||
|
||||
private data class RoundUiInputs(
|
||||
val round: StudyRound?,
|
||||
val round: HermesRound?,
|
||||
val localeCode: String,
|
||||
val phase: RoundPhase,
|
||||
val selectedOutcomeId: String?,
|
||||
@@ -98,7 +98,7 @@ class RoundViewModel(
|
||||
private val isSubmittingSelectionFlow = MutableStateFlow(false)
|
||||
private val nowFlow = flow {
|
||||
while (true) {
|
||||
emit(Clock.System.now())
|
||||
emit(repository.serverNow())
|
||||
delay(1_000)
|
||||
}
|
||||
}
|
||||
@@ -128,7 +128,7 @@ class RoundViewModel(
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = buildUiState(
|
||||
initialValue = buildUiState(
|
||||
RoundUiInputs(
|
||||
round = roundFlow.value,
|
||||
localeCode = localeFlow.value,
|
||||
@@ -139,7 +139,7 @@ class RoundViewModel(
|
||||
actionMessage = actionMessageFlow.value,
|
||||
isSubmitting = isSubmittingSelectionFlow.value,
|
||||
),
|
||||
Clock.System.now(),
|
||||
repository.serverNow(),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -160,7 +160,7 @@ class RoundViewModel(
|
||||
return
|
||||
}
|
||||
|
||||
if (isTimerLocked(round, Clock.System.now())) {
|
||||
if (isTimerLocked(round, repository.serverNow())) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@ class RoundViewModel(
|
||||
return
|
||||
}
|
||||
|
||||
if (isTimerLocked(round, Clock.System.now())) {
|
||||
if (isTimerLocked(round, repository.serverNow())) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -280,7 +280,7 @@ class RoundViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun startPreview(round: StudyRound) {
|
||||
private fun startPreview(round: HermesRound) {
|
||||
transitionJob?.cancel()
|
||||
phaseFlow.value = RoundPhase.PREVIEW
|
||||
selectedOutcomeIdFlow.value = null
|
||||
@@ -368,7 +368,7 @@ class RoundViewModel(
|
||||
return localizationStore.string(localeCode, resId)
|
||||
}
|
||||
|
||||
private fun selectionOptions(round: StudyRound, localeCode: String): List<SelectionOptionUi> {
|
||||
private fun selectionOptions(round: HermesRound, localeCode: String): List<SelectionOptionUi> {
|
||||
val oddsByOutcomeId = round.oddsVersion.odds.associateBy { it.outcomeId }
|
||||
|
||||
return round.market.outcomes
|
||||
@@ -391,7 +391,7 @@ class RoundViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveSelectedOutcomeTitle(round: StudyRound, localeCode: String, selectedOutcomeId: String?): String {
|
||||
private fun resolveSelectedOutcomeTitle(round: HermesRound, localeCode: String, selectedOutcomeId: String?): String {
|
||||
if (selectedOutcomeId == null) {
|
||||
return localized(localeCode, R.string.round_selection_prompt)
|
||||
}
|
||||
@@ -400,12 +400,12 @@ class RoundViewModel(
|
||||
?: localized(localeCode, R.string.round_selection_prompt)
|
||||
}
|
||||
|
||||
private fun resolveWinningOutcomeTitle(round: StudyRound, localeCode: String): String {
|
||||
private fun resolveWinningOutcomeTitle(round: HermesRound, localeCode: String): String {
|
||||
return round.market.outcomes.firstOrNull { it.id == round.settlement.winningOutcomeId }?.let { outcomeTitle(localeCode, it) }
|
||||
?: localized(localeCode, R.string.round_selection_prompt)
|
||||
}
|
||||
|
||||
private fun oddsSummary(round: StudyRound): String {
|
||||
private fun oddsSummary(round: HermesRound): String {
|
||||
val oddsByOutcomeId = round.oddsVersion.odds.associateBy { it.outcomeId }
|
||||
return round.market.outcomes
|
||||
.sortedBy { it.sortOrder }
|
||||
@@ -436,7 +436,7 @@ class RoundViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun isTimerLocked(round: StudyRound?, now: Instant): Boolean {
|
||||
private fun isTimerLocked(round: HermesRound?, now: Instant): Boolean {
|
||||
return round != null && now.toEpochMilliseconds() >= round.event.lockAt.toEpochMilliseconds()
|
||||
}
|
||||
|
||||
@@ -444,7 +444,7 @@ class RoundViewModel(
|
||||
return mapOf("screen_name" to "round", "outcome_id" to outcomeId)
|
||||
}
|
||||
|
||||
private fun roundAnalyticsAttributes(round: StudyRound): Map<String, String> {
|
||||
private fun roundAnalyticsAttributes(round: HermesRound): Map<String, String> {
|
||||
return mapOf(
|
||||
"screen_name" to "round",
|
||||
"event_id" to round.event.id,
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
package com.hermes.study.feature.selection
|
||||
package com.hermes.app.feature.selection
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -23,8 +23,8 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermes.study.core.designsystem.HermesColors
|
||||
import com.hermes.study.core.designsystem.HermesPrimaryButton
|
||||
import com.hermes.app.core.designsystem.HermesColors
|
||||
import com.hermes.app.core.designsystem.HermesPrimaryButton
|
||||
|
||||
data class SelectionOptionUi(
|
||||
val id: String,
|
||||
+8
-8
@@ -1,4 +1,4 @@
|
||||
package com.hermes.study.feature.session
|
||||
package com.hermes.app.feature.session
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -7,14 +7,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermes.study.R
|
||||
import com.hermes.study.core.designsystem.HermesCard
|
||||
import com.hermes.app.R
|
||||
import com.hermes.app.core.designsystem.HermesCard
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import com.hermes.study.core.designsystem.HermesColors
|
||||
import com.hermes.study.core.designsystem.HermesPrimaryButton
|
||||
import com.hermes.study.core.designsystem.HermesSectionHeader
|
||||
import com.hermes.study.core.localization.HermesLocalizationStore
|
||||
import com.hermes.app.core.designsystem.HermesColors
|
||||
import com.hermes.app.core.designsystem.HermesPrimaryButton
|
||||
import com.hermes.app.core.designsystem.HermesSectionHeader
|
||||
import com.hermes.app.core.localization.HermesLocalizationStore
|
||||
|
||||
@Composable
|
||||
fun SessionView(
|
||||
@@ -64,7 +64,7 @@ fun SessionView(
|
||||
)
|
||||
SessionRow(
|
||||
label = localizationStore.string(uiState.localeCode, R.string.session_locale_label),
|
||||
value = uiState.localeCode.uppercase(),
|
||||
value = localizationStore.localeName(uiState.localeCode),
|
||||
)
|
||||
SessionRow(
|
||||
label = localizationStore.string(uiState.localeCode, R.string.session_started_label),
|
||||
+7
-7
@@ -1,13 +1,13 @@
|
||||
package com.hermes.study.feature.session
|
||||
package com.hermes.app.feature.session
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.hermes.study.R
|
||||
import com.hermes.study.core.analytics.HermesAnalyticsTracker
|
||||
import com.hermes.study.core.errors.mapUserFacingError
|
||||
import com.hermes.study.core.localization.HermesLocalizationStore
|
||||
import com.hermes.study.data.HermesRepository
|
||||
import com.hermes.study.domain.HermesSessionStartRequest
|
||||
import com.hermes.app.R
|
||||
import com.hermes.app.core.analytics.HermesAnalyticsTracker
|
||||
import com.hermes.app.core.errors.mapUserFacingError
|
||||
import com.hermes.app.core.localization.HermesLocalizationStore
|
||||
import com.hermes.app.data.HermesRepository
|
||||
import com.hermes.app.domain.HermesSessionStartRequest
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
+7
-8
@@ -1,4 +1,4 @@
|
||||
package com.hermes.study.feature.settings
|
||||
package com.hermes.app.feature.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -8,14 +8,13 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermes.study.R
|
||||
import com.hermes.study.core.designsystem.HermesCard
|
||||
import com.hermes.study.core.designsystem.HermesColors
|
||||
import com.hermes.app.R
|
||||
import com.hermes.app.core.designsystem.HermesCard
|
||||
import com.hermes.app.core.designsystem.HermesColors
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import com.hermes.study.core.localization.HermesLocalizationStore
|
||||
import com.hermes.study.core.designsystem.HermesSectionHeader
|
||||
import java.util.Locale
|
||||
import com.hermes.app.core.localization.HermesLocalizationStore
|
||||
import com.hermes.app.core.designsystem.HermesSectionHeader
|
||||
|
||||
@Composable
|
||||
fun SettingsView(
|
||||
@@ -32,7 +31,7 @@ fun SettingsView(
|
||||
|
||||
SettingRow(
|
||||
label = localizationStore.string(localeCode, R.string.settings_language),
|
||||
value = localeCode.uppercase(Locale.US),
|
||||
value = localizationStore.localeName(localeCode),
|
||||
)
|
||||
SettingRow(
|
||||
label = localizationStore.string(localeCode, R.string.settings_haptics),
|
||||
@@ -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
|
||||
}
|
||||
-26
@@ -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"?>
|
||||
<resources>
|
||||
<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_cancel">Avbryt</string>
|
||||
<string name="common_close">Stäng</string>
|
||||
@@ -11,7 +13,7 @@
|
||||
<string name="errors_network">Nätverksfel. Kontrollera anslutningen.</string>
|
||||
<string name="errors_playback">Videouppspelningen misslyckades.</string>
|
||||
<string name="errors_session_expired">Sessionen har gått ut. Starta igen.</string>
|
||||
<string name="onboarding_title">Studieintro</string>
|
||||
<string name="onboarding_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_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>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Hermes</string>
|
||||
<string name="app_subtitle">Native study app prototype</string>
|
||||
<string name="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_cancel">Cancel</string>
|
||||
<string name="common_close">Close</string>
|
||||
@@ -11,7 +13,7 @@
|
||||
<string name="errors_network">Network error. Check your connection.</string>
|
||||
<string name="errors_playback">Video playback failed.</string>
|
||||
<string name="errors_session_expired">Session expired. Please start again.</string>
|
||||
<string name="onboarding_title">Study intro</string>
|
||||
<string name="onboarding_title">Welcome to Hermes</string>
|
||||
<string name="onboarding_subtitle">Watch the clip, decide before lock, then see the reveal.</string>
|
||||
<string name="onboarding_consent_body">This prototype is for research and does not use real money.</string>
|
||||
<string name="onboarding_consent_note">You can switch languages at any time.</string>
|
||||
|
||||
@@ -1,15 +1,59 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct HermesApp: App {
|
||||
@StateObject private var repository = HermesRepository(
|
||||
apiClient: HermesAPIClient(
|
||||
environment: APIEnvironment(baseURL: URL(string: "http://localhost:3000/")!)
|
||||
)
|
||||
)
|
||||
@StateObject private var analytics = HermesAnalyticsClient()
|
||||
@StateObject private var playerCoordinator = PlayerCoordinator()
|
||||
@State private var isBootstrapping = false
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
RootView(onStartSession: { localeCode in
|
||||
if repository.currentSession != nil, repository.currentRound != nil {
|
||||
return
|
||||
}
|
||||
|
||||
guard !isBootstrapping else {
|
||||
return
|
||||
}
|
||||
|
||||
isBootstrapping = true
|
||||
let request = HermesSessionStartRequest(
|
||||
localeCode: localeCode,
|
||||
devicePlatform: "ios",
|
||||
deviceModel: UIDevice.current.model,
|
||||
osVersion: UIDevice.current.systemVersion,
|
||||
appVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.1.0"
|
||||
)
|
||||
|
||||
analytics.track("session_start_requested", attributes: ["screen_name": "session", "locale_code": localeCode])
|
||||
|
||||
Task { @MainActor in
|
||||
defer {
|
||||
isBootstrapping = false
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try await repository.bootstrap(request)
|
||||
analytics.track("session_started", attributes: ["screen_name": "session", "locale_code": localeCode])
|
||||
await analytics.flush(using: repository)
|
||||
} catch {
|
||||
analytics.track("session_start_failed", attributes: ["screen_name": "session", "locale_code": localeCode])
|
||||
}
|
||||
}
|
||||
})
|
||||
.preferredColorScheme(.dark)
|
||||
.tint(HermesTheme.accent)
|
||||
.environmentObject(analytics)
|
||||
.environmentObject(repository)
|
||||
.environmentObject(playerCoordinator)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,19 @@ import SwiftUI
|
||||
struct RootView: View {
|
||||
@StateObject private var localization = LocalizationStore()
|
||||
@EnvironmentObject private var analytics: HermesAnalyticsClient
|
||||
@EnvironmentObject private var repository: HermesRepository
|
||||
|
||||
let onStartSession: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
header
|
||||
OnboardingView()
|
||||
FeedView()
|
||||
RoundView()
|
||||
OnboardingView(onStartSession: { onStartSession(localization.localeCode) })
|
||||
FeedView(onWatchPreview: {}, onRetry: { onStartSession(localization.localeCode) })
|
||||
RoundView(onRetry: { onStartSession(localization.localeCode) })
|
||||
SessionView(onRetry: { onStartSession(localization.localeCode) })
|
||||
}
|
||||
.padding(.horizontal, HermesTheme.screenPadding)
|
||||
.padding(.vertical, 24)
|
||||
@@ -25,6 +29,15 @@ struct RootView: View {
|
||||
analytics.track("app_opened", attributes: ["screen_name": "home"])
|
||||
analytics.track("screen_viewed", attributes: ["screen_name": "home"])
|
||||
}
|
||||
.task(id: localization.localeCode) {
|
||||
onStartSession(localization.localeCode)
|
||||
}
|
||||
.task {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 5_000_000_000)
|
||||
await analytics.flush(using: repository)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
@@ -46,8 +59,8 @@ struct RootView: View {
|
||||
|
||||
private var localeToggle: some View {
|
||||
HStack(spacing: 8) {
|
||||
localeButton(title: "EN", localeCode: "en")
|
||||
localeButton(title: "SV", localeCode: "sv")
|
||||
localeButton(title: localization.localeName(for: "en"), localeCode: "en")
|
||||
localeButton(title: localization.localeName(for: "sv"), localeCode: "sv")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
func localeName(for targetLocaleCode: String, displayLocaleCode: String? = nil) -> String {
|
||||
let key = Self.normalize(targetLocaleCode) == "sv" ? "locale_swedish" : "locale_english"
|
||||
return string(for: key, localeCode: displayLocaleCode ?? localeCode)
|
||||
}
|
||||
|
||||
private func fallbackString(for key: String, localeCode: String) -> String {
|
||||
guard localeCode != Self.fallbackLocaleCode else {
|
||||
return key
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import AVKit
|
||||
import SwiftUI
|
||||
|
||||
struct StudyVideoPlayerView: View {
|
||||
struct HermesVideoPlayerView: View {
|
||||
@ObservedObject var coordinator: PlayerCoordinator
|
||||
|
||||
var body: some View {
|
||||
@@ -9,13 +9,15 @@ final class PlayerCoordinator: ObservableObject {
|
||||
@Published var isPlaying = false
|
||||
@Published var playbackPositionMs: Int = 0
|
||||
|
||||
init(previewURL: URL = URL(string: "https://cdn.example.com/hermes/sample-event/master.m3u8")!) {
|
||||
self.player = AVPlayer(url: previewURL)
|
||||
init() {
|
||||
self.player = AVPlayer()
|
||||
self.player.actionAtItemEnd = .pause
|
||||
}
|
||||
|
||||
func prepareForPreview() {
|
||||
player.seek(to: .zero)
|
||||
func prepareForPreview(url: URL, startTimeMs: Int = 0) {
|
||||
player.replaceCurrentItem(with: AVPlayerItem(url: url))
|
||||
let startTime = CMTime(seconds: Double(startTimeMs) / 1_000.0, preferredTimescale: 1_000)
|
||||
player.seek(to: startTime)
|
||||
player.play()
|
||||
isPlaying = true
|
||||
}
|
||||
@@ -30,8 +32,7 @@ final class PlayerCoordinator: ObservableObject {
|
||||
isPlaying = false
|
||||
}
|
||||
|
||||
func restart() {
|
||||
player.seek(to: .zero)
|
||||
play()
|
||||
func restart(url: URL, startTimeMs: Int = 0) {
|
||||
prepareForPreview(url: url, startTimeMs: startTimeMs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,10 @@ struct HermesAPIClient {
|
||||
try await send(path: "api/v1/session/start", method: "POST", body: payload)
|
||||
}
|
||||
|
||||
func health() async throws -> HermesHealthResponse {
|
||||
try await send(path: "health")
|
||||
}
|
||||
|
||||
func endSession() async throws -> HermesSessionResponse {
|
||||
try await send(path: "api/v1/session/end", method: "POST")
|
||||
}
|
||||
|
||||
@@ -23,6 +23,17 @@ struct HermesSessionResponse: Codable {
|
||||
var devicePlatform: String
|
||||
}
|
||||
|
||||
struct HermesHealthResponse: Codable {
|
||||
var status: String
|
||||
var serviceName: String
|
||||
var environment: String
|
||||
var version: String
|
||||
var uptimeMs: Int
|
||||
var serverTime: Date
|
||||
var databaseReady: Bool
|
||||
var redisReady: Bool
|
||||
}
|
||||
|
||||
struct HermesEvent: Codable {
|
||||
var id: UUID
|
||||
var sportType: String
|
||||
@@ -94,6 +105,22 @@ struct HermesEventManifest: Codable {
|
||||
var markets: [HermesMarket]
|
||||
}
|
||||
|
||||
struct HermesRound: Codable {
|
||||
var event: HermesEvent
|
||||
var media: HermesEventMedia
|
||||
var market: HermesMarket
|
||||
var oddsVersion: HermesOddsVersion
|
||||
var settlement: HermesSettlement
|
||||
}
|
||||
|
||||
struct HermesRound: Codable {
|
||||
var event: HermesEvent
|
||||
var media: HermesEventMedia
|
||||
var market: HermesMarket
|
||||
var oddsVersion: HermesOddsVersion
|
||||
var settlement: HermesSettlement
|
||||
}
|
||||
|
||||
struct HermesBetIntentRequest: Codable {
|
||||
var sessionId: UUID
|
||||
var eventId: UUID
|
||||
|
||||
@@ -3,55 +3,173 @@ import SwiftUI
|
||||
struct FeedView: View {
|
||||
@EnvironmentObject private var localization: LocalizationStore
|
||||
@EnvironmentObject private var analytics: HermesAnalyticsClient
|
||||
@EnvironmentObject private var repository: HermesRepository
|
||||
|
||||
let onWatchPreview: () -> Void = {}
|
||||
let onRetry: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) {
|
||||
HermesSectionHeader(
|
||||
title: localization.string(for: "feed.title"),
|
||||
subtitle: localization.string(for: "feed.subtitle")
|
||||
TimelineView(.periodic(from: Date(), by: 1)) { _ in
|
||||
let round = repository.currentRound
|
||||
let now = repository.serverNow()
|
||||
let bannerMessage = hermesUserFacingErrorMessage(
|
||||
localization: localization,
|
||||
localeCode: localization.localeCode,
|
||||
error: repository.errorCause
|
||||
)
|
||||
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
RoundedRectangle(cornerRadius: HermesTheme.cornerRadius, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [HermesTheme.surfaceElevated, HermesTheme.background],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) {
|
||||
HermesSectionHeader(
|
||||
title: localization.string(for: "feed.title"),
|
||||
subtitle: localization.string(for: "feed.subtitle")
|
||||
)
|
||||
|
||||
if round == nil {
|
||||
if let bannerMessage {
|
||||
feedErrorState(
|
||||
message: bannerMessage,
|
||||
retryText: localization.string(for: "common.retry"),
|
||||
onRetry: onRetry
|
||||
)
|
||||
)
|
||||
.frame(height: 220)
|
||||
} else {
|
||||
feedLoadingState(
|
||||
title: localization.string(for: "common.loading"),
|
||||
subtitle: localization.string(for: "feed.subtitle")
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if let bannerMessage {
|
||||
feedBanner(message: bannerMessage)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(localization.string(for: "feed.hero_title"))
|
||||
.font(.title2.weight(.bold))
|
||||
.foregroundStyle(HermesTheme.textPrimary)
|
||||
heroCard(round: round)
|
||||
|
||||
Text(localization.string(for: "feed.hero_subtitle"))
|
||||
.font(.callout)
|
||||
.foregroundStyle(HermesTheme.textSecondary)
|
||||
.frame(maxWidth: 260, alignment: .leading)
|
||||
HStack(spacing: 12) {
|
||||
HermesMetricPill(
|
||||
label: localization.string(for: "feed.lock_label"),
|
||||
value: round.map { Self.countdownText(for: $0.event.lockAt.timeIntervalSince(now)) } ?? "--:--"
|
||||
)
|
||||
HermesMetricPill(
|
||||
label: localization.string(for: "feed.odds_label"),
|
||||
value: round.map { Self.formatOdds($0) } ?? "--"
|
||||
)
|
||||
}
|
||||
|
||||
Button {
|
||||
analytics.track("next_round_requested", attributes: ["screen_name": "feed"])
|
||||
analytics.track("cta_pressed", attributes: ["screen_name": "feed", "action": "watch_preview"])
|
||||
onWatchPreview()
|
||||
} label: {
|
||||
Text(localization.string(for: "feed.cta"))
|
||||
}
|
||||
.buttonStyle(HermesPrimaryButtonStyle())
|
||||
.disabled(round == nil)
|
||||
}
|
||||
.padding(HermesTheme.contentPadding)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
HermesMetricPill(label: localization.string(for: "feed.lock_label"), value: "01:42")
|
||||
HermesMetricPill(label: localization.string(for: "feed.odds_label"), value: "1.85 / 2.05")
|
||||
.onAppear {
|
||||
analytics.track("feed_viewed", attributes: ["screen_name": "feed"])
|
||||
analytics.track("screen_viewed", attributes: ["screen_name": "feed"])
|
||||
}
|
||||
|
||||
Button {
|
||||
analytics.track("next_round_requested", attributes: ["screen_name": "feed"])
|
||||
analytics.track("cta_pressed", attributes: ["screen_name": "feed", "action": "watch_preview"])
|
||||
} label: {
|
||||
Text(localization.string(for: "feed.cta"))
|
||||
}
|
||||
.buttonStyle(HermesPrimaryButtonStyle())
|
||||
}
|
||||
.hermesCard(elevated: true)
|
||||
.onAppear {
|
||||
analytics.track("feed_viewed", attributes: ["screen_name": "feed"])
|
||||
analytics.track("screen_viewed", attributes: ["screen_name": "feed"])
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func heroCard(round: HermesRound?) -> some View {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
RoundedRectangle(cornerRadius: HermesTheme.cornerRadius, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [HermesTheme.surfaceElevated, HermesTheme.background],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.frame(height: 220)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(round.map { localizedEventTitle($0) } ?? localization.string(for: "feed.hero_title"))
|
||||
.font(.title2.weight(.bold))
|
||||
.foregroundStyle(HermesTheme.textPrimary)
|
||||
|
||||
Text(round.map { localization.string(for: "feed.hero_subtitle") } ?? localization.string(for: "feed.hero_subtitle"))
|
||||
.font(.callout)
|
||||
.foregroundStyle(HermesTheme.textSecondary)
|
||||
.frame(maxWidth: 260, alignment: .leading)
|
||||
}
|
||||
.padding(HermesTheme.contentPadding)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func feedLoadingState(title: String, subtitle: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(.title2.weight(.bold))
|
||||
.foregroundStyle(HermesTheme.textPrimary)
|
||||
Text(subtitle)
|
||||
.font(.callout)
|
||||
.foregroundStyle(HermesTheme.textSecondary)
|
||||
}
|
||||
.padding(HermesTheme.contentPadding)
|
||||
.frame(maxWidth: .infinity, minHeight: 220, alignment: .center)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: HermesTheme.cornerRadius, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [HermesTheme.surfaceElevated, HermesTheme.background],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func feedErrorState(message: String, retryText: String, onRetry: @escaping () -> Void) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(message)
|
||||
.font(.callout)
|
||||
.foregroundStyle(HermesTheme.warning)
|
||||
|
||||
Button {
|
||||
onRetry()
|
||||
} label: {
|
||||
Text(retryText)
|
||||
}
|
||||
.buttonStyle(HermesSecondaryButtonStyle())
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func feedBanner(message: String) -> some View {
|
||||
Text(message)
|
||||
.font(.callout)
|
||||
.foregroundStyle(HermesTheme.warning)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(HermesTheme.warning.opacity(0.12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: HermesTheme.insetRadius, style: .continuous))
|
||||
}
|
||||
|
||||
private func localizedEventTitle(_ round: HermesRound) -> String {
|
||||
localization.localeCode == "sv" ? round.event.titleSv : round.event.titleEn
|
||||
}
|
||||
|
||||
private static func countdownText(for remaining: TimeInterval) -> String {
|
||||
let totalSeconds = max(Int(remaining.rounded(.down)), 0)
|
||||
let minutes = totalSeconds / 60
|
||||
let seconds = totalSeconds % 60
|
||||
return String(format: "%02d:%02d", minutes, seconds)
|
||||
}
|
||||
|
||||
private static func formatOdds(_ round: HermesRound) -> String {
|
||||
let oddsByOutcomeId = Dictionary(uniqueKeysWithValues: round.oddsVersion.odds.map { ($0.outcomeId, $0) })
|
||||
return round.market.outcomes
|
||||
.sorted(by: { $0.sortOrder < $1.sortOrder })
|
||||
.compactMap { oddsByOutcomeId[$0.id]?.decimalOdds }
|
||||
.map { String(format: "%.2f", $0) }
|
||||
.joined(separator: " / ")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ struct OnboardingView: View {
|
||||
@EnvironmentObject private var localization: LocalizationStore
|
||||
@EnvironmentObject private var analytics: HermesAnalyticsClient
|
||||
|
||||
let onStartSession: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) {
|
||||
HermesSectionHeader(
|
||||
@@ -27,6 +29,7 @@ struct OnboardingView: View {
|
||||
Button {
|
||||
analytics.track("consent_accepted", attributes: ["screen_name": "onboarding"])
|
||||
analytics.track("cta_pressed", attributes: ["screen_name": "onboarding", "action": "start_session"])
|
||||
onStartSession()
|
||||
} label: {
|
||||
Text(localization.string(for: "onboarding.start_session"))
|
||||
}
|
||||
|
||||
@@ -13,8 +13,6 @@ struct ResultView: View {
|
||||
let nextRoundTitle: String
|
||||
let onNextRound: () -> Void
|
||||
|
||||
@EnvironmentObject private var analytics: HermesAnalyticsClient
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) {
|
||||
HermesSectionHeader(title: title, subtitle: subtitle)
|
||||
@@ -33,16 +31,11 @@ struct ResultView: View {
|
||||
}
|
||||
|
||||
Button {
|
||||
analytics.track("next_round_requested", attributes: ["screen_name": "result"])
|
||||
onNextRound()
|
||||
} label: {
|
||||
Text(nextRoundTitle)
|
||||
}
|
||||
.buttonStyle(HermesPrimaryButtonStyle())
|
||||
}
|
||||
.onAppear {
|
||||
analytics.track("screen_viewed", attributes: ["screen_name": "result"])
|
||||
analytics.track("result_viewed", attributes: ["screen_name": "result", "selection": selectionValue, "outcome": outcomeValue])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ struct RevealView: View {
|
||||
let continueTitle: String
|
||||
let onContinue: () -> Void
|
||||
|
||||
@EnvironmentObject private var analytics: HermesAnalyticsClient
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) {
|
||||
HermesSectionHeader(title: title, subtitle: subtitle)
|
||||
@@ -24,16 +22,11 @@ struct RevealView: View {
|
||||
}
|
||||
|
||||
Button {
|
||||
analytics.track("reveal_completed", attributes: ["screen_name": "reveal", "selection": selectionValue])
|
||||
onContinue()
|
||||
} label: {
|
||||
Text(continueTitle)
|
||||
}
|
||||
.buttonStyle(HermesPrimaryButtonStyle())
|
||||
}
|
||||
.onAppear {
|
||||
analytics.track("screen_viewed", attributes: ["screen_name": "reveal"])
|
||||
analytics.track("reveal_started", attributes: ["screen_name": "reveal", "selection": selectionValue])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,17 @@ import SwiftUI
|
||||
struct RoundView: View {
|
||||
@EnvironmentObject private var localization: LocalizationStore
|
||||
@EnvironmentObject private var analytics: HermesAnalyticsClient
|
||||
@EnvironmentObject private var repository: HermesRepository
|
||||
@EnvironmentObject private var playerCoordinator: PlayerCoordinator
|
||||
|
||||
let onRetry: () -> Void
|
||||
|
||||
@StateObject private var playerCoordinator = PlayerCoordinator()
|
||||
@State private var phase: Phase = .preview
|
||||
@State private var selectedOutcomeID: String? = nil
|
||||
@State private var lockAt: Date = Date().addingTimeInterval(47)
|
||||
@State private var lockAt: Date = .now
|
||||
@State private var transitionTask: Task<Void, Never>?
|
||||
|
||||
private let previewDuration: TimeInterval = 47
|
||||
private let winningOutcomeID = "home"
|
||||
@State private var actionMessage: String?
|
||||
@State private var isSubmitting = false
|
||||
|
||||
private enum Phase {
|
||||
case preview
|
||||
@@ -20,40 +22,19 @@ struct RoundView: View {
|
||||
case result
|
||||
}
|
||||
|
||||
private var selectionOptions: [SelectionOption] {
|
||||
[
|
||||
SelectionOption(
|
||||
id: "home",
|
||||
title: localization.string(for: "round.home"),
|
||||
subtitle: localization.string(for: "round.selection_prompt"),
|
||||
odds: "1.85"
|
||||
),
|
||||
SelectionOption(
|
||||
id: "away",
|
||||
title: localization.string(for: "round.away"),
|
||||
subtitle: localization.string(for: "round.selection_prompt"),
|
||||
odds: "2.05"
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
private var selectedOutcome: SelectionOption? {
|
||||
guard let selectedOutcomeID else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return selectionOptions.first { $0.id == selectedOutcomeID }
|
||||
}
|
||||
|
||||
private var winningOutcome: SelectionOption {
|
||||
selectionOptions.first { $0.id == winningOutcomeID } ?? selectionOptions[0]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TimelineView(.periodic(from: Date(), by: 1)) { context in
|
||||
let remaining = max(lockAt.timeIntervalSince(context.date), 0)
|
||||
let timerLocked = remaining <= 0
|
||||
TimelineView(.periodic(from: Date(), by: 1)) { _ in
|
||||
let round = repository.currentRound
|
||||
let now = repository.serverNow()
|
||||
let hasRound = round != nil
|
||||
let remaining = max(lockAt.timeIntervalSince(now), 0)
|
||||
let timerLocked = round != nil && remaining <= 0
|
||||
let countdownText = Self.countdownText(for: remaining)
|
||||
let bannerMessage = actionMessage ?? hermesUserFacingErrorMessage(
|
||||
localization: localization,
|
||||
localeCode: localization.localeCode,
|
||||
error: repository.errorCause
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) {
|
||||
HermesSectionHeader(
|
||||
@@ -61,15 +42,87 @@ struct RoundView: View {
|
||||
subtitle: localization.string(for: "round.subtitle")
|
||||
)
|
||||
|
||||
videoSection(countdownText: countdownText, remaining: remaining, isTimerLocked: timerLocked)
|
||||
if !hasRound {
|
||||
if let bannerMessage {
|
||||
roundErrorState(
|
||||
message: bannerMessage,
|
||||
retryText: localization.string(for: "common.retry"),
|
||||
onRetry: onRetry
|
||||
)
|
||||
} else {
|
||||
roundLoadingState(
|
||||
title: localization.string(for: "common.loading"),
|
||||
subtitle: localization.string(for: "round.subtitle")
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if let bannerMessage {
|
||||
roundBanner(message: bannerMessage)
|
||||
}
|
||||
|
||||
phaseContent(isTimerLocked: timerLocked)
|
||||
videoSection(
|
||||
round: round,
|
||||
countdownText: countdownText,
|
||||
remaining: remaining,
|
||||
isTimerLocked: timerLocked
|
||||
)
|
||||
|
||||
if let round {
|
||||
switch phase {
|
||||
case .preview, .locked:
|
||||
SelectionView(
|
||||
statusText: phase == .preview && !timerLocked ? localization.string(for: "round.selection_prompt") : localization.string(for: "round.locked_label"),
|
||||
options: selectionOptions(for: round),
|
||||
selectedOptionID: selectedOutcomeID,
|
||||
isLocked: phase != .preview || timerLocked || isSubmitting,
|
||||
confirmTitle: localization.string(for: "round.primary_cta"),
|
||||
onSelect: handleSelection,
|
||||
onConfirm: confirmSelection
|
||||
)
|
||||
|
||||
case .reveal:
|
||||
RevealView(
|
||||
title: localization.string(for: "reveal.title"),
|
||||
subtitle: localization.string(for: "reveal.subtitle"),
|
||||
statusText: localization.string(for: "reveal.status"),
|
||||
selectionLabel: localization.string(for: "result.selection_label"),
|
||||
selectionValue: selectedOutcomeTitle(for: round),
|
||||
continueTitle: localization.string(for: "reveal.cta"),
|
||||
onContinue: showResult
|
||||
)
|
||||
|
||||
case .result:
|
||||
ResultView(
|
||||
title: localization.string(for: "result.title"),
|
||||
subtitle: localization.string(for: "result.subtitle"),
|
||||
selectionLabel: localization.string(for: "result.selection_label"),
|
||||
selectionValue: selectedOutcomeTitle(for: round),
|
||||
outcomeLabel: localization.string(for: "result.outcome_label"),
|
||||
outcomeValue: winningOutcomeTitle(for: round),
|
||||
didWin: selectedOutcomeID == round.settlement.winningOutcomeId.uuidString,
|
||||
winLabel: localization.string(for: "result.win"),
|
||||
loseLabel: localization.string(for: "result.lose"),
|
||||
nextRoundTitle: localization.string(for: "result.next_round"),
|
||||
onNextRound: nextRound
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if let round {
|
||||
startPreview(round)
|
||||
}
|
||||
}
|
||||
.onChange(of: round?.event.id) { _, newValue in
|
||||
guard newValue != nil, let round else {
|
||||
return
|
||||
}
|
||||
|
||||
startPreview(round)
|
||||
}
|
||||
}
|
||||
.hermesCard(elevated: true)
|
||||
.onAppear {
|
||||
startPreview()
|
||||
}
|
||||
.onDisappear {
|
||||
transitionTask?.cancel()
|
||||
playerCoordinator.pause()
|
||||
@@ -77,65 +130,36 @@ struct RoundView: View {
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func phaseContent(isTimerLocked: Bool) -> some View {
|
||||
switch phase {
|
||||
case .preview, .locked:
|
||||
SelectionView(
|
||||
statusText: isTimerLocked || phase != .preview ? localization.string(for: "round.locked_label") : localization.string(for: "round.selection_prompt"),
|
||||
options: selectionOptions,
|
||||
selectedOptionID: selectedOutcomeID,
|
||||
isLocked: isTimerLocked || phase != .preview,
|
||||
confirmTitle: localization.string(for: "round.primary_cta"),
|
||||
onSelect: handleSelection,
|
||||
onConfirm: confirmSelection
|
||||
)
|
||||
|
||||
case .reveal:
|
||||
RevealView(
|
||||
title: localization.string(for: "reveal.title"),
|
||||
subtitle: localization.string(for: "reveal.subtitle"),
|
||||
statusText: localization.string(for: "reveal.status"),
|
||||
selectionLabel: localization.string(for: "result.selection_label"),
|
||||
selectionValue: selectedOutcome?.title ?? localization.string(for: "round.selection_prompt"),
|
||||
continueTitle: localization.string(for: "reveal.cta"),
|
||||
onContinue: showResult
|
||||
)
|
||||
|
||||
case .result:
|
||||
ResultView(
|
||||
title: localization.string(for: "result.title"),
|
||||
subtitle: localization.string(for: "result.subtitle"),
|
||||
selectionLabel: localization.string(for: "result.selection_label"),
|
||||
selectionValue: selectedOutcome?.title ?? localization.string(for: "round.selection_prompt"),
|
||||
outcomeLabel: localization.string(for: "result.outcome_label"),
|
||||
outcomeValue: winningOutcome.title,
|
||||
didWin: selectedOutcomeID == winningOutcomeID,
|
||||
winLabel: localization.string(for: "result.win"),
|
||||
loseLabel: localization.string(for: "result.lose"),
|
||||
nextRoundTitle: localization.string(for: "result.next_round"),
|
||||
onNextRound: resetRound
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func videoSection(countdownText: String, remaining: TimeInterval, isTimerLocked: Bool) -> some View {
|
||||
private func videoSection(round: HermesRound?, countdownText: String, remaining: TimeInterval, isTimerLocked: Bool) -> some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
StudyVideoPlayerView(coordinator: playerCoordinator)
|
||||
RoundedRectangle(cornerRadius: HermesTheme.cornerRadius, style: .continuous)
|
||||
.fill(HermesTheme.surfaceElevated)
|
||||
.frame(height: 220)
|
||||
|
||||
VStack(alignment: .trailing, spacing: 10) {
|
||||
HermesCountdownBadge(
|
||||
label: localization.string(for: "round.countdown_label"),
|
||||
value: countdownText,
|
||||
warning: !isTimerLocked && remaining <= 10
|
||||
)
|
||||
|
||||
HermesMetricPill(
|
||||
label: localization.string(for: "round.odds_label"),
|
||||
value: "1.85 / 2.05"
|
||||
)
|
||||
if let round {
|
||||
HermesVideoPlayerView(coordinator: playerCoordinator)
|
||||
} else {
|
||||
Text(localization.string(for: "round.video_placeholder"))
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundStyle(HermesTheme.textSecondary)
|
||||
}
|
||||
|
||||
if let round {
|
||||
VStack(alignment: .trailing, spacing: 10) {
|
||||
HermesCountdownBadge(
|
||||
label: localization.string(for: "round.countdown_label"),
|
||||
value: countdownText,
|
||||
warning: !isTimerLocked && remaining <= 10
|
||||
)
|
||||
|
||||
HermesMetricPill(
|
||||
label: localization.string(for: "round.odds_label"),
|
||||
value: formatOdds(round)
|
||||
)
|
||||
.frame(maxWidth: 160)
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
.padding(12)
|
||||
|
||||
Text(phaseLabel(isTimerLocked: isTimerLocked))
|
||||
.font(.caption.weight(.bold))
|
||||
@@ -147,35 +171,25 @@ struct RoundView: View {
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: HermesTheme.cornerRadius, style: .continuous))
|
||||
}
|
||||
|
||||
private func phaseLabel(isTimerLocked: Bool) -> String {
|
||||
switch phase {
|
||||
case .preview:
|
||||
return isTimerLocked ? localization.string(for: "round.locked_label") : localization.string(for: "round.preview_label")
|
||||
case .locked:
|
||||
return localization.string(for: "round.locked_label")
|
||||
case .reveal:
|
||||
return localization.string(for: "reveal.title")
|
||||
case .result:
|
||||
return localization.string(for: "result.title")
|
||||
}
|
||||
}
|
||||
|
||||
private func startPreview() {
|
||||
private func startPreview(_ round: HermesRound) {
|
||||
transitionTask?.cancel()
|
||||
phase = .preview
|
||||
lockAt = Date().addingTimeInterval(previewDuration)
|
||||
selectedOutcomeID = nil
|
||||
playerCoordinator.restart()
|
||||
actionMessage = nil
|
||||
isSubmitting = false
|
||||
lockAt = round.event.lockAt
|
||||
playerCoordinator.prepareForPreview(url: round.media.hlsMasterUrl, startTimeMs: round.media.previewStartMs)
|
||||
|
||||
analytics.track("round_loaded", attributes: ["screen_name": "round"])
|
||||
analytics.track("preview_started", attributes: ["screen_name": "round"])
|
||||
analytics.track("round_loaded", attributes: roundAnalyticsAttributes(round))
|
||||
analytics.track("preview_started", attributes: roundAnalyticsAttributes(round))
|
||||
analytics.track("screen_viewed", attributes: ["screen_name": "round"])
|
||||
}
|
||||
|
||||
private func handleSelection(_ option: SelectionOption) {
|
||||
guard phase == .preview else {
|
||||
guard phase == .preview, !isSubmitting else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -185,39 +199,204 @@ struct RoundView: View {
|
||||
}
|
||||
|
||||
private func confirmSelection() {
|
||||
guard phase == .preview, let selectedOutcomeID else {
|
||||
guard phase == .preview, !isSubmitting, let round = repository.currentRound else {
|
||||
return
|
||||
}
|
||||
|
||||
analytics.track("selection_submitted", attributes: ["screen_name": "round", "outcome_id": selectedOutcomeID])
|
||||
analytics.track("selection_accepted", attributes: ["screen_name": "round", "outcome_id": selectedOutcomeID])
|
||||
analytics.track("market_locked", attributes: ["screen_name": "round", "lock_reason": "manual_selection"])
|
||||
guard let selectedOutcomeID, let session = repository.currentSession else {
|
||||
actionMessage = localization.string(for: "errors.session_expired")
|
||||
return
|
||||
}
|
||||
|
||||
phase = .locked
|
||||
playerCoordinator.pause()
|
||||
guard let outcomeID = UUID(uuidString: selectedOutcomeID) ?? round.market.outcomes.first?.id else {
|
||||
actionMessage = localization.string(for: "errors.generic")
|
||||
return
|
||||
}
|
||||
|
||||
transitionTask?.cancel()
|
||||
transitionTask = Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 750_000_000)
|
||||
guard !Task.isCancelled else {
|
||||
return
|
||||
if repository.serverNow() >= round.event.lockAt {
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting = true
|
||||
actionMessage = nil
|
||||
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let request = HermesBetIntentRequest(
|
||||
sessionId: session.sessionId,
|
||||
eventId: round.event.id,
|
||||
marketId: round.market.id,
|
||||
outcomeId: outcomeID,
|
||||
idempotencyKey: UUID().uuidString,
|
||||
clientSentAt: Date()
|
||||
)
|
||||
|
||||
let response = try await repository.submitBetIntent(request)
|
||||
|
||||
guard response.accepted else {
|
||||
actionMessage = localization.string(for: "errors.generic")
|
||||
phase = .preview
|
||||
isSubmitting = false
|
||||
return
|
||||
}
|
||||
|
||||
analytics.track("selection_submitted", attributes: baseSelectionAttributes(selectedOutcomeID))
|
||||
analytics.track("selection_accepted", attributes: baseSelectionAttributes(selectedOutcomeID))
|
||||
analytics.track("market_locked", attributes: baseSelectionAttributes(selectedOutcomeID).merging(["lock_reason": "manual_selection"]) { _, new in new })
|
||||
|
||||
phase = .locked
|
||||
playerCoordinator.pause()
|
||||
|
||||
transitionTask?.cancel()
|
||||
transitionTask = Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 750_000_000)
|
||||
guard !Task.isCancelled, phase == .locked else {
|
||||
return
|
||||
}
|
||||
|
||||
phase = .reveal
|
||||
analytics.track("reveal_started", attributes: roundAnalyticsAttributes(round).merging(baseSelectionAttributes(selectedOutcomeID)) { _, new in new })
|
||||
}
|
||||
} catch {
|
||||
actionMessage = hermesUserFacingErrorMessage(localization: localization, localeCode: localization.localeCode, error: error) ?? localization.string(for: "errors.generic")
|
||||
phase = .preview
|
||||
}
|
||||
|
||||
phase = .reveal
|
||||
isSubmitting = false
|
||||
}
|
||||
}
|
||||
|
||||
private func showResult() {
|
||||
analytics.track("reveal_completed", attributes: ["screen_name": "round"])
|
||||
guard let round = repository.currentRound, let selectedOutcomeID else {
|
||||
return
|
||||
}
|
||||
|
||||
analytics.track("reveal_completed", attributes: roundAnalyticsAttributes(round).merging(baseSelectionAttributes(selectedOutcomeID)) { _, new in new })
|
||||
phase = .result
|
||||
analytics.track(
|
||||
"result_viewed",
|
||||
attributes: roundAnalyticsAttributes(round)
|
||||
.merging(baseSelectionAttributes(selectedOutcomeID)) { _, new in new }
|
||||
.merging(["outcome": winningOutcomeTitle(for: round)]) { _, new in new }
|
||||
)
|
||||
}
|
||||
|
||||
private func resetRound() {
|
||||
private func nextRound() {
|
||||
analytics.track("next_round_requested", attributes: ["screen_name": "result"])
|
||||
transitionTask?.cancel()
|
||||
selectedOutcomeID = nil
|
||||
phase = .preview
|
||||
lockAt = Date().addingTimeInterval(previewDuration)
|
||||
playerCoordinator.restart()
|
||||
actionMessage = nil
|
||||
|
||||
Task { @MainActor in
|
||||
do {
|
||||
_ = try await repository.refreshRoundFromNetwork()
|
||||
} catch {
|
||||
actionMessage = hermesUserFacingErrorMessage(localization: localization, localeCode: localization.localeCode, error: error) ?? localization.string(for: "errors.generic")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func roundLoadingState(title: String, subtitle: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundStyle(HermesTheme.textPrimary)
|
||||
Text(subtitle)
|
||||
.font(.callout)
|
||||
.foregroundStyle(HermesTheme.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func roundErrorState(message: String, retryText: String, onRetry: @escaping () -> Void) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(message)
|
||||
.font(.callout)
|
||||
.foregroundStyle(HermesTheme.warning)
|
||||
|
||||
Button {
|
||||
onRetry()
|
||||
} label: {
|
||||
Text(retryText)
|
||||
}
|
||||
.buttonStyle(HermesSecondaryButtonStyle())
|
||||
}
|
||||
}
|
||||
|
||||
private func roundBanner(message: String) -> some View {
|
||||
Text(message)
|
||||
.font(.callout)
|
||||
.foregroundStyle(HermesTheme.warning)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(HermesTheme.warning.opacity(0.12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: HermesTheme.insetRadius, style: .continuous))
|
||||
}
|
||||
|
||||
private func selectionOptions(for round: HermesRound) -> [SelectionOption] {
|
||||
let oddsByOutcomeId = Dictionary(uniqueKeysWithValues: round.oddsVersion.odds.map { ($0.outcomeId, $0) })
|
||||
return round.market.outcomes
|
||||
.sorted(by: { $0.sortOrder < $1.sortOrder })
|
||||
.map { outcome in
|
||||
SelectionOption(
|
||||
id: outcome.id.uuidString,
|
||||
title: outcomeTitle(outcome),
|
||||
subtitle: localization.string(for: "round.selection_prompt"),
|
||||
odds: oddsByOutcomeId[outcome.id].map { String(format: "%.2f", $0.decimalOdds) } ?? "--"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func outcomeTitle(_ outcome: HermesOutcome) -> String {
|
||||
switch outcome.labelKey {
|
||||
case "round.home":
|
||||
return localization.string(for: "round.home")
|
||||
case "round.away":
|
||||
return localization.string(for: "round.away")
|
||||
default:
|
||||
return outcome.outcomeCode.capitalized
|
||||
}
|
||||
}
|
||||
|
||||
private func selectedOutcomeTitle(for round: HermesRound) -> String {
|
||||
guard let selectedOutcomeID,
|
||||
let outcome = round.market.outcomes.first(where: { $0.id.uuidString == selectedOutcomeID }) else {
|
||||
return localization.string(for: "round.selection_prompt")
|
||||
}
|
||||
|
||||
return outcomeTitle(outcome)
|
||||
}
|
||||
|
||||
private func winningOutcomeTitle(for round: HermesRound) -> String {
|
||||
guard let outcome = round.market.outcomes.first(where: { $0.id == round.settlement.winningOutcomeId }) else {
|
||||
return localization.string(for: "round.selection_prompt")
|
||||
}
|
||||
|
||||
return outcomeTitle(outcome)
|
||||
}
|
||||
|
||||
private func formatOdds(_ round: HermesRound) -> String {
|
||||
let oddsByOutcomeId = Dictionary(uniqueKeysWithValues: round.oddsVersion.odds.map { ($0.outcomeId, $0) })
|
||||
return round.market.outcomes
|
||||
.sorted(by: { $0.sortOrder < $1.sortOrder })
|
||||
.compactMap { oddsByOutcomeId[$0.id]?.decimalOdds }
|
||||
.map { String(format: "%.2f", $0) }
|
||||
.joined(separator: " / ")
|
||||
}
|
||||
|
||||
private func phaseLabel(isTimerLocked: Bool) -> String {
|
||||
switch phase {
|
||||
case .preview:
|
||||
if isTimerLocked {
|
||||
return localization.string(for: "round.locked_label")
|
||||
}
|
||||
return localization.string(for: "round.preview_label")
|
||||
case .locked:
|
||||
return localization.string(for: "round.locked_label")
|
||||
case .reveal:
|
||||
return localization.string(for: "reveal.title")
|
||||
case .result:
|
||||
return localization.string(for: "result.title")
|
||||
}
|
||||
}
|
||||
|
||||
private static func countdownText(for remaining: TimeInterval) -> String {
|
||||
@@ -226,4 +405,16 @@ struct RoundView: View {
|
||||
let seconds = totalSeconds % 60
|
||||
return String(format: "%02d:%02d", minutes, seconds)
|
||||
}
|
||||
|
||||
private func baseSelectionAttributes(_ outcomeId: String) -> [String: String] {
|
||||
["screen_name": "round", "outcome_id": outcomeId]
|
||||
}
|
||||
|
||||
private func roundAnalyticsAttributes(_ round: HermesRound) -> [String: String] {
|
||||
[
|
||||
"screen_name": "round",
|
||||
"event_id": round.event.id.uuidString,
|
||||
"market_id": round.market.id.uuidString,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,147 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SessionView: View {
|
||||
@EnvironmentObject private var analytics: HermesAnalyticsClient
|
||||
@EnvironmentObject private var localization: LocalizationStore
|
||||
@EnvironmentObject private var repository: HermesRepository
|
||||
|
||||
let onRetry: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Text("Session scaffold")
|
||||
let session = repository.currentSession
|
||||
let bannerMessage = hermesUserFacingErrorMessage(
|
||||
localization: localization,
|
||||
localeCode: localization.localeCode,
|
||||
error: repository.errorCause
|
||||
)
|
||||
let statusText: String
|
||||
if session != nil {
|
||||
statusText = localization.string(for: "session.status_ready")
|
||||
} else if bannerMessage != nil {
|
||||
statusText = localization.string(for: "session.status_error")
|
||||
} else {
|
||||
statusText = localization.string(for: "session.status_loading")
|
||||
}
|
||||
|
||||
return HermesCard {
|
||||
VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) {
|
||||
HermesSectionHeader(
|
||||
title: localization.string(for: "session.title"),
|
||||
subtitle: localization.string(for: "session.subtitle")
|
||||
)
|
||||
|
||||
if session == nil {
|
||||
if let bannerMessage {
|
||||
sessionErrorState(
|
||||
message: bannerMessage,
|
||||
retryText: localization.string(for: "common.retry"),
|
||||
onRetry: onRetry
|
||||
)
|
||||
} else {
|
||||
sessionLoadingState(
|
||||
title: statusText,
|
||||
subtitle: localization.string(for: "session.note")
|
||||
)
|
||||
}
|
||||
} else {
|
||||
sessionStatusBadge(text: statusText, warning: bannerMessage != nil)
|
||||
|
||||
if let bannerMessage {
|
||||
sessionBanner(message: bannerMessage)
|
||||
}
|
||||
|
||||
sessionRow(label: localization.string(for: "session.id_label"), value: session?.sessionId.uuidString ?? "--")
|
||||
sessionRow(label: localization.string(for: "session.user_id_label"), value: session?.userId.uuidString ?? "--")
|
||||
sessionRow(
|
||||
label: localization.string(for: "session.locale_label"),
|
||||
value: localization.localeName(for: session?.localeCode ?? localization.localeCode)
|
||||
)
|
||||
sessionRow(label: localization.string(for: "session.started_label"), value: session.map { Self.compactDateFormatter.string(from: $0.startedAt) } ?? "--")
|
||||
sessionRow(label: localization.string(for: "session.variant_label"), value: session?.experimentVariant ?? "--")
|
||||
sessionRow(label: localization.string(for: "session.app_version_label"), value: session?.appVersion ?? "--")
|
||||
sessionRow(label: localization.string(for: "session.device_model_label"), value: session?.deviceModel ?? "--")
|
||||
sessionRow(label: localization.string(for: "session.os_version_label"), value: session?.osVersion ?? "--")
|
||||
|
||||
Text(localization.string(for: "session.note"))
|
||||
.font(.callout)
|
||||
.foregroundStyle(HermesTheme.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
analytics.track("screen_viewed", attributes: ["screen_name": "session"])
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sessionLoadingState(title: String, subtitle: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundStyle(HermesTheme.textPrimary)
|
||||
Text(subtitle)
|
||||
.font(.callout)
|
||||
.foregroundStyle(HermesTheme.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sessionErrorState(message: String, retryText: String, onRetry: @escaping () -> Void) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(message)
|
||||
.font(.callout)
|
||||
.foregroundStyle(HermesTheme.warning)
|
||||
|
||||
Button {
|
||||
onRetry()
|
||||
} label: {
|
||||
Text(retryText)
|
||||
}
|
||||
.buttonStyle(HermesSecondaryButtonStyle())
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sessionStatusBadge(text: String, warning: Bool) -> some View {
|
||||
Text(text)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(warning ? HermesTheme.warning : HermesTheme.accent)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background((warning ? HermesTheme.warning : HermesTheme.accent).opacity(0.16))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sessionBanner(message: String) -> some View {
|
||||
Text(message)
|
||||
.font(.callout)
|
||||
.foregroundStyle(HermesTheme.warning)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(HermesTheme.warning.opacity(0.12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: HermesTheme.insetRadius, style: .continuous))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sessionRow(label: String, value: String) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.callout)
|
||||
.foregroundStyle(HermesTheme.textSecondary)
|
||||
Spacer(minLength: 12)
|
||||
Text(value)
|
||||
.font(.callout.weight(.semibold))
|
||||
.foregroundStyle(HermesTheme.textPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
private static let compactDateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = .current
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm"
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# iOS App
|
||||
|
||||
Native SwiftUI client scaffold for the Hermes study app.
|
||||
Native SwiftUI client scaffold for the Hermes app.
|
||||
|
||||
Planned structure:
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
"app.name" = "Hermes";
|
||||
"app.subtitle" = "Native study app prototype";
|
||||
"onboarding.title" = "Study intro";
|
||||
"app.subtitle" = "Native Hermes app prototype";
|
||||
"locale_english" = "English";
|
||||
"locale_swedish" = "Swedish";
|
||||
"common.loading" = "Loading";
|
||||
"common.retry" = "Retry";
|
||||
"errors.generic" = "Please try again.";
|
||||
"errors.network" = "Network error. Check your connection.";
|
||||
"errors.session_expired" = "Session expired. Please start again.";
|
||||
"onboarding.title" = "Welcome to Hermes";
|
||||
"onboarding.subtitle" = "Watch the clip, decide before lock, then see the reveal.";
|
||||
"onboarding.consent_body" = "This prototype is for research and does not use real money.";
|
||||
"onboarding.consent_note" = "You can switch languages at any time.";
|
||||
@@ -35,3 +42,17 @@
|
||||
"result.win" = "Winning selection";
|
||||
"result.lose" = "Not this time";
|
||||
"result.next_round" = "Next round";
|
||||
"session.title" = "Session";
|
||||
"session.subtitle" = "Session sync and lifecycle controls.";
|
||||
"session.note" = "Session state will appear here once the backend session is started.";
|
||||
"session.status_loading" = "Starting session";
|
||||
"session.status_ready" = "Session active";
|
||||
"session.status_error" = "Session unavailable";
|
||||
"session.id_label" = "Session ID";
|
||||
"session.user_id_label" = "User ID";
|
||||
"session.locale_label" = "Locale";
|
||||
"session.started_label" = "Started";
|
||||
"session.variant_label" = "Variant";
|
||||
"session.app_version_label" = "App version";
|
||||
"session.device_model_label" = "Device model";
|
||||
"session.os_version_label" = "OS version";
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
"app.name" = "Hermes";
|
||||
"app.subtitle" = "Native prototype för studien";
|
||||
"onboarding.title" = "Studieintro";
|
||||
"app.subtitle" = "Native Hermes-prototyp";
|
||||
"locale_english" = "Engelska";
|
||||
"locale_swedish" = "Svenska";
|
||||
"common.loading" = "Laddar";
|
||||
"common.retry" = "Försök igen";
|
||||
"errors.generic" = "Försök igen.";
|
||||
"errors.network" = "Nätverksfel. Kontrollera anslutningen.";
|
||||
"errors.session_expired" = "Sessionen har gått ut. Starta igen.";
|
||||
"onboarding.title" = "Välkommen till Hermes";
|
||||
"onboarding.subtitle" = "Titta på klippet, välj före låsning och se sedan avslöjandet.";
|
||||
"onboarding.consent_body" = "Den här prototypen är för forskning och använder inga riktiga pengar.";
|
||||
"onboarding.consent_note" = "Du kan byta språk när som helst.";
|
||||
@@ -35,3 +42,17 @@
|
||||
"result.win" = "Vinnande val";
|
||||
"result.lose" = "Inte denna gång";
|
||||
"result.next_round" = "Nästa runda";
|
||||
"session.title" = "Session";
|
||||
"session.subtitle" = "Sessionssynk och livscykelkontroller.";
|
||||
"session.note" = "Sessionsstatus visas här när backend-sessionen har startat.";
|
||||
"session.status_loading" = "Startar session";
|
||||
"session.status_ready" = "Session aktiv";
|
||||
"session.status_error" = "Sessionen är otillgänglig";
|
||||
"session.id_label" = "Sessions-ID";
|
||||
"session.user_id_label" = "Användar-ID";
|
||||
"session.locale_label" = "Språk";
|
||||
"session.started_label" = "Startad";
|
||||
"session.variant_label" = "Variant";
|
||||
"session.app_version_label" = "Appversion";
|
||||
"session.device_model_label" = "Enhetsmodell";
|
||||
"session.os_version_label" = "OS-version";
|
||||
|
||||
Reference in New Issue
Block a user