Files
Reflect/Shared/Views/InsightsView/InsightsView.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

652 lines
23 KiB
Swift

//
// InsightsView.swift
// Reflect
//
// Created by Claude Code on 12/9/24.
//
import SwiftUI
enum InsightsTab: String, CaseIterable {
case insights = "Insights"
case reports = "Reports"
}
struct InsightsView: View {
private enum AnimationConstants {
static let refreshDelay: UInt64 = 500_000_000 // 0.5 seconds in nanoseconds
}
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@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
@Environment(\.colorScheme) private var colorScheme
@Environment(\.scenePhase) private var scenePhase
private var textColor: Color { theme.currentTheme.labelColor }
@StateObject private var viewModel = InsightsViewModel()
@EnvironmentObject var iapManager: IAPManager
@State private var showSubscriptionStore = false
@State private var selectedTab: InsightsTab = .insights
@State private var weeklyDigest: WeeklyDigest?
@State private var showDigest = true
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("Insights")
.font(.title.weight(.bold))
.foregroundColor(textColor)
.accessibilityIdentifier(AccessibilityID.Insights.header)
Spacer()
// AI badge
if viewModel.isAIAvailable {
HStack(spacing: 4) {
Image(systemName: "sparkles")
.font(.caption.weight(.medium))
.accessibilityHidden(true)
Text("AI")
.font(.caption.weight(.semibold))
}
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
LinearGradient(
colors: [.purple, .blue],
startPoint: .leading,
endPoint: .trailing
)
)
.clipShape(Capsule())
.aiInsightsTip()
}
}
.padding(.horizontal)
// Segmented picker
Picker("", selection: $selectedTab) {
ForEach(InsightsTab.allCases, id: \.self) { tab in
Text(tab.rawValue).tag(tab)
}
}
.pickerStyle(.segmented)
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 16)
.accessibilityIdentifier(AccessibilityID.Reports.segmentedPicker)
// Content
ZStack {
if selectedTab == .insights {
insightsContent
} else {
ReportsView()
}
if iapManager.shouldShowPaywall {
paywallOverlay
}
if selectedTab == .insights && isGeneratingInsights && !iapManager.shouldShowPaywall {
generatingOverlay
}
}
}
.sheet(isPresented: $showSubscriptionStore) {
ReflectSubscriptionStoreView(source: "insights_gate")
}
.background(
theme.currentTheme.bg
.edgesIgnoringSafeArea(.all)
)
.onAppear {
AnalyticsManager.shared.trackScreen(.insights)
viewModel.generateInsights()
loadWeeklyDigest()
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
viewModel.recheckAvailability()
}
}
.padding(.top)
}
// MARK: - Insights Content
private func loadWeeklyDigest() {
guard #available(iOS 26, *), !iapManager.shouldShowPaywall else { return }
// Try cached digest first
if let digest = FoundationModelsDigestService.shared.loadLatestDigest(),
digest.isFromCurrentWeek {
weeklyDigest = digest
return
}
// No digest for this week generate one on-demand
Task {
do {
let digest = try await FoundationModelsDigestService.shared.generateWeeklyDigest()
weeklyDigest = digest
} catch {
// Not enough data or AI unavailable just don't show the card
}
}
}
private var insightsContent: some View {
ScrollView {
VStack(spacing: 20) {
// AI enablement guidance when not available
if !viewModel.isAIAvailable && !iapManager.shouldShowPaywall {
aiEnablementCard
}
// Weekly Digest Card
if let digest = weeklyDigest {
WeeklyDigestCardView(digest: digest)
}
// This Month Section
InsightsSectionView(
title: "This Month",
icon: "calendar",
insights: viewModel.monthInsights,
loadingState: viewModel.monthLoadingState,
textColor: textColor,
moodTint: moodTint,
imagePack: imagePack,
colorScheme: colorScheme
)
.accessibilityIdentifier(AccessibilityID.Insights.monthSection)
// This Year Section
InsightsSectionView(
title: "This Year",
icon: "calendar.badge.clock",
insights: viewModel.yearInsights,
loadingState: viewModel.yearLoadingState,
textColor: textColor,
moodTint: moodTint,
imagePack: imagePack,
colorScheme: colorScheme
)
.accessibilityIdentifier(AccessibilityID.Insights.yearSection)
// All Time Section
InsightsSectionView(
title: "All Time",
icon: "infinity",
insights: viewModel.allTimeInsights,
loadingState: viewModel.allTimeLoadingState,
textColor: textColor,
moodTint: moodTint,
imagePack: imagePack,
colorScheme: colorScheme
)
.accessibilityIdentifier(AccessibilityID.Insights.allTimeSection)
}
.padding(.vertical)
.padding(.bottom, 100)
}
.opacity(isGeneratingInsights && !iapManager.shouldShowPaywall ? 0.2 : 1.0)
.animation(.easeInOut(duration: 0.3), value: isGeneratingInsights)
.refreshable {
viewModel.refreshInsights()
// Small delay to show refresh animation
try? await Task.sleep(nanoseconds: AnimationConstants.refreshDelay)
}
.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
private var isGeneratingInsights: Bool {
let states = [viewModel.monthLoadingState, viewModel.yearLoadingState, viewModel.allTimeLoadingState]
return states.contains(where: { $0 == .loading || $0 == .idle })
}
private var generatingOverlay: some View {
VStack(spacing: 20) {
Spacer()
VStack(spacing: 16) {
Image(systemName: "sparkles")
.font(.system(size: 36))
.foregroundStyle(
LinearGradient(
colors: [.purple, .blue],
startPoint: .leading,
endPoint: .trailing
)
)
.symbolEffect(.pulse, options: .repeating)
Text(String(localized: "Generating Insights"))
.font(.headline)
.foregroundColor(textColor)
Text(String(localized: "Apple Intelligence is analyzing your mood data..."))
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding(32)
.background(
RoundedRectangle(cornerRadius: 24)
.fill(.regularMaterial)
)
.padding(.horizontal, 40)
Spacer()
}
.transition(.opacity)
}
// MARK: - Paywall Overlay
private var paywallOverlay: some View {
VStack(spacing: 24) {
Spacer()
// Icon
ZStack {
Circle()
.fill(
LinearGradient(
colors: [.purple.opacity(0.2), .blue.opacity(0.2)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 100, height: 100)
Image(systemName: "sparkles")
.font(.largeTitle)
.accessibilityHidden(true)
.foregroundStyle(
LinearGradient(
colors: [.purple, .blue],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
// Text
VStack(spacing: 12) {
Text("Unlock AI-Powered Insights")
.font(.title2.weight(.bold))
.foregroundColor(textColor)
.multilineTextAlignment(.center)
Text("Discover patterns in your mood, get personalized recommendations, and understand what affects how you feel.")
.font(.body)
.foregroundColor(textColor.opacity(0.7))
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
// Subscribe button
Button {
showSubscriptionStore = true
} label: {
HStack {
Image(systemName: "sparkles")
.accessibilityHidden(true)
Text("Get Personal Insights")
}
.font(.headline.weight(.bold))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(
LinearGradient(
colors: [.purple, .blue],
startPoint: .leading,
endPoint: .trailing
)
)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.padding(.horizontal, 24)
.accessibilityIdentifier(AccessibilityID.Paywall.insightsUnlockButton)
Spacer()
}
.background(theme.currentTheme.bg)
.accessibilityIdentifier(AccessibilityID.Paywall.insightsOverlay)
}
}
// MARK: - Insights Section View
struct InsightsSectionView: View {
let title: String
let icon: String
let insights: [Insight]
let loadingState: InsightLoadingState
let textColor: Color
let moodTint: MoodTints
let imagePack: MoodImages
let colorScheme: ColorScheme
@State private var isExpanded = true
var body: some View {
VStack(spacing: 0) {
// Section Header
Button(action: {
if UIAccessibility.isReduceMotionEnabled {
isExpanded.toggle()
} else {
withAnimation(.easeInOut(duration: 0.2)) { isExpanded.toggle() }
}
}) {
HStack {
Image(systemName: icon)
.font(.headline.weight(.medium))
.foregroundColor(textColor.opacity(0.6))
Text(title)
.font(.title3.weight(.bold))
.foregroundColor(textColor)
// Loading indicator in header
if loadingState == .loading {
ProgressView()
.scaleEffect(0.7)
.padding(.leading, 4)
}
Spacer()
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.caption.weight(.semibold))
.foregroundColor(textColor.opacity(0.4))
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
.buttonStyle(.plain)
.accessibilityIdentifier(AccessibilityID.Insights.expandCollapseButton)
.accessibilityAddTraits(.isHeader)
// Insights List (collapsible)
if isExpanded {
switch loadingState {
case .loading:
// Skeleton loading view
VStack(spacing: 10) {
ForEach(0..<3, id: \.self) { _ in
InsightSkeletonView(colorScheme: colorScheme)
}
}
.padding(.horizontal, 16)
.padding(.bottom, 16)
.transition(.opacity)
case .error:
// Show insights (which contain error message) with error styling
VStack(spacing: 10) {
ForEach(insights) { insight in
InsightCardView(
insight: insight,
textColor: textColor,
moodTint: moodTint,
imagePack: imagePack,
colorScheme: colorScheme
)
}
}
.padding(.horizontal, 16)
.padding(.bottom, 16)
.transition(.opacity.combined(with: .move(edge: .top)))
case .loaded, .idle:
// Normal insights display with staggered animation
VStack(spacing: 10) {
ForEach(Array(insights.enumerated()), id: \.element.id) { index, insight in
InsightCardView(
insight: insight,
textColor: textColor,
moodTint: moodTint,
imagePack: imagePack,
colorScheme: colorScheme
)
.transition(.asymmetric(
insertion: .opacity.combined(with: .scale(scale: 0.95)).combined(with: .offset(y: 10)),
removal: .opacity
))
.animation(
UIAccessibility.isReduceMotionEnabled ? nil :
.spring(response: 0.4, dampingFraction: 0.8)
.delay(Double(index) * 0.05),
value: insights.count
)
}
}
.padding(.horizontal, 16)
.padding(.bottom, 16)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
}
.background(
RoundedRectangle(cornerRadius: 16)
.fill(colorScheme == .dark ? Color(.systemGray6) : .white)
)
.padding(.horizontal)
.animation(UIAccessibility.isReduceMotionEnabled ? nil : .easeInOut(duration: 0.2), value: isExpanded)
}
}
// MARK: - Skeleton Loading View
struct InsightSkeletonView: View {
let colorScheme: ColorScheme
@State private var isAnimating = false
var body: some View {
HStack(alignment: .top, spacing: 14) {
// Icon placeholder
Circle()
.fill(Color.gray.opacity(0.3))
.frame(width: 44, height: 44)
// Text placeholders
VStack(alignment: .leading, spacing: 8) {
RoundedRectangle(cornerRadius: 4)
.fill(Color.gray.opacity(0.3))
.frame(width: 120, height: 16)
RoundedRectangle(cornerRadius: 4)
.fill(Color.gray.opacity(0.2))
.frame(maxWidth: .infinity)
.frame(height: 14)
RoundedRectangle(cornerRadius: 4)
.fill(Color.gray.opacity(0.2))
.frame(width: 180, height: 14)
}
Spacer()
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(colorScheme == .dark ? Color(.systemGray5) : Color(.systemGray6))
)
.opacity(isAnimating ? 0.6 : 1.0)
.animation(
UIAccessibility.isReduceMotionEnabled ? nil :
.easeInOut(duration: 0.8)
.repeatForever(autoreverses: true),
value: isAnimating
)
.onAppear {
if !UIAccessibility.isReduceMotionEnabled {
isAnimating = true
}
}
}
}
// MARK: - Insight Card View
struct InsightCardView: View {
let insight: Insight
let textColor: Color
let moodTint: MoodTints
let imagePack: MoodImages
let colorScheme: ColorScheme
private var accentColor: Color {
if let mood = insight.mood {
return moodTint.color(forMood: mood)
}
return .accentColor
}
var body: some View {
HStack(alignment: .top, spacing: 14) {
// Icon
ZStack {
Circle()
.fill(accentColor.opacity(0.15))
.frame(width: 44, height: 44)
if let mood = insight.mood {
imagePack.icon(forMood: mood)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 22, height: 22)
.foregroundColor(accentColor)
.accessibilityLabel(mood.strValue)
} else {
Image(systemName: insight.icon)
.font(.headline.weight(.semibold))
.foregroundColor(accentColor)
}
}
// Text Content
VStack(alignment: .leading, spacing: 4) {
Text(insight.title)
.font(.subheadline.weight(.semibold))
.foregroundColor(textColor)
Text(insight.description)
.font(.subheadline)
.foregroundColor(textColor.opacity(0.7))
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(colorScheme == .dark ? Color(.systemGray5) : Color(.systemGray6))
)
.accessibilityElement(children: .combine)
}
}
struct InsightsView_Previews: PreviewProvider {
static var previews: some View {
InsightsView()
.environmentObject(IAPManager())
}
}