- Replace HealthService.analyzeCorrelations() with computeHealthAverages() - Remove hardcoded threshold-based correlation analysis (8k steps, 7hrs sleep, etc.) - Pass raw averages (steps, exercise, sleep, HRV, HR, mindfulness, calories) to AI - Let Apple Intelligence find nuanced multi-variable patterns naturally - Update MoodDataSummarizer to format raw health data for AI prompts - Simplifies code by ~200 lines while improving insight quality 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
180 lines
5.9 KiB
Swift
180 lines
5.9 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
|
|
|
|
init() {
|
|
isAIAvailable = insightService.isAvailable
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
}
|
|
}
|