diff --git a/Feels.xcodeproj/project.pbxproj b/Feels.xcodeproj/project.pbxproj index b3c0196..c07210f 100644 --- a/Feels.xcodeproj/project.pbxproj +++ b/Feels.xcodeproj/project.pbxproj @@ -1133,7 +1133,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 26.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1175,7 +1175,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 26.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1260,7 +1260,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "com.88oak.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1278,7 +1278,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "com.88oak.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1403,7 +1403,7 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = QND55P4443; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.feels.tests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -1453,7 +1453,7 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = QND55P4443; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.feels.tests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/Shared/Models/AIInsight.swift b/Shared/Models/AIInsight.swift index 99a9f51..99b21db 100644 --- a/Shared/Models/AIInsight.swift +++ b/Shared/Models/AIInsight.swift @@ -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] { diff --git a/Shared/Models/UserDefaultsStore.swift b/Shared/Models/UserDefaultsStore.swift index 4b64982..096f3ac 100644 --- a/Shared/Models/UserDefaultsStore.swift +++ b/Shared/Models/UserDefaultsStore.swift @@ -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 diff --git a/Shared/Services/FoundationModelsInsightService.swift b/Shared/Services/FoundationModelsInsightService.swift index c7a8b4c..a4ba793 100644 --- a/Shared/Services/FoundationModelsInsightService.swift +++ b/Shared/Services/FoundationModelsInsightService.swift @@ -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 { diff --git a/Shared/Views/InsightsView/InsightsViewModel.swift b/Shared/Views/InsightsView/InsightsViewModel.swift index 674cf30..03dffce 100644 --- a/Shared/Views/InsightsView/InsightsViewModel.swift +++ b/Shared/Views/InsightsView/InsightsViewModel.swift @@ -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")) } } }