Show specific guidance when Apple Intelligence is unavailable: - Device not eligible: "iPhone 15 Pro or later required" - Not enabled: step-by-step path + "Open Settings" button - Model downloading: "Please wait" + "Try Again" button - Pre-iOS 26: "Update required" Auto re-checks availability when app returns to foreground so enabling Apple Intelligence in Settings immediately triggers insight generation. Adds translations for all new AI strings across de, es, fr, ja, ko, pt-BR. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
266 lines
9.8 KiB
Swift
266 lines
9.8 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)
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
}
|
|
}
|
|
}
|