Files
Reflect/Shared/Views/InsightsView/InsightsViewModel.swift
Trey t 0442eab1f8 Rebrand entire project from Feels to Reflect
Complete rename across all bundle IDs, App Groups, CloudKit containers,
StoreKit product IDs, data store filenames, URL schemes, logger subsystems,
Swift identifiers, user-facing strings (7 languages), file names, directory
names, Xcode project, schemes, assets, and documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:47:16 -06:00

225 lines
7.6 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
// 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
} else {
insightService = nil
isAIAvailable = false
}
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])
// 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",
updateState: { self.monthLoadingState = $0 },
updateInsights: { self.monthInsights = $0 }
)
}
group.addTask { @MainActor in
await self.generatePeriodInsights(
entries: yearEntries,
periodName: "this year",
updateState: { self.yearLoadingState = $0 },
updateInsights: { self.yearInsights = $0 }
)
}
group.addTask { @MainActor in
await self.generatePeriodInsights(
entries: allTimeEntries,
periodName: "all time",
updateState: { self.allTimeLoadingState = $0 },
updateInsights: { self.allTimeInsights = $0 }
)
}
}
}
private func generatePeriodInsights(
entries: [MoodEntryModel],
periodName: String,
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
guard isAIAvailable else {
updateInsights([Insight(
icon: "brain.head.profile",
title: "AI Unavailable",
description: "Apple Intelligence is required for personalized insights. Please enable it in Settings.",
mood: nil
)])
updateState(.error("AI not available"))
return
}
updateState(.loading)
// Fetch health data if enabled - pass raw averages to AI for correlation analysis
var healthAverages: HealthService.HealthAverages?
if healthService.isEnabled && healthService.isAuthorized {
let healthData = await healthService.fetchHealthData(for: validEntries)
healthAverages = healthService.computeHealthAverages(entries: validEntries, healthData: healthData)
}
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 {
updateInsights([Insight(
icon: "brain.head.profile",
title: "AI Unavailable",
description: "Apple Intelligence is required for personalized insights. Please enable it in Settings.",
mood: nil
)])
updateState(.error("AI not available"))
}
}
}