// // 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 { @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 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)) 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 } } } .sheet(isPresented: $showSubscriptionStore) { ReflectSubscriptionStoreView(source: "insights_gate") } .background( theme.currentTheme.bg .edgesIgnoringSafeArea(.all) ) .onAppear { AnalyticsManager.shared.trackScreen(.insights) viewModel.generateInsights() loadWeeklyDigest() } .padding(.top) } // MARK: - Insights Content private func loadWeeklyDigest() { if #available(iOS 26, *), !iapManager.shouldShowPaywall { if let digest = FoundationModelsDigestService.shared.loadLatestDigest(), digest.isFromCurrentWeek, !WeeklyDigest.isDismissed(for: digest) { weeklyDigest = digest showDigest = true } } } private var insightsContent: some View { ScrollView { VStack(spacing: 20) { // Weekly Digest Card if showDigest, let digest = weeklyDigest { WeeklyDigestCardView(digest: digest) { showDigest = false } } // 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) } .refreshable { viewModel.refreshInsights() // Small delay to show refresh animation try? await Task.sleep(nanoseconds: 500_000_000) } .disabled(iapManager.shouldShowPaywall) } // 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) .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") 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()) } }