Lower deployment target from iOS 26 to iOS 18, gate Foundation Models behind @available

Broadens installable audience to iOS 18+ while keeping AI insights available on iOS 26.
Foundation Models types and service wrapped in @available(iOS 26, *), InsightsViewModel
conditionally instantiates the service with fallback UI on older versions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-23 20:21:45 -06:00
parent 7660521540
commit b2b6931d7c
5 changed files with 53 additions and 26 deletions

View File

@@ -9,6 +9,7 @@ import Foundation
import FoundationModels
/// Represents a single AI-generated insight using @Generable for structured LLM output
@available(iOS 26, *)
@Generable
struct AIInsight: Equatable {
@Guide(description: "A brief, engaging title for the insight (3-6 words)")
@@ -31,6 +32,7 @@ struct AIInsight: Equatable {
}
/// Container for period-specific insights - the LLM generates this structure
@available(iOS 26, *)
@Generable
struct AIInsightsResponse: Equatable {
@Guide(description: "Array of exactly 5 diverse insights covering patterns, advice, and predictions")
@@ -39,6 +41,7 @@ struct AIInsightsResponse: Equatable {
// MARK: - Conversion to App's Insight Model
@available(iOS 26, *)
extension AIInsight {
/// Converts AI-generated insight to the app's existing Insight model
func toInsight() -> Insight {
@@ -60,6 +63,7 @@ extension AIInsight {
}
}
@available(iOS 26, *)
extension AIInsightsResponse {
/// Converts all AI insights to app's Insight models
func toInsights() -> [Insight] {

View File

@@ -127,7 +127,7 @@ enum DayViewStyle: Int, CaseIterable {
case wave = 13 // Horizontal gradient river bands
case pattern = 14 // Mood icons as repeating background pattern
case leather = 15 // Skeuomorphic leather with stitching
case glass = 16 // iOS 26 liquid glass with variable blur
case glass = 16 // Liquid glass with variable blur
case motion = 17 // Accelerometer-driven parallax effect
case micro = 18 // Ultra compact single-line entries
case orbit = 19 // Celestial circular orbital arrangement

View File

@@ -30,6 +30,7 @@ enum InsightGenerationError: Error, LocalizedError {
}
/// Service responsible for generating AI-powered mood insights using Apple's Foundation Models
@available(iOS 26, *)
@MainActor
class FoundationModelsInsightService: ObservableObject {

View File

@@ -43,7 +43,8 @@ class InsightsViewModel: ObservableObject {
// MARK: - Dependencies
private let insightService = FoundationModelsInsightService()
/// 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
@@ -52,7 +53,14 @@ class InsightsViewModel: ObservableObject {
private var dataListenerToken: DataController.DataListenerToken?
init() {
isAIAvailable = insightService.isAvailable
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()
@@ -70,7 +78,9 @@ class InsightsViewModel: ObservableObject {
/// 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()
if #available(iOS 26, *), let service = insightService as? FoundationModelsInsightService {
service.invalidateCache()
}
monthLoadingState = .idle
yearLoadingState = .idle
allTimeLoadingState = .idle
@@ -87,7 +97,9 @@ class InsightsViewModel: ObservableObject {
/// Force refresh all insights (invalidates cache)
func refreshInsights() {
insightService.invalidateCache()
if #available(iOS 26, *), let service = insightService as? FoundationModelsInsightService {
service.invalidateCache()
}
generateInsights()
}
@@ -179,24 +191,34 @@ class InsightsViewModel: ObservableObject {
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
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: "exclamationmark.triangle",
title: "Insights Unavailable",
description: "Unable to generate AI insights right now. Please try again later.",
icon: "brain.head.profile",
title: "AI Unavailable",
description: "Apple Intelligence is required for personalized insights. Please enable it in Settings.",
mood: nil
)])
updateState(.error(error.localizedDescription))
updateState(.error("AI not available"))
}
}
}