Files
Reflect/Shared/Views/InsightsView/InsightsViewModel.swift
Trey t c22d246865 Fix 25 audit issues: memory leaks, concurrency, performance, accessibility
Address findings from comprehensive audit across 5 workstreams:

- Memory: Token-based DataController listeners (prevent closure leaks),
  static DateFormatters, ImageCache observer cleanup, MotionManager
  reference counting, FoundationModels dedup guard
- Concurrency: Replace Task.detached with Task in FeelsApp (preserve
  MainActor isolation), wrap WatchConnectivity handler in MainActor
- Performance: Cache sortedGroupedData in DayViewViewModel, cache demo
  data in MonthView/YearView, remove broken ReduceMotionModifier
- Accessibility: VoiceOver support for LockScreen, DemoHeatmapCell
  labels, MonthCard button labels, InsightsView header traits,
  Smart Invert protection on neon headers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:11:48 -06:00

203 lines
6.6 KiB
Swift

//
// InsightsViewModel.swift
// Feels
//
// 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
private let insightService = FoundationModelsInsightService()
private let healthService = HealthService.shared
private let calendar = Calendar.current
// MARK: - Initialization
private var dataListenerToken: DataController.DataListenerToken?
init() {
isAIAvailable = insightService.isAvailable
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() {
insightService.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() {
insightService.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)
}
do {
let insights = try await insightService.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))
}
}
}