Audit found ~50+ interactive elements (buttons, toggles, pickers, alerts, links) missing accessibility identifiers across 13 view files. Added centralized ID definitions and applied them to every entry detail button, guided reflection control, settings toggle, paywall unlock button, subscription/IAP button, lock screen control, and photo action dialog.
469 lines
16 KiB
Swift
469 lines
16 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 {
|
|
@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
|
|
@State private var selectedTab: InsightsTab = .insights
|
|
|
|
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))
|
|
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
|
|
}
|
|
}
|
|
}
|
|
.sheet(isPresented: $showSubscriptionStore) {
|
|
ReflectSubscriptionStoreView(source: "insights_gate")
|
|
}
|
|
.background(
|
|
theme.currentTheme.bg
|
|
.edgesIgnoringSafeArea(.all)
|
|
)
|
|
.onAppear {
|
|
AnalyticsManager.shared.trackScreen(.insights)
|
|
viewModel.generateInsights()
|
|
}
|
|
.padding(.top)
|
|
}
|
|
|
|
// MARK: - Insights Content
|
|
|
|
private var insightsContent: some View {
|
|
ScrollView {
|
|
VStack(spacing: 20) {
|
|
// 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)
|
|
}
|
|
|
|
// 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)
|
|
.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)
|
|
.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)
|
|
.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())
|
|
}
|
|
}
|