diff --git a/apps/HeartCoach/Shared/Theme/DashboardHeroPresentation.swift b/apps/HeartCoach/Shared/Theme/DashboardHeroPresentation.swift
new file mode 100644
index 0000000..377439a
--- /dev/null
+++ b/apps/HeartCoach/Shared/Theme/DashboardHeroPresentation.swift
@@ -0,0 +1,75 @@
+import Foundation
+
+enum DashboardHeroPresentation {
+ static func greetingPrefix(for hour: Int) -> String {
+ switch hour {
+ case 5..<12:
+ return "Good morning"
+ case 12..<17:
+ return "Good afternoon"
+ case 17..<21:
+ return "Good evening"
+ default:
+ return "Good night"
+ }
+ }
+
+ static func mood(
+ assessment: HeartAssessment?,
+ readinessScore: Int?,
+ hour: Int
+ ) -> BuddyMood {
+ guard let assessment else {
+ return isQuietHours(hour) ? .tired : .content
+ }
+
+ let baseMood = BuddyMood.from(
+ assessment: assessment,
+ readinessScore: readinessScore,
+ currentHour: hour
+ )
+
+ guard isQuietHours(hour) else {
+ return baseMood
+ }
+
+ switch baseMood {
+ case .stressed:
+ return .stressed
+ case .tired:
+ return .tired
+ default:
+ return .tired
+ }
+ }
+
+ static func isQuietHours(_ hour: Int) -> Bool {
+ hour >= 21 || hour < 5
+ }
+}
+
+enum DashboardUITestOverrides {
+ static var readinessScore: Int? {
+ value(for: "-UITestReadinessScore").flatMap(Int.init)
+ }
+
+ static var hour: Int? {
+ guard let parsed = value(for: "-UITestHour").flatMap(Int.init),
+ (0..<24).contains(parsed) else {
+ return nil
+ }
+ return parsed
+ }
+
+ static var useDesignB: Bool {
+ CommandLine.arguments.contains("-UITest_UseDesignB")
+ }
+
+ private static func value(for flag: String) -> String? {
+ guard let index = CommandLine.arguments.firstIndex(of: flag),
+ index + 1 < CommandLine.arguments.count else {
+ return nil
+ }
+ return CommandLine.arguments[index + 1]
+ }
+}
diff --git a/apps/HeartCoach/Shared/Views/ThumpBuddy.swift b/apps/HeartCoach/Shared/Views/ThumpBuddy.swift
index 12d1b5c..62748c9 100644
--- a/apps/HeartCoach/Shared/Views/ThumpBuddy.swift
+++ b/apps/HeartCoach/Shared/Views/ThumpBuddy.swift
@@ -48,7 +48,8 @@ enum BuddyMood: String, Equatable, Sendable {
readinessScore: Int? = nil,
nudgeCompleted: Bool = false,
feedbackType: DailyFeedback? = nil,
- activityInProgress: Bool = false
+ activityInProgress: Bool = false,
+ currentHour: Int? = nil
) -> BuddyMood {
if nudgeCompleted { return .conquering }
if feedbackType == .positive { return .conquering }
@@ -69,7 +70,7 @@ enum BuddyMood: String, Equatable, Sendable {
}
// Low readiness (< 40): genuinely tired — BUT only show sleeping
// mood in evening hours. During daytime, show nudging instead.
- let hour = Calendar.current.component(.hour, from: Date())
+ let hour = currentHour ?? Calendar.current.component(.hour, from: Date())
let isEvening = hour >= 20 || hour < 6
return isEvening ? .tired : .nudging
}
diff --git a/apps/HeartCoach/Tests/DashboardHeroPresentationTests.swift b/apps/HeartCoach/Tests/DashboardHeroPresentationTests.swift
new file mode 100644
index 0000000..0c22649
--- /dev/null
+++ b/apps/HeartCoach/Tests/DashboardHeroPresentationTests.swift
@@ -0,0 +1,67 @@
+import XCTest
+@testable import Thump
+
+final class DashboardHeroPresentationTests: XCTestCase {
+
+ func testNightPresentation_usesRestfulMoodForRecoveringScore() {
+ let mood = DashboardHeroPresentation.mood(
+ assessment: makeAssessment(status: .stable, stressFlag: false),
+ readinessScore: 55,
+ hour: 22
+ )
+
+ XCTAssertEqual(mood, .tired)
+ }
+
+ func testDayPresentation_keepsDaytimeMoodForRecoveringScore() {
+ let mood = DashboardHeroPresentation.mood(
+ assessment: makeAssessment(status: .stable, stressFlag: false),
+ readinessScore: 55,
+ hour: 10
+ )
+
+ XCTAssertEqual(mood, .nudging)
+ }
+
+ func testNightPresentation_usesRestfulMoodEvenWhenReadinessIsHigh() {
+ let mood = DashboardHeroPresentation.mood(
+ assessment: makeAssessment(status: .improving, stressFlag: false),
+ readinessScore: 88,
+ hour: 23
+ )
+
+ XCTAssertEqual(mood, .tired)
+ }
+
+ func testNightPresentation_preservesStressedMoodWhenStressIsHigh() {
+ let mood = DashboardHeroPresentation.mood(
+ assessment: makeAssessment(status: .needsAttention, stressFlag: true),
+ readinessScore: 55,
+ hour: 23
+ )
+
+ XCTAssertEqual(mood, .stressed)
+ }
+
+ private func makeAssessment(status: TrendStatus, stressFlag: Bool) -> HeartAssessment {
+ let nudge = DailyNudge(
+ category: .rest,
+ title: "Rest",
+ description: "Take it easy tonight.",
+ durationMinutes: nil,
+ icon: "bed.double.fill"
+ )
+
+ return HeartAssessment(
+ status: status,
+ confidence: .high,
+ anomalyScore: stressFlag ? 2.0 : 0.2,
+ regressionFlag: stressFlag,
+ stressFlag: stressFlag,
+ cardioScore: 60,
+ dailyNudge: nudge,
+ dailyNudges: [nudge],
+ explanation: "Test assessment"
+ )
+ }
+}
diff --git a/apps/HeartCoach/UITests/BuddyShowcaseTests.swift b/apps/HeartCoach/UITests/BuddyShowcaseTests.swift
index 2bacf7b..1dc17d7 100644
--- a/apps/HeartCoach/UITests/BuddyShowcaseTests.swift
+++ b/apps/HeartCoach/UITests/BuddyShowcaseTests.swift
@@ -2,10 +2,16 @@ import XCTest
final class BuddyShowcaseTests: XCTestCase {
- func testCaptureDashboardBuddy() throws {
+ private func launchApp() -> XCUIApplication {
let app = XCUIApplication()
- app.launchArguments = ["-UITestMode", "-startTab", "0"]
+ app.launchArguments = ["-UITestMode", "-UITest_UseDesignB", "-startTab", "0"]
app.launch()
+ XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10))
+ return app
+ }
+
+ func testCaptureDashboardBuddy() throws {
+ let app = launchApp()
sleep(4) // Let animations settle
@@ -17,9 +23,7 @@ final class BuddyShowcaseTests: XCTestCase {
}
func testCaptureAllTabs() throws {
- let app = XCUIApplication()
- app.launchArguments = ["-UITestMode", "-startTab", "0"]
- app.launch()
+ let app = launchApp()
sleep(3)
// Dashboard (Home)
@@ -29,7 +33,8 @@ final class BuddyShowcaseTests: XCTestCase {
add(shot1)
// Insights tab
- app.tabBars.buttons.element(boundBy: 1).tap()
+ XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: 10))
+ app.tabBars.buttons["Insights"].tap()
sleep(2)
let shot2 = XCTAttachment(screenshot: app.screenshot())
shot2.name = "Tab_Insights"
@@ -37,7 +42,7 @@ final class BuddyShowcaseTests: XCTestCase {
add(shot2)
// Stress tab
- app.tabBars.buttons.element(boundBy: 2).tap()
+ app.tabBars.buttons["Stress"].tap()
sleep(2)
let shot3 = XCTAttachment(screenshot: app.screenshot())
shot3.name = "Tab_Stress"
@@ -45,7 +50,7 @@ final class BuddyShowcaseTests: XCTestCase {
add(shot3)
// Trends tab
- app.tabBars.buttons.element(boundBy: 3).tap()
+ app.tabBars.buttons["Trends"].tap()
sleep(2)
let shot4 = XCTAttachment(screenshot: app.screenshot())
shot4.name = "Tab_Trends"
@@ -53,7 +58,7 @@ final class BuddyShowcaseTests: XCTestCase {
add(shot4)
// Settings tab
- app.tabBars.buttons.element(boundBy: 4).tap()
+ app.tabBars.buttons["Settings"].tap()
sleep(2)
let shot5 = XCTAttachment(screenshot: app.screenshot())
shot5.name = "Tab_Settings"
diff --git a/apps/HeartCoach/UITests/ClickableValidationTests.swift b/apps/HeartCoach/UITests/ClickableValidationTests.swift
index 15ce4b8..a2457cc 100644
--- a/apps/HeartCoach/UITests/ClickableValidationTests.swift
+++ b/apps/HeartCoach/UITests/ClickableValidationTests.swift
@@ -467,6 +467,55 @@ final class ClickableValidationTests: XCTestCase {
XCTAssertTrue(hasLongText, "Stressed state must show a mission sentence on Home screen")
}
+ func testDesignBHomeNightState_usesRestfulBuddy() {
+ app.terminate()
+ app.launchArguments = [
+ "-UITestMode",
+ "-UITest_UseDesignB",
+ "-startTab", "0",
+ "-UITestHour", "22",
+ "-UITestReadinessScore", "55"
+ ]
+ app.launch()
+ XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10))
+
+ navigateToTab("Home")
+ let hero = app.otherElements["dashboard_hero"]
+ XCTAssertTrue(hero.waitForExistence(timeout: 5), "Home hero should be visible on Home")
+
+ screenshot("design_b_night_buddy")
+
+ XCTAssertTrue(hero.label.contains("Good night"), "Night hero should use the nighttime greeting")
+ XCTAssertTrue(hero.label.contains("Rest Up"), "Night hero should show the restful buddy mood")
+ XCTAssertFalse(hero.label.contains("Train Your Heart"), "Night hero should not show the daytime nudging face")
+ XCTAssertFalse(hero.label.contains("In the Zone"), "Night hero should not show the active face")
+ }
+
+ func testDesignBHomeNightState_overridesHighReadinessEnergy() {
+ app.terminate()
+ app.launchArguments = [
+ "-UITestMode",
+ "-UITest_UseDesignB",
+ "-startTab", "0",
+ "-UITestHour", "22",
+ "-UITestReadinessScore", "88"
+ ]
+ app.launch()
+ XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10))
+
+ navigateToTab("Home")
+ let hero = app.otherElements["dashboard_hero"]
+ XCTAssertTrue(hero.waitForExistence(timeout: 5), "Home hero should be visible on Home")
+
+ screenshot("design_b_night_high_readiness")
+
+ XCTAssertTrue(hero.label.contains("Good night"), "Night hero should keep the nighttime greeting")
+ XCTAssertTrue(hero.label.contains("Rest Up"), "Night hero should force the resting buddy mood at night")
+ XCTAssertFalse(hero.label.contains("Crushing It"), "Night hero should not show the high-energy thriving face")
+ XCTAssertFalse(hero.label.contains("Heart Happy"), "Night hero should not show the daytime content face")
+ XCTAssertFalse(hero.label.contains("In the Zone"), "Night hero should not show the active face")
+ }
+
// MARK: - Helpers
private func navigateToTab(_ name: String) {
diff --git a/apps/HeartCoach/iOS/Info.plist b/apps/HeartCoach/iOS/Info.plist
index 5be51b4..dc92b96 100644
--- a/apps/HeartCoach/iOS/Info.plist
+++ b/apps/HeartCoach/iOS/Info.plist
@@ -16,6 +16,8 @@
NSHealthShareUsageDescription
Thump reads your heart rate, HRV, recovery, VO2 max, steps, exercise, and sleep data to generate wellness insights and training suggestions.
+ NSHealthUpdateUsageDescription
+ Thump writes debug-only simulator samples so we can validate HealthKit-powered screens end to end during development and testing.
UIApplicationSupportsIndirectInputEvents
UILaunchScreen
diff --git a/apps/HeartCoach/iOS/ThumpiOSApp.swift b/apps/HeartCoach/iOS/ThumpiOSApp.swift
index 2f9a92b..dbd2206 100644
--- a/apps/HeartCoach/iOS/ThumpiOSApp.swift
+++ b/apps/HeartCoach/iOS/ThumpiOSApp.swift
@@ -49,13 +49,27 @@ struct ThumpiOSApp: App {
// MARK: - Initialization
init() {
- FirebaseApp.configure()
+ Self.configureFirebase()
let store = LocalStore()
_localStore = StateObject(wrappedValue: store)
_notificationService = StateObject(wrappedValue: NotificationService(localStore: store))
}
+ private static func configureFirebase() {
+ guard let options = FirebaseOptions.defaultOptions() else {
+ FirebaseApp.configure()
+ return
+ }
+
+ if let bundleID = Bundle.main.bundleIdentifier,
+ options.bundleID != bundleID {
+ options.bundleID = bundleID
+ }
+
+ FirebaseApp.configure(options: options)
+ }
+
// MARK: - Scene
var body: some Scene {
diff --git a/apps/HeartCoach/iOS/ViewModels/DashboardViewModel.swift b/apps/HeartCoach/iOS/ViewModels/DashboardViewModel.swift
index 74fc65a..7c123d2 100644
--- a/apps/HeartCoach/iOS/ViewModels/DashboardViewModel.swift
+++ b/apps/HeartCoach/iOS/ViewModels/DashboardViewModel.swift
@@ -138,6 +138,16 @@ final class DashboardViewModel: ObservableObject {
errorMessage = nil
healthDataProvider.clearQueryWarnings()
+ // Timeout: if refresh takes more than 15s, stop loading and show error
+ let timeoutTask = Task { @MainActor in
+ try await Task.sleep(nanoseconds: 15_000_000_000)
+ if self.isLoading && self.assessment == nil {
+ AppLogger.engine.warning("Dashboard refresh timed out after 15s")
+ self.errorMessage = "Loading took too long. Check that Health permissions are granted in Settings > Privacy > Health > Thump."
+ self.isLoading = false
+ }
+ }
+
do {
// Ensure HealthKit authorization
if !healthDataProvider.isAuthorized {
@@ -296,6 +306,7 @@ final class DashboardViewModel: ObservableObject {
let totalMs = (CFAbsoluteTimeGetCurrent() - refreshStart) * 1000
AppLogger.engine.info("Dashboard refresh complete in \(String(format: "%.0f", totalMs))ms — history=\(history.count) days")
+ timeoutTask.cancel()
isLoading = false
// Write diagnostic snapshot for bug reports (BUG-070)
@@ -331,6 +342,7 @@ final class DashboardViewModel: ObservableObject {
EngineTelemetryService.shared.uploadTrace(trace)
} catch {
AppLogger.engine.error("Dashboard refresh failed: \(error.localizedDescription)")
+ timeoutTask.cancel()
errorMessage = error.localizedDescription
isLoading = false
}
diff --git a/apps/HeartCoach/iOS/Views/DashboardView+DesignB.swift b/apps/HeartCoach/iOS/Views/DashboardView+DesignB.swift
index dbfa0c8..12b33dc 100644
--- a/apps/HeartCoach/iOS/Views/DashboardView+DesignB.swift
+++ b/apps/HeartCoach/iOS/Views/DashboardView+DesignB.swift
@@ -53,9 +53,8 @@ extension DashboardView {
/// This is the entry point called from DashboardView when useDesignB = true.
@ViewBuilder
var designBCardStack: some View {
- // When embedded in the existing scroll/hero layout, show the new hero
- // and content sections as cards. Full-screen OLED layout is in designBFullScreen.
- designBHeroCard
+ // The dashboard already renders its primary hero above this card stack.
+ // Keep the Design B card hierarchy here without a second buddy hero.
designBDrivingSignalsCard
designBFeelingVsDataCard
checkInSectionB
@@ -114,6 +113,8 @@ extension DashboardView {
)
.clipShape(RoundedRectangle(cornerRadius: 28))
.animation(.easeInOut(duration: 0.4), value: designBAppState)
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel("\(greetingText). Buddy is feeling \(buddyMood.label). \(designBMissionText ?? "")")
.accessibilityIdentifier("dashboard_design_b_hero")
}
@@ -139,6 +140,9 @@ extension DashboardView {
}
}
.frame(minHeight: 440)
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel("\(greetingText). Buddy is feeling \(buddyMood.label). \(designBMissionText ?? "")")
+ .accessibilityIdentifier("dashboard_design_b_hero")
}
// MARK: - Content Section (below hero)
@@ -187,7 +191,7 @@ extension DashboardView {
// ThumpBuddy — 210pt hero size (§5: minimum 35% screen height)
ThumpBuddy(
- mood: designBAppState.buddyMood,
+ mood: buddyMood,
size: 105, // ThumpBuddy size param = half the display size (frame = size*2)
showAura: designBAppState == .steady, // amber aura for Steady
tappable: true
@@ -200,7 +204,7 @@ extension DashboardView {
/// Score ring — clockwise fill, state color, privacy lock at 6 o'clock.
@ViewBuilder
private var designBScoreRing: some View {
- let score = viewModel.readinessResult?.score ?? 0
+ let score = effectiveReadinessScore ?? 0
let progress = Double(score) / 100.0
let state = designBAppState
@@ -234,7 +238,7 @@ extension DashboardView {
/// Chronic Steady: score demoted to 28pt Regular; "Steady" label promoted to 42pt Bold
@ViewBuilder
private var designBScoreHierarchy: some View {
- let score = viewModel.readinessResult?.score ?? 0
+ let score = effectiveReadinessScore ?? 0
let state = designBAppState
let isSteady = state == .steady
@@ -498,7 +502,7 @@ extension DashboardView {
/// Current app state derived from readiness score + chronic steady flag.
var designBAppState: AppState {
- let score = viewModel.readinessResult?.score ?? 0
+ let score = effectiveReadinessScore ?? 0
// isChronicSteady: placeholder until Tier A lands the DesignTokens property.
// Will be replaced by a real computed property from the view model.
return AppState.from(score: score, isChronicSteady: isChronicSteadyState)
@@ -733,10 +737,10 @@ extension DashboardView {
}
HStack(spacing: 8) {
- checkInButtonB(emoji: "☀️", label: "Great", mood: .great, color: .green)
- checkInButtonB(emoji: "🌤️", label: "Good", mood: .good, color: .teal)
- checkInButtonB(emoji: "☁️", label: "Okay", mood: .okay, color: .orange)
- checkInButtonB(emoji: "🌧️", label: "Rough", mood: .rough, color: .purple)
+ checkInButtonB(icon: "sun.max.fill", label: "Great", mood: .great, color: .green)
+ checkInButtonB(icon: "cloud.sun.fill", label: "Good", mood: .good, color: .teal)
+ checkInButtonB(icon: "cloud.fill", label: "Okay", mood: .okay, color: .orange)
+ checkInButtonB(icon: "cloud.rain.fill", label: "Rough", mood: .rough, color: .purple)
}
}
.padding(16)
@@ -827,12 +831,7 @@ extension DashboardView {
)
.onTapGesture {
InteractionLog.log(.cardTap, element: "recovery_card_b", page: "Dashboard")
- NotificationCenter.default.post(
- name: .thumpOpenTrendsMetric,
- object: nil,
- userInfo: [ThumpSharedKeys.trendsMetricKey: recoveryDrillDownMetric]
- )
- withAnimation { selectedTab = 3 }
+ navigate(to: .trends)
}
}
}
@@ -928,14 +927,15 @@ extension DashboardView {
.frame(maxWidth: .infinity)
}
- private func checkInButtonB(emoji: String, label: String, mood: CheckInMood, color: Color) -> some View {
+ private func checkInButtonB(icon: String, label: String, mood: CheckInMood, color: Color) -> some View {
Button {
viewModel.submitCheckIn(mood: mood)
InteractionLog.log(.buttonTap, element: "check_in_\(label.lowercased())_b", page: "Dashboard")
} label: {
VStack(spacing: 4) {
- Text(emoji)
- .font(.title3)
+ Image(systemName: icon)
+ .font(.system(size: 18, weight: .semibold))
+ .foregroundStyle(color)
Text(label)
.font(.system(size: 10, weight: .medium))
.foregroundStyle(color)
@@ -974,12 +974,7 @@ extension DashboardView {
)
.onTapGesture {
InteractionLog.log(.cardTap, element: "wow_banner_b", page: "Dashboard")
- NotificationCenter.default.post(
- name: .thumpOpenTrendsMetric,
- object: nil,
- userInfo: [ThumpSharedKeys.trendsMetricKey: recoveryDrillDownMetric]
- )
- withAnimation { selectedTab = 3 }
+ navigate(to: .trends)
}
}
@@ -1012,4 +1007,3 @@ extension DashboardView {
}
}
}
-
diff --git a/apps/HeartCoach/iOS/Views/DashboardView.swift b/apps/HeartCoach/iOS/Views/DashboardView.swift
index d83ae21..6bed5cc 100644
--- a/apps/HeartCoach/iOS/Views/DashboardView.swift
+++ b/apps/HeartCoach/iOS/Views/DashboardView.swift
@@ -33,9 +33,6 @@ struct DashboardView: View {
@StateObject var viewModel = DashboardViewModel()
- /// A/B design variant toggle.
- @AppStorage("thump_design_variant_b") private var useDesignB: Bool = false
-
// MARK: - Sheet State
/// Controls the Bio Age detail sheet presentation.
@@ -47,6 +44,15 @@ struct DashboardView: View {
/// The pillar "Why?" explanation currently shown (nil = no sheet).
@State var pillarWhyText: PillarWhyContent?
+ /// Keeps dashboard navigation aligned with the active tab layout.
+ @AppStorage("useNewTabLayout") var useNewTabLayout: Bool = false
+
+ /// Design A/B toggle — controlled from Settings.
+ @AppStorage("thump_design_variant_b") private var useDesignB: Bool = false
+
+ /// Expands the Design B "What's driving this" explainer card.
+ @State var isDrivingSignalsExpanded = false
+
/// Prevents redundant initial refresh work when the tab view re-renders.
@State private var didInitialLoad = false
@@ -128,18 +134,17 @@ struct DashboardView: View {
// Main content cards
VStack(alignment: .leading, spacing: 16) {
- if useDesignB {
+ if isUsingDesignB {
designBCardStack
} else {
- checkInSection // 1. Daily check-in right after hero
- readinessSection // 2. Thump Check (readiness)
- howYouRecoveredCard // 3. How You Recovered (replaces Weekly RHR)
- consecutiveAlertCard // 4. Alert if elevated
- dailyGoalsSection // 5. Daily Goals (engine-driven)
- buddyRecommendationsSection // 6. Buddy Recommendations
- zoneDistributionSection // 7. Heart Rate Zones (dynamic targets)
- buddyCoachSection // 8. Buddy Coach (was "Your Heart Coach")
- streakSection // 9. Streak
+ checkInSection
+ readinessSection
+ zoneDistributionSection
+ dailyGoalsSection
+ nudgeSection
+ buddyRecommendationsSection
+ buddyCoachSection
+ streakSection
}
}
.padding(.horizontal, 16)
@@ -148,6 +153,7 @@ struct DashboardView: View {
.background(Color(.systemGroupedBackground))
}
}
+ .accessibilityIdentifier("dashboard_scroll_view")
}
.accessibilityIdentifier("dashboard_scroll_view")
}
@@ -162,11 +168,11 @@ struct DashboardView: View {
return 388
}
- private var buddyMood: BuddyMood {
- guard let assessment = viewModel.assessment else { return .content }
- return BuddyMood.from(
- assessment: assessment,
- readinessScore: viewModel.readinessResult?.score
+ var buddyMood: BuddyMood {
+ DashboardHeroPresentation.mood(
+ assessment: viewModel.assessment,
+ readinessScore: effectiveReadinessScore,
+ hour: dashboardDisplayHour
)
}
@@ -239,6 +245,7 @@ struct DashboardView: View {
))
.accessibilityElement(children: .combine)
.accessibilityLabel("\(greetingText). Buddy is feeling \(buddyMood.label). \(buddyFocusInsight ?? "")")
+ .accessibilityIdentifier("dashboard_hero")
}
/// Warm gradient that shifts with buddy mood.
@@ -263,7 +270,7 @@ struct DashboardView: View {
/// Synthesizes ALL engine outputs into one human-readable sentence.
/// When coordinator is active, delegates to AdvicePresenter.
- private var buddyFocusInsight: String? {
+ var buddyFocusInsight: String? {
// Coordinator path: use AdvicePresenter
if ConfigService.enableCoordinator,
let adviceState = coordinator.bundle?.adviceState {
@@ -308,19 +315,25 @@ struct DashboardView: View {
// MARK: - Greeting
- private var greetingText: String {
- let hour = Calendar.current.component(.hour, from: Date())
- let greeting: String
- switch hour {
- case 0..<12: greeting = "Good morning"
- case 12..<17: greeting = "Good afternoon"
- case 17..<21: greeting = "Good evening"
- default: greeting = "Good night"
- }
+ var greetingText: String {
+ let greeting = DashboardHeroPresentation.greetingPrefix(for: dashboardDisplayHour)
let name = viewModel.profileName
return name.isEmpty ? greeting : "\(greeting), \(name)"
}
+ var dashboardDisplayHour: Int {
+ DashboardUITestOverrides.hour
+ ?? Calendar.current.component(.hour, from: Date())
+ }
+
+ var effectiveReadinessScore: Int? {
+ DashboardUITestOverrides.readinessScore ?? viewModel.readinessResult?.score
+ }
+
+ private var isUsingDesignB: Bool {
+ DashboardUITestOverrides.useDesignB || useDesignB
+ }
+
private var formattedDate: String {
Date().formatted(.dateTime.weekday(.wide).month(.wide).day())
}
@@ -608,7 +621,7 @@ struct DashboardView: View {
Spacer()
Button {
InteractionLog.log(.buttonTap, element: "see_trends", page: "Dashboard")
- withAnimation { selectedTab = 3 }
+ navigate(to: .trends)
} label: {
HStack(spacing: 4) {
Text("See Trends")
@@ -646,7 +659,7 @@ struct DashboardView: View {
private func metricTileButton(label: String, value: Double?, unit: String, decimals: Int = 0) -> some View {
Button {
InteractionLog.log(.cardTap, element: "metric_tile_\(label.lowercased().replacingOccurrences(of: " ", with: "_"))", page: "Dashboard")
- withAnimation { selectedTab = 3 }
+ navigate(to: .trends)
} label: {
MetricTileView(
label: label,
@@ -671,6 +684,19 @@ struct DashboardView: View {
}
return "\(Int(value.rounded())) \(unit)"
}
+
+ func navigate(to destination: DashboardTabDestination) {
+ withAnimation {
+ selectedTab = DashboardTabRouter.tabIndex(
+ for: destination,
+ useNewTabLayout: useNewTabLayout
+ )
+ }
+ }
+
+ func navigate(for category: NudgeCategory) {
+ navigate(to: DashboardTabRouter.destination(for: category))
+ }
}
/// Button style that adds a subtle press effect for card-like buttons.
diff --git a/apps/HeartCoach/iOS/Views/SettingsView.swift b/apps/HeartCoach/iOS/Views/SettingsView.swift
index 524d838..f2a9d3f 100644
--- a/apps/HeartCoach/iOS/Views/SettingsView.swift
+++ b/apps/HeartCoach/iOS/Views/SettingsView.swift
@@ -81,10 +81,6 @@ struct SettingsView: View {
/// Feedback preferences.
@State private var feedbackPrefs: FeedbackPreferences = FeedbackPreferences()
- /// A/B design variant toggle (false = Design A / current, true = Design B / new).
- @AppStorage("thump_design_variant_b")
- private var useDesignB: Bool = false
-
// MARK: - Body
var body: some View {
@@ -92,7 +88,7 @@ struct SettingsView: View {
Form {
profileSection
subscriptionSection
- designVariantSection
+ designSection
feedbackPreferencesSection
notificationsSection
analyticsSection
@@ -101,6 +97,7 @@ struct SettingsView: View {
aboutSection
disclaimerSection
}
+ .accessibilityIdentifier("settings_screen")
.onAppear {
InteractionLog.pageView("Settings")
feedbackPrefs = localStore.loadFeedbackPreferences()
@@ -273,6 +270,25 @@ struct SettingsView: View {
}
}
+ // MARK: - Design Section
+
+ @AppStorage("thump_design_variant_b") private var useDesignB: Bool = false
+
+ private var designSection: some View {
+ Section {
+ Toggle(isOn: $useDesignB) {
+ Label("Design B (Beta)", systemImage: "paintbrush.fill")
+ }
+ .tint(.pink)
+ } header: {
+ Text("Design Experiment")
+ } footer: {
+ Text(useDesignB
+ ? "You're seeing Design B — a refreshed card layout with enhanced visuals."
+ : "You're seeing Design A — the standard layout.")
+ }
+ }
+
// MARK: - Notifications Section
private var notificationsSection: some View {
@@ -334,26 +350,6 @@ struct SettingsView: View {
}
}
- // MARK: - Design Variant Section
-
- private var designVariantSection: some View {
- Section {
- Toggle(isOn: $useDesignB) {
- Label("Design B (Beta)", systemImage: "paintbrush.fill")
- }
- .tint(.pink)
- .onChange(of: useDesignB) { _, newValue in
- InteractionLog.log(.toggleChange, element: "design_variant_b", page: "Settings")
- }
- } header: {
- Text("Design Experiment")
- } footer: {
- Text(useDesignB
- ? "You're seeing Design B — a refreshed card layout with enhanced visuals."
- : "You're seeing Design A — the current standard layout.")
- }
- }
-
// MARK: - Feedback Preferences Section
private var feedbackPreferencesSection: some View {
@@ -419,6 +415,7 @@ struct SettingsView: View {
} label: {
Label("Report a Bug", systemImage: "ant.fill")
}
+ .accessibilityIdentifier("settings_bug_report_button")
.sheet(isPresented: $showBugReport) {
bugReportSheet
}
@@ -429,6 +426,7 @@ struct SettingsView: View {
} label: {
Label("Send Feature Request", systemImage: "sparkles")
}
+ .accessibilityIdentifier("settings_feature_request_button")
.sheet(isPresented: $showFeatureRequest) {
featureRequestSheet
}
@@ -682,7 +680,8 @@ struct SettingsView: View {
// Active screen state
metrics["currentTab"] = "Settings"
- metrics["designVariantB"] = UserDefaults.standard.bool(forKey: "thump_design_variant_b")
+ metrics["designVariantB"] = true
+ metrics["dashboardDesign"] = "designB"
metrics["anomalyAlertsEnabled"] = anomalyAlertsEnabled
metrics["nudgeRemindersEnabled"] = nudgeRemindersEnabled
metrics["telemetryConsent"] = telemetryConsent
diff --git a/apps/HeartCoach/project.yml b/apps/HeartCoach/project.yml
index 5d28199..81a17b5 100644
--- a/apps/HeartCoach/project.yml
+++ b/apps/HeartCoach/project.yml
@@ -74,6 +74,7 @@ targets:
scheme:
testTargets:
- ThumpCoreTests
+ - ThumpUITests
gatherCoverageData: true
# ----------------------------------------------------------