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:
@@ -1133,7 +1133,7 @@
|
|||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -1175,7 +1175,7 @@
|
|||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -1260,7 +1260,7 @@
|
|||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "com.88oak.Tests-iOS";
|
PRODUCT_BUNDLE_IDENTIFIER = "com.88oak.Tests-iOS";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -1278,7 +1278,7 @@
|
|||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "com.88oak.Tests-iOS";
|
PRODUCT_BUNDLE_IDENTIFIER = "com.88oak.Tests-iOS";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -1403,7 +1403,7 @@
|
|||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
DEVELOPMENT_TEAM = QND55P4443;
|
DEVELOPMENT_TEAM = QND55P4443;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.feels.tests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.feels.tests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -1453,7 +1453,7 @@
|
|||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
DEVELOPMENT_TEAM = QND55P4443;
|
DEVELOPMENT_TEAM = QND55P4443;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.feels.tests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.feels.tests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import Foundation
|
|||||||
import FoundationModels
|
import FoundationModels
|
||||||
|
|
||||||
/// Represents a single AI-generated insight using @Generable for structured LLM output
|
/// Represents a single AI-generated insight using @Generable for structured LLM output
|
||||||
|
@available(iOS 26, *)
|
||||||
@Generable
|
@Generable
|
||||||
struct AIInsight: Equatable {
|
struct AIInsight: Equatable {
|
||||||
@Guide(description: "A brief, engaging title for the insight (3-6 words)")
|
@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
|
/// Container for period-specific insights - the LLM generates this structure
|
||||||
|
@available(iOS 26, *)
|
||||||
@Generable
|
@Generable
|
||||||
struct AIInsightsResponse: Equatable {
|
struct AIInsightsResponse: Equatable {
|
||||||
@Guide(description: "Array of exactly 5 diverse insights covering patterns, advice, and predictions")
|
@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
|
// MARK: - Conversion to App's Insight Model
|
||||||
|
|
||||||
|
@available(iOS 26, *)
|
||||||
extension AIInsight {
|
extension AIInsight {
|
||||||
/// Converts AI-generated insight to the app's existing Insight model
|
/// Converts AI-generated insight to the app's existing Insight model
|
||||||
func toInsight() -> Insight {
|
func toInsight() -> Insight {
|
||||||
@@ -60,6 +63,7 @@ extension AIInsight {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@available(iOS 26, *)
|
||||||
extension AIInsightsResponse {
|
extension AIInsightsResponse {
|
||||||
/// Converts all AI insights to app's Insight models
|
/// Converts all AI insights to app's Insight models
|
||||||
func toInsights() -> [Insight] {
|
func toInsights() -> [Insight] {
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ enum DayViewStyle: Int, CaseIterable {
|
|||||||
case wave = 13 // Horizontal gradient river bands
|
case wave = 13 // Horizontal gradient river bands
|
||||||
case pattern = 14 // Mood icons as repeating background pattern
|
case pattern = 14 // Mood icons as repeating background pattern
|
||||||
case leather = 15 // Skeuomorphic leather with stitching
|
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 motion = 17 // Accelerometer-driven parallax effect
|
||||||
case micro = 18 // Ultra compact single-line entries
|
case micro = 18 // Ultra compact single-line entries
|
||||||
case orbit = 19 // Celestial circular orbital arrangement
|
case orbit = 19 // Celestial circular orbital arrangement
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ enum InsightGenerationError: Error, LocalizedError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Service responsible for generating AI-powered mood insights using Apple's Foundation Models
|
/// Service responsible for generating AI-powered mood insights using Apple's Foundation Models
|
||||||
|
@available(iOS 26, *)
|
||||||
@MainActor
|
@MainActor
|
||||||
class FoundationModelsInsightService: ObservableObject {
|
class FoundationModelsInsightService: ObservableObject {
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ class InsightsViewModel: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Dependencies
|
// 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 healthService = HealthService.shared
|
||||||
private let calendar = Calendar.current
|
private let calendar = Calendar.current
|
||||||
|
|
||||||
@@ -52,7 +53,14 @@ class InsightsViewModel: ObservableObject {
|
|||||||
private var dataListenerToken: DataController.DataListenerToken?
|
private var dataListenerToken: DataController.DataListenerToken?
|
||||||
|
|
||||||
init() {
|
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
|
dataListenerToken = DataController.shared.addNewDataListener { [weak self] in
|
||||||
self?.onDataChanged()
|
self?.onDataChanged()
|
||||||
@@ -70,7 +78,9 @@ class InsightsViewModel: ObservableObject {
|
|||||||
/// Called when mood data changes in another tab. Invalidates cached insights
|
/// Called when mood data changes in another tab. Invalidates cached insights
|
||||||
/// so they are regenerated with fresh data on next view appearance.
|
/// so they are regenerated with fresh data on next view appearance.
|
||||||
private func onDataChanged() {
|
private func onDataChanged() {
|
||||||
insightService.invalidateCache()
|
if #available(iOS 26, *), let service = insightService as? FoundationModelsInsightService {
|
||||||
|
service.invalidateCache()
|
||||||
|
}
|
||||||
monthLoadingState = .idle
|
monthLoadingState = .idle
|
||||||
yearLoadingState = .idle
|
yearLoadingState = .idle
|
||||||
allTimeLoadingState = .idle
|
allTimeLoadingState = .idle
|
||||||
@@ -87,7 +97,9 @@ class InsightsViewModel: ObservableObject {
|
|||||||
|
|
||||||
/// Force refresh all insights (invalidates cache)
|
/// Force refresh all insights (invalidates cache)
|
||||||
func refreshInsights() {
|
func refreshInsights() {
|
||||||
insightService.invalidateCache()
|
if #available(iOS 26, *), let service = insightService as? FoundationModelsInsightService {
|
||||||
|
service.invalidateCache()
|
||||||
|
}
|
||||||
generateInsights()
|
generateInsights()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,24 +191,34 @@ class InsightsViewModel: ObservableObject {
|
|||||||
healthAverages = healthService.computeHealthAverages(entries: validEntries, healthData: healthData)
|
healthAverages = healthService.computeHealthAverages(entries: validEntries, healthData: healthData)
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
if #available(iOS 26, *), let service = insightService as? FoundationModelsInsightService {
|
||||||
let insights = try await insightService.generateInsights(
|
do {
|
||||||
for: validEntries,
|
let insights = try await service.generateInsights(
|
||||||
periodName: periodName,
|
for: validEntries,
|
||||||
count: 5,
|
periodName: periodName,
|
||||||
healthAverages: healthAverages
|
count: 5,
|
||||||
)
|
healthAverages: healthAverages
|
||||||
updateInsights(insights)
|
)
|
||||||
updateState(.loaded)
|
updateInsights(insights)
|
||||||
} catch {
|
updateState(.loaded)
|
||||||
// On error, provide a helpful message
|
} 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(
|
updateInsights([Insight(
|
||||||
icon: "exclamationmark.triangle",
|
icon: "brain.head.profile",
|
||||||
title: "Insights Unavailable",
|
title: "AI Unavailable",
|
||||||
description: "Unable to generate AI insights right now. Please try again later.",
|
description: "Apple Intelligence is required for personalized insights. Please enable it in Settings.",
|
||||||
mood: nil
|
mood: nil
|
||||||
)])
|
)])
|
||||||
updateState(.error(error.localizedDescription))
|
updateState(.error("AI not available"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user