scaffolding hermes flow and audit logging

This commit is contained in:
2026-04-09 18:54:10 +02:00
parent e401b6dbab
commit cf5316a2c1
59 changed files with 1830 additions and 593 deletions
+155 -37
View File
@@ -3,55 +3,173 @@ import SwiftUI
struct FeedView: View {
@EnvironmentObject private var localization: LocalizationStore
@EnvironmentObject private var analytics: HermesAnalyticsClient
@EnvironmentObject private var repository: HermesRepository
let onWatchPreview: () -> Void = {}
let onRetry: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) {
HermesSectionHeader(
title: localization.string(for: "feed.title"),
subtitle: localization.string(for: "feed.subtitle")
TimelineView(.periodic(from: Date(), by: 1)) { _ in
let round = repository.currentRound
let now = repository.serverNow()
let bannerMessage = hermesUserFacingErrorMessage(
localization: localization,
localeCode: localization.localeCode,
error: repository.errorCause
)
ZStack(alignment: .bottomLeading) {
RoundedRectangle(cornerRadius: HermesTheme.cornerRadius, style: .continuous)
.fill(
LinearGradient(
colors: [HermesTheme.surfaceElevated, HermesTheme.background],
startPoint: .topLeading,
endPoint: .bottomTrailing
VStack(alignment: .leading, spacing: HermesTheme.sectionSpacing) {
HermesSectionHeader(
title: localization.string(for: "feed.title"),
subtitle: localization.string(for: "feed.subtitle")
)
if round == nil {
if let bannerMessage {
feedErrorState(
message: bannerMessage,
retryText: localization.string(for: "common.retry"),
onRetry: onRetry
)
)
.frame(height: 220)
} else {
feedLoadingState(
title: localization.string(for: "common.loading"),
subtitle: localization.string(for: "feed.subtitle")
)
}
} else {
if let bannerMessage {
feedBanner(message: bannerMessage)
}
VStack(alignment: .leading, spacing: 8) {
Text(localization.string(for: "feed.hero_title"))
.font(.title2.weight(.bold))
.foregroundStyle(HermesTheme.textPrimary)
heroCard(round: round)
Text(localization.string(for: "feed.hero_subtitle"))
.font(.callout)
.foregroundStyle(HermesTheme.textSecondary)
.frame(maxWidth: 260, alignment: .leading)
HStack(spacing: 12) {
HermesMetricPill(
label: localization.string(for: "feed.lock_label"),
value: round.map { Self.countdownText(for: $0.event.lockAt.timeIntervalSince(now)) } ?? "--:--"
)
HermesMetricPill(
label: localization.string(for: "feed.odds_label"),
value: round.map { Self.formatOdds($0) } ?? "--"
)
}
Button {
analytics.track("next_round_requested", attributes: ["screen_name": "feed"])
analytics.track("cta_pressed", attributes: ["screen_name": "feed", "action": "watch_preview"])
onWatchPreview()
} label: {
Text(localization.string(for: "feed.cta"))
}
.buttonStyle(HermesPrimaryButtonStyle())
.disabled(round == nil)
}
.padding(HermesTheme.contentPadding)
}
HStack(spacing: 12) {
HermesMetricPill(label: localization.string(for: "feed.lock_label"), value: "01:42")
HermesMetricPill(label: localization.string(for: "feed.odds_label"), value: "1.85 / 2.05")
.onAppear {
analytics.track("feed_viewed", attributes: ["screen_name": "feed"])
analytics.track("screen_viewed", attributes: ["screen_name": "feed"])
}
Button {
analytics.track("next_round_requested", attributes: ["screen_name": "feed"])
analytics.track("cta_pressed", attributes: ["screen_name": "feed", "action": "watch_preview"])
} label: {
Text(localization.string(for: "feed.cta"))
}
.buttonStyle(HermesPrimaryButtonStyle())
}
.hermesCard(elevated: true)
.onAppear {
analytics.track("feed_viewed", attributes: ["screen_name": "feed"])
analytics.track("screen_viewed", attributes: ["screen_name": "feed"])
}
@ViewBuilder
private func heroCard(round: HermesRound?) -> some View {
ZStack(alignment: .bottomLeading) {
RoundedRectangle(cornerRadius: HermesTheme.cornerRadius, style: .continuous)
.fill(
LinearGradient(
colors: [HermesTheme.surfaceElevated, HermesTheme.background],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(height: 220)
VStack(alignment: .leading, spacing: 8) {
Text(round.map { localizedEventTitle($0) } ?? localization.string(for: "feed.hero_title"))
.font(.title2.weight(.bold))
.foregroundStyle(HermesTheme.textPrimary)
Text(round.map { localization.string(for: "feed.hero_subtitle") } ?? localization.string(for: "feed.hero_subtitle"))
.font(.callout)
.foregroundStyle(HermesTheme.textSecondary)
.frame(maxWidth: 260, alignment: .leading)
}
.padding(HermesTheme.contentPadding)
}
}
@ViewBuilder
private func feedLoadingState(title: String, subtitle: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.title2.weight(.bold))
.foregroundStyle(HermesTheme.textPrimary)
Text(subtitle)
.font(.callout)
.foregroundStyle(HermesTheme.textSecondary)
}
.padding(HermesTheme.contentPadding)
.frame(maxWidth: .infinity, minHeight: 220, alignment: .center)
.background(
RoundedRectangle(cornerRadius: HermesTheme.cornerRadius, style: .continuous)
.fill(
LinearGradient(
colors: [HermesTheme.surfaceElevated, HermesTheme.background],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
)
}
@ViewBuilder
private func feedErrorState(message: String, retryText: String, onRetry: @escaping () -> Void) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text(message)
.font(.callout)
.foregroundStyle(HermesTheme.warning)
Button {
onRetry()
} label: {
Text(retryText)
}
.buttonStyle(HermesSecondaryButtonStyle())
}
}
@ViewBuilder
private func feedBanner(message: String) -> some View {
Text(message)
.font(.callout)
.foregroundStyle(HermesTheme.warning)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(HermesTheme.warning.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: HermesTheme.insetRadius, style: .continuous))
}
private func localizedEventTitle(_ round: HermesRound) -> String {
localization.localeCode == "sv" ? round.event.titleSv : round.event.titleEn
}
private static func countdownText(for remaining: TimeInterval) -> String {
let totalSeconds = max(Int(remaining.rounded(.down)), 0)
let minutes = totalSeconds / 60
let seconds = totalSeconds % 60
return String(format: "%02d:%02d", minutes, seconds)
}
private static func formatOdds(_ round: HermesRound) -> String {
let oddsByOutcomeId = Dictionary(uniqueKeysWithValues: round.oddsVersion.odds.map { ($0.outcomeId, $0) })
return round.market.outcomes
.sorted(by: { $0.sortOrder < $1.sortOrder })
.compactMap { oddsByOutcomeId[$0.id]?.decimalOdds }
.map { String(format: "%.2f", $0) }
.joined(separator: " / ")
}
}