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>
This commit is contained in:
Trey t
2026-04-04 21:36:04 -05:00
parent 7a6c4056d8
commit b0cd4be8d7
4 changed files with 19737 additions and 18882 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -30,6 +30,15 @@ enum InsightGenerationError: Error, LocalizedError {
} }
} }
/// Why Apple Intelligence is unavailable
enum AIUnavailableReason {
case deviceNotEligible
case notEnabled
case modelDownloading
case unknown
case preiOS26
}
/// 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, *) @available(iOS 26, *)
@MainActor @MainActor
@@ -40,6 +49,7 @@ class FoundationModelsInsightService: ObservableObject {
@Published private(set) var isAvailable: Bool = false @Published private(set) var isAvailable: Bool = false
@Published private(set) var isGenerating: Bool = false @Published private(set) var isGenerating: Bool = false
@Published private(set) var lastError: InsightGenerationError? @Published private(set) var lastError: InsightGenerationError?
@Published private(set) var unavailableReason: AIUnavailableReason = .unknown
// MARK: - Dependencies // MARK: - Dependencies
@@ -63,15 +73,27 @@ class FoundationModelsInsightService: ObservableObject {
switch model.availability { switch model.availability {
case .available: case .available:
isAvailable = true isAvailable = true
unavailableReason = .unknown
case .unavailable(let reason): case .unavailable(let reason):
isAvailable = false isAvailable = false
unavailableReason = mapUnavailableReason(reason)
lastError = .modelUnavailable(reason: describeUnavailability(reason)) lastError = .modelUnavailable(reason: describeUnavailability(reason))
@unknown default: @unknown default:
isAvailable = false isAvailable = false
unavailableReason = .unknown
lastError = .modelUnavailable(reason: "Unknown availability status") lastError = .modelUnavailable(reason: "Unknown availability status")
} }
} }
private func mapUnavailableReason(_ reason: SystemLanguageModel.Availability.UnavailableReason) -> AIUnavailableReason {
switch reason {
case .deviceNotEligible: return .deviceNotEligible
case .appleIntelligenceNotEnabled: return .notEnabled
case .modelNotReady: return .modelDownloading
@unknown default: return .unknown
}
}
private func describeUnavailability(_ reason: SystemLanguageModel.Availability.UnavailableReason) -> String { private func describeUnavailability(_ reason: SystemLanguageModel.Availability.UnavailableReason) -> String {
switch reason { switch reason {
case .deviceNotEligible: case .deviceNotEligible:

View File

@@ -21,6 +21,7 @@ struct InsightsView: View {
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
@AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome @AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@Environment(\.scenePhase) private var scenePhase
private var textColor: Color { theme.currentTheme.labelColor } private var textColor: Color { theme.currentTheme.labelColor }
@@ -107,6 +108,11 @@ struct InsightsView: View {
viewModel.generateInsights() viewModel.generateInsights()
loadWeeklyDigest() loadWeeklyDigest()
} }
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
viewModel.recheckAvailability()
}
}
.padding(.top) .padding(.top)
} }
@@ -136,6 +142,11 @@ struct InsightsView: View {
private var insightsContent: some View { private var insightsContent: some View {
ScrollView { ScrollView {
VStack(spacing: 20) { VStack(spacing: 20) {
// AI enablement guidance when not available
if !viewModel.isAIAvailable && !iapManager.shouldShowPaywall {
aiEnablementCard
}
// Weekly Digest Card // Weekly Digest Card
if let digest = weeklyDigest { if let digest = weeklyDigest {
WeeklyDigestCardView(digest: digest) WeeklyDigestCardView(digest: digest)
@@ -193,6 +204,91 @@ struct InsightsView: View {
.disabled(iapManager.shouldShowPaywall) .disabled(iapManager.shouldShowPaywall)
} }
// MARK: - AI Enablement Card
private var aiEnablementCard: some View {
VStack(spacing: 16) {
Image(systemName: aiEnablementIcon)
.font(.system(size: 36))
.foregroundStyle(.secondary)
Text(aiEnablementTitle)
.font(.headline)
.foregroundColor(textColor)
Text(aiEnablementDescription)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
if viewModel.aiUnavailableReason == .notEnabled {
Button {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
} label: {
Label(String(localized: "Open Settings"), systemImage: "gear")
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.purple)
}
if viewModel.aiUnavailableReason == .modelDownloading {
Button {
viewModel.recheckAvailability()
} label: {
Label(String(localized: "Try Again"), systemImage: "arrow.clockwise")
.font(.subheadline.weight(.medium))
}
.buttonStyle(.bordered)
}
}
.padding(24)
.background(
RoundedRectangle(cornerRadius: 20)
.fill(Color(.secondarySystemBackground))
)
.padding(.horizontal)
}
private var aiEnablementIcon: String {
switch viewModel.aiUnavailableReason {
case .deviceNotEligible: return "iphone.slash"
case .notEnabled: return "gearshape.fill"
case .modelDownloading: return "arrow.down.circle"
case .preiOS26: return "arrow.up.circle"
case .unknown: return "brain.head.profile"
}
}
private var aiEnablementTitle: String {
switch viewModel.aiUnavailableReason {
case .deviceNotEligible: return String(localized: "Device Not Supported")
case .notEnabled: return String(localized: "Enable Apple Intelligence")
case .modelDownloading: return String(localized: "AI Model Downloading")
case .preiOS26: return String(localized: "Update Required")
case .unknown: return String(localized: "AI Unavailable")
}
}
private var aiEnablementDescription: String {
switch viewModel.aiUnavailableReason {
case .deviceNotEligible:
return String(localized: "AI insights require iPhone 15 Pro or later with Apple Intelligence.")
case .notEnabled:
return String(localized: "Turn on Apple Intelligence to unlock personalized mood insights.\n\nSettings → Apple Intelligence & Siri → Apple Intelligence")
case .modelDownloading:
return String(localized: "The AI model is still downloading. This may take a few minutes.")
case .preiOS26:
return String(localized: "AI insights require iOS 26 or later with Apple Intelligence.")
case .unknown:
return String(localized: "Apple Intelligence is required for personalized insights.")
}
}
// MARK: - Generating State // MARK: - Generating State
private var isGeneratingInsights: Bool { private var isGeneratingInsights: Bool {

View File

@@ -40,6 +40,7 @@ class InsightsViewModel: ObservableObject {
@Published var allTimeLoadingState: InsightLoadingState = .idle @Published var allTimeLoadingState: InsightLoadingState = .idle
@Published var isAIAvailable: Bool = false @Published var isAIAvailable: Bool = false
@Published var aiUnavailableReason: AIUnavailableReason = .preiOS26
// MARK: - Dependencies // MARK: - Dependencies
@@ -57,10 +58,12 @@ class InsightsViewModel: ObservableObject {
let service = FoundationModelsInsightService() let service = FoundationModelsInsightService()
insightService = service insightService = service
isAIAvailable = service.isAvailable isAIAvailable = service.isAvailable
aiUnavailableReason = service.unavailableReason
service.prewarm() service.prewarm()
} else { } else {
insightService = nil insightService = nil
isAIAvailable = false isAIAvailable = false
aiUnavailableReason = .preiOS26
} }
dataListenerToken = DataController.shared.addNewDataListener { [weak self] in dataListenerToken = DataController.shared.addNewDataListener { [weak self] in
@@ -185,14 +188,10 @@ class InsightsViewModel: ObservableObject {
return return
} }
// Check if AI is available // Check if AI is available show reason-specific guidance
guard isAIAvailable else { guard isAIAvailable else {
updateInsights([Insight( let (icon, title, description) = unavailableMessage()
icon: "brain.head.profile", updateInsights([Insight(icon: icon, title: title, description: description, mood: nil)])
title: "AI Unavailable",
description: "Apple Intelligence is required for personalized insights. Please enable it in Settings.",
mood: nil
)])
updateState(.error("AI not available")) updateState(.error("AI not available"))
return return
} }
@@ -220,13 +219,47 @@ class InsightsViewModel: ObservableObject {
updateState(.error(error.localizedDescription)) updateState(.error(error.localizedDescription))
} }
} else { } else {
updateInsights([Insight( let (icon, title, description) = unavailableMessage()
icon: "brain.head.profile", updateInsights([Insight(icon: icon, title: title, description: description, mood: nil)])
title: "AI Unavailable",
description: "Apple Intelligence is required for personalized insights. Please enable it in Settings.",
mood: nil
)])
updateState(.error("AI not available")) 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()
}
}
}
} }