Files
Reflect/Shared/Views/InsightsView/InsightsView.swift
Trey t 0442eab1f8 Rebrand entire project from Feels to Reflect
Complete rename across all bundle IDs, App Groups, CloudKit containers,
StoreKit product IDs, data store filenames, URL schemes, logger subsystems,
Swift identifiers, user-facing strings (7 languages), file names, directory
names, Xcode project, schemes, assets, and documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:47:16 -06:00

432 lines
16 KiB
Swift

//
// InsightsView.swift
// Reflect
//
// Created by Claude Code on 12/9/24.
//
import SwiftUI
struct InsightsView: View {
@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
private var textColor: Color { theme.currentTheme.labelColor }
@StateObject private var viewModel = InsightsViewModel()
@EnvironmentObject var iapManager: IAPManager
@State private var showSubscriptionStore = false
var body: some View {
ZStack {
ScrollView {
VStack(spacing: 20) {
// 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))
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)
// 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)
}
.refreshable {
viewModel.refreshInsights()
// Small delay to show refresh animation
try? await Task.sleep(nanoseconds: 500_000_000)
}
.disabled(iapManager.shouldShowPaywall)
if iapManager.shouldShowPaywall {
// Premium insights prompt
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)
.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")
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)
Spacer()
}
.background(theme.currentTheme.bg)
.accessibilityIdentifier(AccessibilityID.Paywall.insightsOverlay)
}
}
.sheet(isPresented: $showSubscriptionStore) {
ReflectSubscriptionStoreView(source: "insights_gate")
}
.background(
theme.currentTheme.bg
.edgesIgnoringSafeArea(.all)
)
.onAppear {
AnalyticsManager.shared.trackScreen(.insights)
viewModel.generateInsights()
}
.padding(.top)
}
}
// 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)
.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())
}
}