Files
Reflect/Shared/Views/InsightsView/InsightsViewModel.swift
Trey T e6a34a0f25 Fix stuck "Generating Insights" modal overlay
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>
2026-04-05 19:43:32 -05:00

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()
}
}
}
}