Set all three loading states to .loading upfront before entering the task group, and remove .idle from the modal visibility condition. This prevents the overlay from staying visible when tasks complete at different rates while others remain in .idle state. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
272 lines
10 KiB
Swift
272 lines
10 KiB
Swift
//
|
|
// 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()
|
|
}
|
|
}
|
|
}
|
|
}
|