Files
Reflect/Shared/Views/InsightsView/InsightsViewModel.swift
Trey t b0cd4be8d7 Add AI enablement guidance with reason-specific UI and localized translations
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>
2026-04-04 21:36:04 -05:00

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