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