diff --git a/apps/HeartCoach/iOS/Views/DashboardView.swift b/apps/HeartCoach/iOS/Views/DashboardView.swift index 52675f3c..32452c63 100644 --- a/apps/HeartCoach/iOS/Views/DashboardView.swift +++ b/apps/HeartCoach/iOS/Views/DashboardView.swift @@ -615,606 +615,6 @@ struct DashboardView: View { .buttonStyle(.plain) .accessibilityHint("Double tap to view trends") } - - // MARK: - Buddy Suggestions - - @ViewBuilder - private var nudgeSection: some View { - // Only show Buddy Says after bio age is unlocked (DOB set) - // so nudges are based on full analysis including age-stratified norms - if let assessment = viewModel.assessment, - localStore.profile.dateOfBirth != nil { - VStack(alignment: .leading, spacing: 12) { - HStack { - Label("Your Daily Coaching", systemImage: "sparkles") - .font(.headline) - .foregroundStyle(.primary) - - Spacer() - - if let trend = viewModel.weeklyTrendSummary { - Label(trend, systemImage: "chart.line.uptrend.xyaxis") - .font(.caption) - .foregroundStyle(.secondary) - } - } - - Text("Based on your data today") - .font(.caption) - .foregroundStyle(.secondary) - - ForEach( - Array(assessment.dailyNudges.enumerated()), - id: \.offset - ) { index, nudge in - Button { - InteractionLog.log(.cardTap, element: "nudge_\(index)", page: "Dashboard", details: nudge.category.rawValue) - // Navigate to Stress tab for rest/breathe nudges, - // Insights tab for everything else - withAnimation { - let stressCategories: [NudgeCategory] = [.rest, .breathe, .seekGuidance] - selectedTab = stressCategories.contains(nudge.category) ? 2 : 1 - } - } label: { - NudgeCardView( - nudge: nudge, - onMarkComplete: { - viewModel.markNudgeComplete(at: index) - } - ) - } - .buttonStyle(.plain) - .accessibilityHint("Double tap to view details") - } - } - } - } - - // MARK: - Check-In Section - - @ViewBuilder - private var checkInSection: some View { - if !viewModel.hasCheckedInToday { - // Only show when user hasn't checked in yet — disappears after tap - VStack(alignment: .leading, spacing: 12) { - HStack { - Label("Daily Check-In", systemImage: "face.smiling.fill") - .font(.headline) - .foregroundStyle(.primary) - - Spacer() - - Text("How are you feeling?") - .font(.caption) - .foregroundStyle(.secondary) - } - - HStack(spacing: 10) { - ForEach(CheckInMood.allCases, id: \.self) { mood in - Button { - InteractionLog.log(.buttonTap, element: "checkin_\(mood.label.lowercased())", page: "Dashboard") - withAnimation(.spring(response: 0.4)) { - viewModel.submitCheckIn(mood: mood) - } - } label: { - VStack(spacing: 8) { - Image(systemName: moodIcon(for: mood)) - .font(.title2) - .foregroundStyle(moodColor(for: mood)) - - Text(mood.label) - .font(.caption2) - .fontWeight(.medium) - .foregroundStyle(.primary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background( - RoundedRectangle(cornerRadius: 14) - .fill(moodColor(for: mood).opacity(0.08)) - ) - .overlay( - RoundedRectangle(cornerRadius: 14) - .strokeBorder( - moodColor(for: mood).opacity(0.15), - lineWidth: 1 - ) - ) - } - .buttonStyle(.plain) - .accessibilityLabel("Feeling \(mood.label)") - } - } - } - .transition(.opacity.combined(with: .move(edge: .top))) - .accessibilityIdentifier("dashboard_checkin") - } - } - - private func moodIcon(for mood: CheckInMood) -> String { - switch mood { - case .great: return "sun.max.fill" - case .good: return "cloud.sun.fill" - case .okay: return "cloud.fill" - case .rough: return "cloud.rain.fill" - } - } - - private func moodColor(for mood: CheckInMood) -> Color { - switch mood { - case .great: return Color(hex: 0x22C55E) - case .good: return Color(hex: 0x0D9488) - case .okay: return Color(hex: 0xF59E0B) - case .rough: return Color(hex: 0x8B5CF6) - } - } - - // MARK: - Zone Distribution (Dynamic Targets) - - private let zoneColors: [Color] = [ - Color(hex: 0x94A3B8), // Zone 1 - Easy (gray-blue) - Color(hex: 0x22C55E), // Zone 2 - Fat Burn (green) - Color(hex: 0x3B82F6), // Zone 3 - Cardio (blue) - Color(hex: 0xF59E0B), // Zone 4 - Threshold (amber) - Color(hex: 0xEF4444) // Zone 5 - Peak (red) - ] - private let zoneNames = ["Easy", "Fat Burn", "Cardio", "Threshold", "Peak"] - - @ViewBuilder - private var zoneDistributionSection: some View { - if let zoneAnalysis = viewModel.zoneAnalysis, - let snapshot = viewModel.todaySnapshot { - let pillars = zoneAnalysis.pillars - let totalMin = snapshot.zoneMinutes.reduce(0, +) - let metCount = pillars.filter { $0.completion >= 1.0 }.count - - VStack(alignment: .leading, spacing: 14) { - // Header with targets-met counter - HStack { - Label("Heart Rate Zones", systemImage: "chart.bar.fill") - .font(.headline) - .foregroundStyle(.primary) - Spacer() - HStack(spacing: 4) { - Text("\(metCount)/\(pillars.count) targets") - .font(.caption) - .fontWeight(.semibold) - .fontDesign(.rounded) - if metCount == pillars.count && !pillars.isEmpty { - Image(systemName: "star.fill") - .font(.caption2) - .foregroundStyle(Color(hex: 0xF59E0B)) - } - } - .foregroundStyle(metCount == pillars.count && !pillars.isEmpty - ? Color(hex: 0x22C55E) : .secondary) - .padding(.horizontal, 10) - .padding(.vertical, 4) - .background( - Capsule().fill( - metCount == pillars.count && !pillars.isEmpty - ? Color(hex: 0x22C55E).opacity(0.12) - : Color(.systemGray5) - ) - ) - } - - // Per-zone rows with progress bars - ForEach(Array(pillars.enumerated()), id: \.offset) { index, pillar in - let color = index < zoneColors.count ? zoneColors[index] : .gray - let name = index < zoneNames.count ? zoneNames[index] : "Zone \(index + 1)" - let met = pillar.completion >= 1.0 - let progress = min(pillar.completion, 1.0) - - VStack(spacing: 6) { - HStack { - // Zone name + icon - HStack(spacing: 6) { - Circle() - .fill(color) - .frame(width: 8, height: 8) - Text(name) - .font(.caption) - .fontWeight(.medium) - .foregroundStyle(.primary) - } - - Spacer() - - // Actual / Target - HStack(spacing: 2) { - Text("\(Int(pillar.actualMinutes))") - .font(.caption) - .fontWeight(.bold) - .fontDesign(.rounded) - .foregroundStyle(met ? color : .primary) - Text("/") - .font(.caption2) - .foregroundStyle(.tertiary) - Text("\(Int(pillar.targetMinutes)) min") - .font(.caption) - .foregroundStyle(.secondary) - } - - // Checkmark or remaining - if met { - Image(systemName: "checkmark.circle.fill") - .font(.caption) - .foregroundStyle(color) - } - } - - // Progress bar - GeometryReader { geo in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 3) - .fill(color.opacity(0.12)) - .frame(height: 6) - RoundedRectangle(cornerRadius: 3) - .fill(color) - .frame(width: max(0, geo.size.width * CGFloat(progress)), height: 6) - } - } - .frame(height: 6) - } - .accessibilityLabel( - "\(name): \(Int(pillar.actualMinutes)) of \(Int(pillar.targetMinutes)) minutes\(met ? ", target met" : "")" - ) - } - - // Coaching nudge per zone (show the most important one) - if let rec = zoneAnalysis.recommendation { - HStack(spacing: 6) { - Image(systemName: rec.icon) - .font(.caption) - .foregroundStyle(rec == .perfectBalance ? Color(hex: 0x22C55E) : Color(hex: 0x3B82F6)) - Text(zoneCoachingNudge(rec, pillars: pillars)) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 10) - .fill((rec == .perfectBalance ? Color(hex: 0x22C55E) : Color(hex: 0x3B82F6)).opacity(0.06)) - ) - } - - // Weekly activity target (AHA 150 min guideline) - let moderateMin = snapshot.zoneMinutes.count >= 4 ? snapshot.zoneMinutes[2] + snapshot.zoneMinutes[3] : 0 - let vigorousMin = snapshot.zoneMinutes.count >= 5 ? snapshot.zoneMinutes[4] : 0 - let weeklyEstimate = (moderateMin + vigorousMin * 2) * 7 - let ahaPercent = min(weeklyEstimate / 150.0 * 100, 100) - HStack(spacing: 6) { - Image(systemName: ahaPercent >= 100 ? "checkmark.circle.fill" : "circle.dashed") - .font(.caption) - .foregroundStyle(ahaPercent >= 100 ? Color(hex: 0x22C55E) : Color(hex: 0xF59E0B)) - Text(ahaPercent >= 100 - ? "On pace for 150 min weekly activity goal" - : "\(Int(max(0, 150 - weeklyEstimate))) min to your weekly activity target") - .font(.caption) - .foregroundStyle(.secondary) - } - } - .padding(16) - .background( - RoundedRectangle(cornerRadius: 20) - .fill(Color(.secondarySystemGroupedBackground)) - ) - .accessibilityIdentifier("dashboard_zone_card") - } - } - - /// Context-aware coaching nudge based on zone recommendation. - private func zoneCoachingNudge(_ rec: ZoneRecommendation, pillars: [ZonePillar]) -> String { - switch rec { - case .perfectBalance: - return "Great balance today! You're hitting all zone targets." - case .needsMoreActivity: - return "A 15-minute walk gets you into your fat-burn and cardio zones." - case .needsMoreAerobic: - let cardio = pillars.first { $0.zone == .aerobic } - let remaining = Int(max(0, (cardio?.targetMinutes ?? 22) - (cardio?.actualMinutes ?? 0))) - return "\(remaining) more min of cardio (brisk walk or jog) to hit your target." - case .needsMoreThreshold: - let threshold = pillars.first { $0.zone == .threshold } - let remaining = Int(max(0, (threshold?.targetMinutes ?? 7) - (threshold?.actualMinutes ?? 0))) - return "\(remaining) more min of tempo effort to reach your threshold target." - case .tooMuchIntensity: - return "You've pushed hard. Try easy zone only for the rest of today." - } - } - - // MARK: - Buddy Recommendations Section - - /// Engine-driven actionable advice cards below Daily Goals. - /// Pulls from readiness, stress, zones, coaching, and recovery to give - /// specific, human-readable recommendations. - @ViewBuilder - private var buddyRecommendationsSection: some View { - if let recs = viewModel.buddyRecommendations, !recs.isEmpty { - VStack(alignment: .leading, spacing: 12) { - Label("Buddy Says", systemImage: "bubble.left.and.bubble.right.fill") - .font(.headline) - .foregroundStyle(.primary) - - ForEach(Array(recs.prefix(3).enumerated()), id: \.offset) { index, rec in - Button { - InteractionLog.log(.cardTap, element: "buddy_recommendation_\(index)", page: "Dashboard", details: rec.category.rawValue) - withAnimation { selectedTab = 1 } - } label: { - HStack(alignment: .top, spacing: 10) { - Image(systemName: buddyRecIcon(rec)) - .font(.subheadline) - .foregroundStyle(buddyRecColor(rec)) - .frame(width: 24) - - VStack(alignment: .leading, spacing: 4) { - Text(rec.title) - .font(.subheadline) - .fontWeight(.semibold) - .foregroundStyle(.primary) - Text(rec.message) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - Spacer() - - Image(systemName: "chevron.right") - .font(.caption2) - .foregroundStyle(.tertiary) - } - .padding(12) - .background( - RoundedRectangle(cornerRadius: 14) - .fill(buddyRecColor(rec).opacity(0.06)) - ) - .overlay( - RoundedRectangle(cornerRadius: 14) - .strokeBorder(buddyRecColor(rec).opacity(0.12), lineWidth: 1) - ) - } - .buttonStyle(.plain) - .accessibilityLabel("\(rec.title): \(rec.message)") - .accessibilityHint("Double tap for details") - } - } - .padding(16) - .background( - RoundedRectangle(cornerRadius: 20) - .fill(Color(.secondarySystemGroupedBackground)) - ) - .accessibilityIdentifier("dashboard_buddy_recommendations") - } - } - - private func buddyRecIcon(_ rec: BuddyRecommendation) -> String { - switch rec.category { - case .rest: return "bed.double.fill" - case .breathe: return "wind" - case .walk: return "figure.walk" - case .moderate: return "figure.run" - case .hydrate: return "drop.fill" - case .seekGuidance: return "stethoscope" - case .celebrate: return "party.popper.fill" - case .sunlight: return "sun.max.fill" - } - } - - private func buddyRecColor(_ rec: BuddyRecommendation) -> Color { - switch rec.category { - case .rest: return Color(hex: 0x8B5CF6) - case .breathe: return Color(hex: 0x0D9488) - case .walk: return Color(hex: 0x3B82F6) - case .moderate: return Color(hex: 0xF97316) - case .hydrate: return Color(hex: 0x06B6D4) - case .seekGuidance: return Color(hex: 0xEF4444) - case .celebrate: return Color(hex: 0x22C55E) - case .sunlight: return Color(hex: 0xF59E0B) - } - } - - // MARK: - Buddy Coach (was "Your Heart Coach") - - @ViewBuilder - private var buddyCoachSection: some View { - if let report = viewModel.coachingReport { - VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 8) { - Image(systemName: "sparkles") - .font(.title3) - .foregroundStyle(Color(hex: 0x8B5CF6)) - Text("Buddy Coach") - .font(.headline) - .foregroundStyle(.primary) - Spacer() - - // Progress score - Text("\(report.weeklyProgressScore)") - .font(.system(size: 18, weight: .bold, design: .rounded)) - .foregroundStyle(.white) - .frame(width: 38, height: 38) - .background( - Circle().fill( - report.weeklyProgressScore >= 70 - ? Color(hex: 0x22C55E) - : (report.weeklyProgressScore >= 45 - ? Color(hex: 0x3B82F6) - : Color(hex: 0xF59E0B)) - ) - ) - .accessibilityLabel("Progress score: \(report.weeklyProgressScore)") - } - - // Hero message - Text(report.heroMessage) - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - - // Top 2 insights - ForEach(Array(report.insights.prefix(2).enumerated()), id: \.offset) { _, insight in - HStack(spacing: 8) { - Image(systemName: insight.icon) - .font(.caption) - .foregroundStyle( - insight.direction == .improving - ? Color(hex: 0x22C55E) - : (insight.direction == .declining - ? Color(hex: 0xF59E0B) - : Color(hex: 0x3B82F6)) - ) - .frame(width: 20) - Text(insight.message) - .font(.caption) - .foregroundStyle(.primary) - .fixedSize(horizontal: false, vertical: true) - } - } - - // Top projection - if let proj = report.projections.first { - HStack(spacing: 6) { - Image(systemName: "chart.line.uptrend.xyaxis") - .font(.caption) - .foregroundStyle(Color(hex: 0xF59E0B)) - Text(proj.description) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(Color(hex: 0xF59E0B).opacity(0.06)) - ) - } - } - .padding(16) - .background( - RoundedRectangle(cornerRadius: 20) - .fill(Color(hex: 0x8B5CF6).opacity(0.04)) - ) - .overlay( - RoundedRectangle(cornerRadius: 20) - .strokeBorder(Color(hex: 0x8B5CF6).opacity(0.12), lineWidth: 1) - ) - .accessibilityIdentifier("dashboard_coaching_card") - } - } - - // MARK: - Streak Badge - - @ViewBuilder - private var streakSection: some View { - let streak = viewModel.profileStreakDays - if streak > 0 { - Button { - InteractionLog.log(.cardTap, element: "streak_badge", page: "Dashboard", details: "\(streak) days") - withAnimation { selectedTab = 1 } - } label: { - HStack(spacing: 10) { - Image(systemName: "flame.fill") - .font(.title3) - .foregroundStyle( - LinearGradient( - colors: [Color(hex: 0xF97316), Color(hex: 0xEF4444)], - startPoint: .top, - endPoint: .bottom - ) - ) - - VStack(alignment: .leading, spacing: 2) { - Text("\(streak)-Day Streak") - .font(.headline) - .fontDesign(.rounded) - .foregroundStyle(.primary) - - Text("Keep checking in daily to build your streak.") - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - - Image(systemName: "chevron.right") - .font(.caption) - .foregroundStyle(.tertiary) - } - .padding(16) - .background( - RoundedRectangle(cornerRadius: 16) - .fill( - LinearGradient( - colors: [ - Color(hex: 0xF97316).opacity(0.08), - Color(hex: 0xEF4444).opacity(0.05) - ], - startPoint: .leading, - endPoint: .trailing - ) - ) - ) - .overlay( - RoundedRectangle(cornerRadius: 16) - .strokeBorder(Color(hex: 0xF97316).opacity(0.15), lineWidth: 1) - ) - } - .buttonStyle(.plain) - .accessibilityElement(children: .ignore) - .accessibilityLabel("\(streak)-day streak. Double tap to view insights.") - .accessibilityHint("Opens the Insights tab") - .accessibilityIdentifier("dashboard_streak_badge") - } - } - - // MARK: - Loading View - - private var loadingView: some View { - VStack(spacing: 20) { - ThumpBuddy(mood: .content, size: 80) - - Text("Getting your wellness snapshot ready...") - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(.systemGroupedBackground)) - .accessibilityElement(children: .combine) - .accessibilityLabel("Getting your wellness snapshot ready") - } - - // MARK: - Error View - - private func errorView(message: String) -> some View { - VStack(spacing: 16) { - ThumpBuddy(mood: .stressed, size: 70) - - Text("Something went wrong") - .font(.headline) - - Text(message) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 32) - - Button("Try Again") { - InteractionLog.log(.buttonTap, element: "try_again", page: "Dashboard") - Task { await viewModel.refresh() } - } - .buttonStyle(.borderedProminent) - .tint(Color(hex: 0xF97316)) - .accessibilityHint("Double tap to reload your wellness data") - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(.systemGroupedBackground)) - } } // MARK: - Preview