From 8bf73f9b818c4f5350e65277704a555d0f3e0b5e Mon Sep 17 00:00:00 2001 From: mission-agi Date: Mon, 6 Apr 2026 01:42:58 -0700 Subject: [PATCH 1/4] fix: greeting time-of-day logic (5AM-12PM = morning) + 15s loading timeout --- .../iOS/ViewModels/DashboardViewModel.swift | 12 +++++ apps/HeartCoach/iOS/Views/DashboardView.swift | 47 ++++++++++--------- 2 files changed, 38 insertions(+), 21 deletions(-) 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.swift b/apps/HeartCoach/iOS/Views/DashboardView.swift index d83ae21..c0aed34 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,12 @@ 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 + + /// 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,19 +131,7 @@ struct DashboardView: View { // Main content cards VStack(alignment: .leading, spacing: 16) { - if useDesignB { - 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 - } + designBCardStack } .padding(.horizontal, 16) .padding(.top, 16) @@ -148,6 +139,7 @@ struct DashboardView: View { .background(Color(.systemGroupedBackground)) } } + .accessibilityIdentifier("dashboard_scroll_view") } .accessibilityIdentifier("dashboard_scroll_view") } @@ -263,7 +255,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 { @@ -312,10 +304,10 @@ struct DashboardView: View { let hour = Calendar.current.component(.hour, from: Date()) let greeting: String switch hour { - case 0..<12: greeting = "Good morning" + case 5..<12: greeting = "Good morning" case 12..<17: greeting = "Good afternoon" case 17..<21: greeting = "Good evening" - default: greeting = "Good night" + default: greeting = "Good night" // 9PM–5AM } let name = viewModel.profileName return name.isEmpty ? greeting : "\(greeting), \(name)" @@ -608,7 +600,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 +638,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 +663,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. From 6b623e0743d3f985cd691258e7d73e20af6b7c45 Mon Sep 17 00:00:00 2001 From: mission-agi Date: Mon, 6 Apr 2026 01:45:42 -0700 Subject: [PATCH 2/4] fix: switch dashboard to Design A layout (single buddy, no duplicate) --- apps/HeartCoach/iOS/Views/DashboardView.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/HeartCoach/iOS/Views/DashboardView.swift b/apps/HeartCoach/iOS/Views/DashboardView.swift index c0aed34..5a4df26 100644 --- a/apps/HeartCoach/iOS/Views/DashboardView.swift +++ b/apps/HeartCoach/iOS/Views/DashboardView.swift @@ -129,9 +129,16 @@ struct DashboardView: View { // Hero: Buddy + Greeting + One Focus Insight buddyHeroSection - // Main content cards + // Main content cards — Design A (single buddy, clean layout) VStack(alignment: .leading, spacing: 16) { - designBCardStack + checkInSection + readinessSection + zoneDistributionSection + dailyGoalsSection + nudgeSection + buddyRecommendationsSection + buddyCoachSection + streakSection } .padding(.horizontal, 16) .padding(.top, 16) From 31bffef890643125c860d1cfb267a5850661e876 Mon Sep 17 00:00:00 2001 From: mission-agi Date: Mon, 6 Apr 2026 01:51:31 -0700 Subject: [PATCH 3/4] fix: restore Design A/B toggle in Settings (default A, B optional) --- apps/HeartCoach/iOS/Views/DashboardView.swift | 25 +++++---- apps/HeartCoach/iOS/Views/SettingsView.swift | 51 +++++++++---------- 2 files changed, 41 insertions(+), 35 deletions(-) diff --git a/apps/HeartCoach/iOS/Views/DashboardView.swift b/apps/HeartCoach/iOS/Views/DashboardView.swift index 5a4df26..f460b8b 100644 --- a/apps/HeartCoach/iOS/Views/DashboardView.swift +++ b/apps/HeartCoach/iOS/Views/DashboardView.swift @@ -47,6 +47,9 @@ struct DashboardView: View { /// 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 @@ -129,16 +132,20 @@ struct DashboardView: View { // Hero: Buddy + Greeting + One Focus Insight buddyHeroSection - // Main content cards — Design A (single buddy, clean layout) + // Main content cards VStack(alignment: .leading, spacing: 16) { - checkInSection - readinessSection - zoneDistributionSection - dailyGoalsSection - nudgeSection - buddyRecommendationsSection - buddyCoachSection - streakSection + if useDesignB { + designBCardStack + } else { + checkInSection + readinessSection + zoneDistributionSection + dailyGoalsSection + nudgeSection + buddyRecommendationsSection + buddyCoachSection + streakSection + } } .padding(.horizontal, 16) .padding(.top, 16) 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 From f4dabf205a54f15d00eac41d8e86c2a8b515bf93 Mon Sep 17 00:00:00 2001 From: mission-agi Date: Mon, 6 Apr 2026 10:39:25 -0700 Subject: [PATCH 4/4] Refine Design B hero states and startup validation --- .../Theme/DashboardHeroPresentation.swift | 75 +++++++++++++++++++ apps/HeartCoach/Shared/Views/ThumpBuddy.swift | 5 +- .../DashboardHeroPresentationTests.swift | 67 +++++++++++++++++ .../UITests/BuddyShowcaseTests.swift | 23 +++--- .../UITests/ClickableValidationTests.swift | 49 ++++++++++++ apps/HeartCoach/iOS/Info.plist | 2 + apps/HeartCoach/iOS/ThumpiOSApp.swift | 16 +++- .../iOS/Views/DashboardView+DesignB.swift | 48 ++++++------ apps/HeartCoach/iOS/Views/DashboardView.swift | 37 +++++---- apps/HeartCoach/project.yml | 1 + 10 files changed, 269 insertions(+), 54 deletions(-) create mode 100644 apps/HeartCoach/Shared/Theme/DashboardHeroPresentation.swift create mode 100644 apps/HeartCoach/Tests/DashboardHeroPresentationTests.swift 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/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 f460b8b..6bed5cc 100644 --- a/apps/HeartCoach/iOS/Views/DashboardView.swift +++ b/apps/HeartCoach/iOS/Views/DashboardView.swift @@ -134,7 +134,7 @@ struct DashboardView: View { // Main content cards VStack(alignment: .leading, spacing: 16) { - if useDesignB { + if isUsingDesignB { designBCardStack } else { checkInSection @@ -168,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 ) } @@ -245,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. @@ -314,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 5..<12: greeting = "Good morning" - case 12..<17: greeting = "Good afternoon" - case 17..<21: greeting = "Good evening" - default: greeting = "Good night" // 9PM–5AM - } + 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()) } 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 # ----------------------------------------------------------