Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 111 additions & 3 deletions apps/HeartCoach/iOS/Views/DashboardView+ThumpCheck.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ extension DashboardView {
// Badge is tappable — navigates to buddy recommendations
Button {
InteractionLog.log(.buttonTap, element: "readiness_badge", page: "Dashboard")
withAnimation { selectedTab = 1 }
showReadinessDetail = true
} label: {
HStack(spacing: 4) {
Text(thumpCheckBadge(result))
Expand All @@ -42,8 +42,8 @@ extension DashboardView {
)
}
.buttonStyle(.plain)
.accessibilityLabel("View buddy recommendations")
.accessibilityHint("Opens Insights tab")
.accessibilityLabel("View readiness breakdown")
.accessibilityHint("Shows what's driving your score")
}

// Main recommendation — context-aware sentence
Expand Down Expand Up @@ -102,6 +102,9 @@ extension DashboardView {
"Thump Check: \(thumpCheckRecommendation(result))"
)
.accessibilityIdentifier("dashboard_readiness_card")
.sheet(isPresented: $showReadinessDetail) {
readinessDetailSheet(result)
}
} else if let assessment = viewModel.assessment {
StatusCardView(
status: assessment.status,
Expand Down Expand Up @@ -334,6 +337,10 @@ extension DashboardView {
)
.accessibilityElement(children: .combine)
.accessibilityLabel("Recovery note: \(ctx.reason). Tonight: \(ctx.tonightAction)")
.onTapGesture {
InteractionLog.log(.cardTap, element: "recovery_context_banner", page: "Dashboard")
withAnimation { selectedTab = 2 }
}
}

/// Shows week-over-week RHR change and recovery trend as a compact banner.
Expand Down Expand Up @@ -418,6 +425,107 @@ extension DashboardView {
}
}

// MARK: - Readiness Detail Sheet

func readinessDetailSheet(_ result: ReadinessResult) -> some View {
NavigationStack {
ScrollView {
VStack(spacing: 20) {
// Score circle + level
VStack(spacing: 8) {
ZStack {
Circle()
.stroke(readinessColor(for: result.level).opacity(0.2), lineWidth: 10)
.frame(width: 100, height: 100)
Circle()
.trim(from: 0, to: Double(result.score) / 100.0)
.stroke(readinessColor(for: result.level), style: StrokeStyle(lineWidth: 10, lineCap: .round))
.rotationEffect(.degrees(-90))
.frame(width: 100, height: 100)
Text("\(result.score)")
.font(.system(size: 32, weight: .bold, design: .rounded))
.foregroundStyle(.primary)
}

Text(thumpCheckBadge(result))
.font(.subheadline)
.fontWeight(.semibold)
.foregroundStyle(readinessColor(for: result.level))

Text(result.summary)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
}
.padding(.top, 8)

// Pillar breakdown
VStack(spacing: 12) {
Text("What's Driving Your Score")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)

ForEach(result.pillars, id: \.type) { pillar in
HStack(spacing: 12) {
Image(systemName: pillar.type.icon)
.font(.title3)
.foregroundStyle(pillarColor(score: pillar.score))
.frame(width: 28)

VStack(alignment: .leading, spacing: 4) {
HStack {
Text(pillar.type.displayName)
.font(.subheadline)
.fontWeight(.semibold)
Spacer()
Text("\(Int(pillar.score))")
.font(.subheadline)
.fontWeight(.bold)
.fontDesign(.rounded)
.foregroundStyle(pillarColor(score: pillar.score))
}

// Score bar
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 3)
.fill(Color(.systemGray5))
.frame(height: 6)
RoundedRectangle(cornerRadius: 3)
.fill(pillarColor(score: pillar.score))
.frame(width: geo.size.width * CGFloat(pillar.score / 100.0), height: 6)
}
}
.frame(height: 6)

Text(pillar.detail)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(pillarColor(score: pillar.score).opacity(0.06))
)
}
}
.padding(.horizontal, 16)
}
.padding(.bottom, 32)
}
.navigationTitle("Readiness Breakdown")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { showReadinessDetail = false }
}
}
}
.presentationDetents([.medium, .large])
}

func readinessColor(for level: ReadinessLevel) -> Color {
switch level {
case .primed: return Color(hex: 0x22C55E)
Expand Down
3 changes: 3 additions & 0 deletions apps/HeartCoach/iOS/Views/DashboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ struct DashboardView: View {
/// Controls the Bio Age detail sheet presentation.
@State private var showBioAgeDetail = false

/// Controls the Readiness detail sheet presentation.
@State var showReadinessDetail = false

// MARK: - Grid Layout

private let metricColumns = [
Expand Down
43 changes: 32 additions & 11 deletions apps/HeartCoach/iOS/Views/TrendsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,16 +114,16 @@ struct TrendsView: View {
// MARK: - Metric Picker

private var metricPicker: some View {
VStack(spacing: 8) {
HStack(spacing: 8) {
metricChip("RHR", icon: "heart.fill", metric: .restingHR)
metricChip("HRV", icon: "waveform.path.ecg", metric: .hrv)
metricChip("Recovery", icon: "arrow.uturn.up", metric: .recovery)
}
HStack(spacing: 8) {
metricChip("Cardio Fitness", icon: "lungs.fill", metric: .vo2Max)
metricChip("Active", icon: "figure.run", metric: .activeMinutes)
}
LazyVGrid(columns: [
GridItem(.flexible(), spacing: 8),
GridItem(.flexible(), spacing: 8),
GridItem(.flexible(), spacing: 8)
], spacing: 8) {
metricChip("RHR", icon: "heart.fill", metric: .restingHR)
metricChip("HRV", icon: "waveform.path.ecg", metric: .hrv)
metricChip("Recovery", icon: "arrow.uturn.up", metric: .recovery)
metricChip("Cardio", icon: "lungs.fill", metric: .vo2Max)
metricChip("Active", icon: "figure.run", metric: .activeMinutes)
}
.accessibilityIdentifier("metric_selector")
}
Expand All @@ -143,9 +143,10 @@ struct TrendsView: View {
.font(.system(size: 11, weight: .semibold))
Text(label)
.font(.system(size: 13, weight: .semibold, design: .rounded))
.lineLimit(1)
}
.foregroundStyle(isSelected ? .white : .primary)
.padding(.horizontal, 14)
.frame(maxWidth: .infinity)
.padding(.vertical, 9)
.background(chipColor, in: Capsule())
.overlay(
Expand Down Expand Up @@ -216,6 +217,11 @@ struct TrendsView: View {
}
}

Text(metricExplainer)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)

TrendChartView(
dataPoints: points,
metricLabel: metricUnit,
Expand Down Expand Up @@ -996,6 +1002,21 @@ struct TrendsView: View {
}
}

private var metricExplainer: String {
switch viewModel.selectedMetric {
case .restingHR:
return "Your heart rate at complete rest — lower generally means better cardiovascular fitness. Athletes often sit in the 40–60 bpm range."
case .hrv:
return "The variation in time between heartbeats. Higher HRV signals better stress resilience and recovery capacity."
case .recovery:
return "How quickly your heart rate drops after exercise. A faster drop (higher number) indicates stronger cardiovascular fitness."
case .vo2Max:
return "An estimate of your VO2 max — how efficiently your body uses oxygen. Higher scores mean better endurance."
case .activeMinutes:
return "Total minutes of walking and workout activity. The AHA recommends 150+ minutes of moderate activity per week."
}
}

private var metricIcon: String {
switch viewModel.selectedMetric {
case .restingHR: return "heart.fill"
Expand Down
Loading