// // InsightsViewModel.swift // Reflect // // Created by Claude Code on 12/9/24. // import Foundation import SwiftUI /// Represents a single insight to display in the UI struct Insight: Identifiable { let id = UUID() let icon: String let title: String let description: String let mood: Mood? } /// Loading state for each insight section enum InsightLoadingState: Equatable { case idle case loading case loaded case error(String) } /// ViewModel for the Insights tab - uses Apple Foundation Models for AI-powered insights @MainActor class InsightsViewModel: ObservableObject { // MARK: - Published Properties @Published var monthInsights: [Insight] = [] @Published var yearInsights: [Insight] = [] @Published var allTimeInsights: [Insight] = [] @Published var monthLoadingState: InsightLoadingState = .idle @Published var yearLoadingState: InsightLoadingState = .idle @Published var allTimeLoadingState: InsightLoadingState = .idle @Published var isAIAvailable: Bool = false @Published var aiUnavailableReason: AIUnavailableReason = .preiOS26 // MARK: - Dependencies /// Stored as Any? to avoid referencing @available(iOS 26, *) type at the property level private var insightService: Any? private let healthService = HealthService.shared private let calendar = Calendar.current // MARK: - Initialization private var dataListenerToken: DataController.DataListenerToken? init() { if #available(iOS 26, *) { let service = FoundationModelsInsightService() insightService = service isAIAvailable = service.isAvailable aiUnavailableReason = service.unavailableReason service.prewarm() } else { insightService = nil isAIAvailable = false aiUnavailableReason = .preiOS26 } dataListenerToken = DataController.shared.addNewDataListener { [weak self] in self?.onDataChanged() } } deinit { if let token = dataListenerToken { Task { @MainActor in DataController.shared.removeDataListener(token: token) } } } /// Called when mood data changes in another tab. Invalidates cached insights /// so they are regenerated with fresh data on next view appearance. private func onDataChanged() { if #available(iOS 26, *), let service = insightService as? FoundationModelsInsightService { service.invalidateCache() } monthLoadingState = .idle yearLoadingState = .idle allTimeLoadingState = .idle } // MARK: - Public Methods /// Generate insights for all time periods func generateInsights() { Task { await generateAllInsights() } } /// Force refresh all insights (invalidates cache) func refreshInsights() { if #available(iOS 26, *), let service = insightService as? FoundationModelsInsightService { service.invalidateCache() } generateInsights() } // MARK: - Private Methods private func generateAllInsights() async { let now = Date() // Get date ranges let monthStart = calendar.date(from: calendar.dateComponents([.year, .month], from: now))! let yearStart = calendar.date(from: calendar.dateComponents([.year], from: now))! let allTimeStart = Date(timeIntervalSince1970: 0) // Fetch entries for each period let monthEntries = DataController.shared.getData(startDate: monthStart, endDate: now, includedDays: [1, 2, 3, 4, 5, 6, 7]) let yearEntries = DataController.shared.getData(startDate: yearStart, endDate: now, includedDays: [1, 2, 3, 4, 5, 6, 7]) let allTimeEntries = DataController.shared.getData(startDate: allTimeStart, endDate: now, includedDays: [1, 2, 3, 4, 5, 6, 7]) // Pre-fetch health data once (instead of 3x per period) var sharedHealthAverages: HealthService.HealthAverages? if healthService.isEnabled && healthService.isAuthorized { let allValidEntries = allTimeEntries.filter { ![.missing, .placeholder].contains($0.mood) } if !allValidEntries.isEmpty { let healthData = await healthService.fetchHealthData(for: allValidEntries) sharedHealthAverages = healthService.computeHealthAverages(entries: allValidEntries, healthData: healthData) } } // Set all states to loading upfront so the overlay dismisses // as soon as all tasks complete (not one-by-one) monthLoadingState = .loading yearLoadingState = .loading allTimeLoadingState = .loading // Generate insights concurrently for all three periods await withTaskGroup(of: Void.self) { group in group.addTask { @MainActor in await self.generatePeriodInsights( entries: monthEntries, periodName: "this month", healthAverages: sharedHealthAverages, updateState: { self.monthLoadingState = $0 }, updateInsights: { self.monthInsights = $0 } ) } group.addTask { @MainActor in await self.generatePeriodInsights( entries: yearEntries, periodName: "this year", healthAverages: sharedHealthAverages, updateState: { self.yearLoadingState = $0 }, updateInsights: { self.yearInsights = $0 } ) } group.addTask { @MainActor in await self.generatePeriodInsights( entries: allTimeEntries, periodName: "all time", healthAverages: sharedHealthAverages, updateState: { self.allTimeLoadingState = $0 }, updateInsights: { self.allTimeInsights = $0 } ) } } } private func generatePeriodInsights( entries: [MoodEntryModel], periodName: String, healthAverages: HealthService.HealthAverages?, updateState: @escaping (InsightLoadingState) -> Void, updateInsights: @escaping ([Insight]) -> Void ) async { // Filter valid entries let validEntries = entries.filter { ![.missing, .placeholder].contains($0.mood) } // Handle empty data case guard !validEntries.isEmpty else { updateInsights([Insight( icon: "questionmark.circle", title: "No Data Yet", description: "Start logging your moods to see insights for \(periodName).", mood: nil )]) updateState(.loaded) return } // Check if AI is available — show reason-specific guidance guard isAIAvailable else { let (icon, title, description) = unavailableMessage() updateInsights([Insight(icon: icon, title: title, description: description, mood: nil)]) updateState(.error("AI not available")) return } updateState(.loading) if #available(iOS 26, *), let service = insightService as? FoundationModelsInsightService { do { let insights = try await service.generateInsights( for: validEntries, periodName: periodName, count: 5, healthAverages: healthAverages ) updateInsights(insights) updateState(.loaded) } catch { // On error, provide a helpful message updateInsights([Insight( icon: "exclamationmark.triangle", title: "Insights Unavailable", description: "Unable to generate AI insights right now. Please try again later.", mood: nil )]) updateState(.error(error.localizedDescription)) } } else { let (icon, title, description) = unavailableMessage() updateInsights([Insight(icon: icon, title: title, description: description, mood: nil)]) updateState(.error("AI not available")) } } // MARK: - Unavailable Messages private func unavailableMessage() -> (icon: String, title: String, description: String) { switch aiUnavailableReason { case .deviceNotEligible: return ("iphone.slash", "Device Not Supported", String(localized: "AI insights require iPhone 15 Pro or later with Apple Intelligence.")) case .notEnabled: return ("gearshape.fill", "Apple Intelligence Disabled", String(localized: "Turn on Apple Intelligence in Settings → Apple Intelligence & Siri to unlock AI insights.")) case .modelDownloading: return ("arrow.down.circle", "AI Model Downloading", String(localized: "The AI model is still downloading. Please wait a few minutes and try again.")) case .preiOS26: return ("arrow.up.circle", "Update Required", String(localized: "AI insights require iOS 26 or later with Apple Intelligence.")) case .unknown: return ("brain.head.profile", "AI Unavailable", String(localized: "Apple Intelligence is required for personalized insights.")) } } /// Re-check AI availability (e.g., after returning from Settings) func recheckAvailability() { if #available(iOS 26, *), let service = insightService as? FoundationModelsInsightService { service.checkAvailability() let wasAvailable = isAIAvailable isAIAvailable = service.isAvailable aiUnavailableReason = service.unavailableReason // If just became available, generate insights if !wasAvailable && isAIAvailable { service.prewarm() generateInsights() } } } }