Set all three loading states to .loading upfront before entering the task group, and remove .idle from the modal visibility condition. This prevents the overlay from staying visible when tasks complete at different rates while others remain in .idle state. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
652 lines
23 KiB
Swift
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 })
|
|
}
|
|
|
|
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())
|
|
}
|
|
}
|