// // InsightsView.swift // Reflect // // Created by Claude Code on 12/9/24. // import SwiftUI enum InsightsTab: String, CaseIterable { case insights = "Insights" case reports = "Reports" } struct InsightsView: View { private enum AnimationConstants { static let refreshDelay: UInt64 = 500_000_000 // 0.5 seconds in nanoseconds } @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default @AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome @Environment(\.colorScheme) private var colorScheme @Environment(\.scenePhase) private var scenePhase private var textColor: Color { theme.currentTheme.labelColor } @StateObject private var viewModel = InsightsViewModel() @EnvironmentObject var iapManager: IAPManager @State private var showSubscriptionStore = false @State private var selectedTab: InsightsTab = .insights @State private var weeklyDigest: WeeklyDigest? @State private var showDigest = true var body: some View { VStack(spacing: 0) { // Header HStack { Text("Insights") .font(.title.weight(.bold)) .foregroundColor(textColor) .accessibilityIdentifier(AccessibilityID.Insights.header) Spacer() // AI badge if viewModel.isAIAvailable { HStack(spacing: 4) { Image(systemName: "sparkles") .font(.caption.weight(.medium)) .accessibilityHidden(true) Text("AI") .font(.caption.weight(.semibold)) } .foregroundColor(.white) .padding(.horizontal, 8) .padding(.vertical, 4) .background( LinearGradient( colors: [.purple, .blue], startPoint: .leading, endPoint: .trailing ) ) .clipShape(Capsule()) .aiInsightsTip() } } .padding(.horizontal) // Segmented picker Picker("", selection: $selectedTab) { ForEach(InsightsTab.allCases, id: \.self) { tab in Text(tab.rawValue).tag(tab) } } .pickerStyle(.segmented) .padding(.horizontal, 16) .padding(.top, 12) .padding(.bottom, 16) .accessibilityIdentifier(AccessibilityID.Reports.segmentedPicker) // Content ZStack { if selectedTab == .insights { insightsContent } else { ReportsView() } if iapManager.shouldShowPaywall { paywallOverlay } if selectedTab == .insights && isGeneratingInsights && !iapManager.shouldShowPaywall { generatingOverlay } } } .sheet(isPresented: $showSubscriptionStore) { ReflectSubscriptionStoreView(source: "insights_gate") } .background( theme.currentTheme.bg .edgesIgnoringSafeArea(.all) ) .onAppear { AnalyticsManager.shared.trackScreen(.insights) viewModel.generateInsights() loadWeeklyDigest() } .onChange(of: scenePhase) { _, newPhase in if newPhase == .active { viewModel.recheckAvailability() } } .padding(.top) } // MARK: - Insights Content private func loadWeeklyDigest() { guard #available(iOS 26, *), !iapManager.shouldShowPaywall else { return } // Try cached digest first if let digest = FoundationModelsDigestService.shared.loadLatestDigest(), digest.isFromCurrentWeek { weeklyDigest = digest return } // No digest for this week — generate one on-demand Task { do { let digest = try await FoundationModelsDigestService.shared.generateWeeklyDigest() weeklyDigest = digest } catch { // Not enough data or AI unavailable — just don't show the card } } } private var insightsContent: some View { ScrollView { VStack(spacing: 20) { // AI enablement guidance when not available if !viewModel.isAIAvailable && !iapManager.shouldShowPaywall { aiEnablementCard } // Weekly Digest Card if let digest = weeklyDigest { WeeklyDigestCardView(digest: digest) } // This Month Section InsightsSectionView( title: "This Month", icon: "calendar", insights: viewModel.monthInsights, loadingState: viewModel.monthLoadingState, textColor: textColor, moodTint: moodTint, imagePack: imagePack, colorScheme: colorScheme ) .accessibilityIdentifier(AccessibilityID.Insights.monthSection) // This Year Section InsightsSectionView( title: "This Year", icon: "calendar.badge.clock", insights: viewModel.yearInsights, loadingState: viewModel.yearLoadingState, textColor: textColor, moodTint: moodTint, imagePack: imagePack, colorScheme: colorScheme ) .accessibilityIdentifier(AccessibilityID.Insights.yearSection) // All Time Section InsightsSectionView( title: "All Time", icon: "infinity", insights: viewModel.allTimeInsights, loadingState: viewModel.allTimeLoadingState, textColor: textColor, moodTint: moodTint, imagePack: imagePack, colorScheme: colorScheme ) .accessibilityIdentifier(AccessibilityID.Insights.allTimeSection) } .padding(.vertical) .padding(.bottom, 100) } .opacity(isGeneratingInsights && !iapManager.shouldShowPaywall ? 0.2 : 1.0) .animation(.easeInOut(duration: 0.3), value: isGeneratingInsights) .refreshable { viewModel.refreshInsights() // Small delay to show refresh animation try? await Task.sleep(nanoseconds: AnimationConstants.refreshDelay) } .disabled(iapManager.shouldShowPaywall) } // MARK: - AI Enablement Card private var aiEnablementCard: some View { VStack(spacing: 16) { Image(systemName: aiEnablementIcon) .font(.system(size: 36)) .foregroundStyle(.secondary) Text(aiEnablementTitle) .font(.headline) .foregroundColor(textColor) Text(aiEnablementDescription) .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) if viewModel.aiUnavailableReason == .notEnabled { Button { if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } } label: { Label(String(localized: "Open Settings"), systemImage: "gear") .font(.subheadline.weight(.semibold)) .frame(maxWidth: .infinity) .padding(.vertical, 12) } .buttonStyle(.borderedProminent) .tint(.purple) } if viewModel.aiUnavailableReason == .modelDownloading { Button { viewModel.recheckAvailability() } label: { Label(String(localized: "Try Again"), systemImage: "arrow.clockwise") .font(.subheadline.weight(.medium)) } .buttonStyle(.bordered) } } .padding(24) .background( RoundedRectangle(cornerRadius: 20) .fill(Color(.secondarySystemBackground)) ) .padding(.horizontal) } private var aiEnablementIcon: String { switch viewModel.aiUnavailableReason { case .deviceNotEligible: return "iphone.slash" case .notEnabled: return "gearshape.fill" case .modelDownloading: return "arrow.down.circle" case .preiOS26: return "arrow.up.circle" case .unknown: return "brain.head.profile" } } private var aiEnablementTitle: String { switch viewModel.aiUnavailableReason { case .deviceNotEligible: return String(localized: "Device Not Supported") case .notEnabled: return String(localized: "Enable Apple Intelligence") case .modelDownloading: return String(localized: "AI Model Downloading") case .preiOS26: return String(localized: "Update Required") case .unknown: return String(localized: "AI Unavailable") } } private var aiEnablementDescription: String { switch viewModel.aiUnavailableReason { case .deviceNotEligible: return String(localized: "AI insights require iPhone 15 Pro or later with Apple Intelligence.") case .notEnabled: return String(localized: "Turn on Apple Intelligence to unlock personalized mood insights.\n\nSettings → Apple Intelligence & Siri → Apple Intelligence") case .modelDownloading: return String(localized: "The AI model is still downloading. This may take a few minutes.") case .preiOS26: return String(localized: "AI insights require iOS 26 or later with Apple Intelligence.") case .unknown: return String(localized: "Apple Intelligence is required for personalized insights.") } } // MARK: - Generating State private var isGeneratingInsights: Bool { let states = [viewModel.monthLoadingState, viewModel.yearLoadingState, viewModel.allTimeLoadingState] return states.contains(where: { $0 == .loading }) } private var generatingOverlay: some View { VStack(spacing: 20) { Spacer() VStack(spacing: 16) { Image(systemName: "sparkles") .font(.system(size: 36)) .foregroundStyle( LinearGradient( colors: [.purple, .blue], startPoint: .leading, endPoint: .trailing ) ) .symbolEffect(.pulse, options: .repeating) Text(String(localized: "Generating Insights")) .font(.headline) .foregroundColor(textColor) Text(String(localized: "Apple Intelligence is analyzing your mood data...")) .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) } .padding(32) .background( RoundedRectangle(cornerRadius: 24) .fill(.regularMaterial) ) .padding(.horizontal, 40) Spacer() } .transition(.opacity) } // MARK: - Paywall Overlay private var paywallOverlay: some View { VStack(spacing: 24) { Spacer() // Icon ZStack { Circle() .fill( LinearGradient( colors: [.purple.opacity(0.2), .blue.opacity(0.2)], startPoint: .topLeading, endPoint: .bottomTrailing ) ) .frame(width: 100, height: 100) Image(systemName: "sparkles") .font(.largeTitle) .accessibilityHidden(true) .foregroundStyle( LinearGradient( colors: [.purple, .blue], startPoint: .topLeading, endPoint: .bottomTrailing ) ) } // Text VStack(spacing: 12) { Text("Unlock AI-Powered Insights") .font(.title2.weight(.bold)) .foregroundColor(textColor) .multilineTextAlignment(.center) Text("Discover patterns in your mood, get personalized recommendations, and understand what affects how you feel.") .font(.body) .foregroundColor(textColor.opacity(0.7)) .multilineTextAlignment(.center) .padding(.horizontal, 32) } // Subscribe button Button { showSubscriptionStore = true } label: { HStack { Image(systemName: "sparkles") .accessibilityHidden(true) Text("Get Personal Insights") } .font(.headline.weight(.bold)) .foregroundColor(.white) .frame(maxWidth: .infinity) .padding(.vertical, 16) .background( LinearGradient( colors: [.purple, .blue], startPoint: .leading, endPoint: .trailing ) ) .clipShape(RoundedRectangle(cornerRadius: 14)) } .padding(.horizontal, 24) .accessibilityIdentifier(AccessibilityID.Paywall.insightsUnlockButton) Spacer() } .background(theme.currentTheme.bg) .accessibilityIdentifier(AccessibilityID.Paywall.insightsOverlay) } } // MARK: - Insights Section View struct InsightsSectionView: View { let title: String let icon: String let insights: [Insight] let loadingState: InsightLoadingState let textColor: Color let moodTint: MoodTints let imagePack: MoodImages let colorScheme: ColorScheme @State private var isExpanded = true var body: some View { VStack(spacing: 0) { // Section Header Button(action: { if UIAccessibility.isReduceMotionEnabled { isExpanded.toggle() } else { withAnimation(.easeInOut(duration: 0.2)) { isExpanded.toggle() } } }) { HStack { Image(systemName: icon) .font(.headline.weight(.medium)) .foregroundColor(textColor.opacity(0.6)) Text(title) .font(.title3.weight(.bold)) .foregroundColor(textColor) // Loading indicator in header if loadingState == .loading { ProgressView() .scaleEffect(0.7) .padding(.leading, 4) } Spacer() Image(systemName: isExpanded ? "chevron.up" : "chevron.down") .font(.caption.weight(.semibold)) .foregroundColor(textColor.opacity(0.4)) } .padding(.horizontal, 16) .padding(.vertical, 14) } .buttonStyle(.plain) .accessibilityIdentifier(AccessibilityID.Insights.expandCollapseButton) .accessibilityAddTraits(.isHeader) // Insights List (collapsible) if isExpanded { switch loadingState { case .loading: // Skeleton loading view VStack(spacing: 10) { ForEach(0..<3, id: \.self) { _ in InsightSkeletonView(colorScheme: colorScheme) } } .padding(.horizontal, 16) .padding(.bottom, 16) .transition(.opacity) case .error: // Show insights (which contain error message) with error styling VStack(spacing: 10) { ForEach(insights) { insight in InsightCardView( insight: insight, textColor: textColor, moodTint: moodTint, imagePack: imagePack, colorScheme: colorScheme ) } } .padding(.horizontal, 16) .padding(.bottom, 16) .transition(.opacity.combined(with: .move(edge: .top))) case .loaded, .idle: // Normal insights display with staggered animation VStack(spacing: 10) { ForEach(Array(insights.enumerated()), id: \.element.id) { index, insight in InsightCardView( insight: insight, textColor: textColor, moodTint: moodTint, imagePack: imagePack, colorScheme: colorScheme ) .transition(.asymmetric( insertion: .opacity.combined(with: .scale(scale: 0.95)).combined(with: .offset(y: 10)), removal: .opacity )) .animation( UIAccessibility.isReduceMotionEnabled ? nil : .spring(response: 0.4, dampingFraction: 0.8) .delay(Double(index) * 0.05), value: insights.count ) } } .padding(.horizontal, 16) .padding(.bottom, 16) .transition(.opacity.combined(with: .move(edge: .top))) } } } .background( RoundedRectangle(cornerRadius: 16) .fill(colorScheme == .dark ? Color(.systemGray6) : .white) ) .padding(.horizontal) .animation(UIAccessibility.isReduceMotionEnabled ? nil : .easeInOut(duration: 0.2), value: isExpanded) } } // MARK: - Skeleton Loading View struct InsightSkeletonView: View { let colorScheme: ColorScheme @State private var isAnimating = false var body: some View { HStack(alignment: .top, spacing: 14) { // Icon placeholder Circle() .fill(Color.gray.opacity(0.3)) .frame(width: 44, height: 44) // Text placeholders VStack(alignment: .leading, spacing: 8) { RoundedRectangle(cornerRadius: 4) .fill(Color.gray.opacity(0.3)) .frame(width: 120, height: 16) RoundedRectangle(cornerRadius: 4) .fill(Color.gray.opacity(0.2)) .frame(maxWidth: .infinity) .frame(height: 14) RoundedRectangle(cornerRadius: 4) .fill(Color.gray.opacity(0.2)) .frame(width: 180, height: 14) } Spacer() } .padding(14) .background( RoundedRectangle(cornerRadius: 12) .fill(colorScheme == .dark ? Color(.systemGray5) : Color(.systemGray6)) ) .opacity(isAnimating ? 0.6 : 1.0) .animation( UIAccessibility.isReduceMotionEnabled ? nil : .easeInOut(duration: 0.8) .repeatForever(autoreverses: true), value: isAnimating ) .onAppear { if !UIAccessibility.isReduceMotionEnabled { isAnimating = true } } } } // MARK: - Insight Card View struct InsightCardView: View { let insight: Insight let textColor: Color let moodTint: MoodTints let imagePack: MoodImages let colorScheme: ColorScheme private var accentColor: Color { if let mood = insight.mood { return moodTint.color(forMood: mood) } return .accentColor } var body: some View { HStack(alignment: .top, spacing: 14) { // Icon ZStack { Circle() .fill(accentColor.opacity(0.15)) .frame(width: 44, height: 44) if let mood = insight.mood { imagePack.icon(forMood: mood) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 22, height: 22) .foregroundColor(accentColor) .accessibilityLabel(mood.strValue) } else { Image(systemName: insight.icon) .font(.headline.weight(.semibold)) .foregroundColor(accentColor) } } // Text Content VStack(alignment: .leading, spacing: 4) { Text(insight.title) .font(.subheadline.weight(.semibold)) .foregroundColor(textColor) Text(insight.description) .font(.subheadline) .foregroundColor(textColor.opacity(0.7)) .fixedSize(horizontal: false, vertical: true) } Spacer() } .padding(14) .background( RoundedRectangle(cornerRadius: 12) .fill(colorScheme == .dark ? Color(.systemGray5) : Color(.systemGray6)) ) .accessibilityElement(children: .combine) } } struct InsightsView_Previews: PreviewProvider { static var previews: some View { InsightsView() .environmentObject(IAPManager()) } }