Files
Reflect/Shared/Views/InsightsView/InsightsViewModel.swift
Trey t 920aaee35c Add premium features and reorganize Settings tab
Premium Features:
- Journal notes and photo attachments for mood entries
- Data export (CSV and PDF reports)
- Privacy lock with Face ID/Touch ID
- Apple Health integration for mood correlation
- 4 new personality packs (Motivational Coach, Zen Master, Best Friend, Data Analyst)

Settings Tab Reorganization:
- Combined Customize and Settings into single tab with segmented control
- Added upgrade banner with trial countdown above segment
- "Why Upgrade?" sheet showing all premium benefits
- Subscribe button opens improved StoreKit 2 subscription view

UI Improvements:
- Enhanced subscription store with feature highlights
- Entry detail view for viewing/editing notes and photos
- Removed duplicate subscription banners from tab content

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 12:22:06 -06:00

189 lines
6.2 KiB
Swift

//
// InsightsViewModel.swift
// Feels
//
// Created by Claude Code on 12/9/24.
//
import Foundation
import SwiftUI
/// Represents a single insight to display in the UI
struct Insight: Identifiable {
let id = UUID()
let icon: String
let title: String
let description: String
let mood: Mood?
}
/// Loading state for each insight section
enum InsightLoadingState: Equatable {
case idle
case loading
case loaded
case error(String)
}
/// ViewModel for the Insights tab - uses Apple Foundation Models for AI-powered insights
@MainActor
class InsightsViewModel: ObservableObject {
// MARK: - Published Properties
@Published var monthInsights: [Insight] = []
@Published var yearInsights: [Insight] = []
@Published var allTimeInsights: [Insight] = []
@Published var monthLoadingState: InsightLoadingState = .idle
@Published var yearLoadingState: InsightLoadingState = .idle
@Published var allTimeLoadingState: InsightLoadingState = .idle
@Published var isAIAvailable: Bool = false
// MARK: - Dependencies
private let insightService = FoundationModelsInsightService()
private let healthService = HealthService.shared
private let calendar = Calendar.current
// MARK: - Initialization
init() {
isAIAvailable = insightService.isAvailable
}
// MARK: - Public Methods
/// Generate insights for all time periods
func generateInsights() {
Task {
await generateAllInsights()
}
}
/// Force refresh all insights (invalidates cache)
func refreshInsights() {
insightService.invalidateCache()
generateInsights()
}
// MARK: - Private Methods
private func generateAllInsights() async {
let now = Date()
// Get date ranges
let monthStart = calendar.date(from: calendar.dateComponents([.year, .month], from: now))!
let yearStart = calendar.date(from: calendar.dateComponents([.year], from: now))!
let allTimeStart = Date(timeIntervalSince1970: 0)
// Fetch entries for each period
let monthEntries = DataController.shared.getData(startDate: monthStart, endDate: now, includedDays: [1, 2, 3, 4, 5, 6, 7])
let yearEntries = DataController.shared.getData(startDate: yearStart, endDate: now, includedDays: [1, 2, 3, 4, 5, 6, 7])
let allTimeEntries = DataController.shared.getData(startDate: allTimeStart, endDate: now, includedDays: [1, 2, 3, 4, 5, 6, 7])
// Generate insights concurrently for all three periods
await withTaskGroup(of: Void.self) { group in
group.addTask { @MainActor in
await self.generatePeriodInsights(
entries: monthEntries,
periodName: "this month",
updateState: { self.monthLoadingState = $0 },
updateInsights: { self.monthInsights = $0 }
)
}
group.addTask { @MainActor in
await self.generatePeriodInsights(
entries: yearEntries,
periodName: "this year",
updateState: { self.yearLoadingState = $0 },
updateInsights: { self.yearInsights = $0 }
)
}
group.addTask { @MainActor in
await self.generatePeriodInsights(
entries: allTimeEntries,
periodName: "all time",
updateState: { self.allTimeLoadingState = $0 },
updateInsights: { self.allTimeInsights = $0 }
)
}
}
}
private func generatePeriodInsights(
entries: [MoodEntryModel],
periodName: String,
updateState: @escaping (InsightLoadingState) -> Void,
updateInsights: @escaping ([Insight]) -> Void
) async {
// Filter valid entries
let validEntries = entries.filter { ![.missing, .placeholder].contains($0.mood) }
// Handle empty data case
guard !validEntries.isEmpty else {
updateInsights([Insight(
icon: "questionmark.circle",
title: "No Data Yet",
description: "Start logging your moods to see insights for \(periodName).",
mood: nil
)])
updateState(.loaded)
return
}
// Check if AI is available
guard isAIAvailable else {
updateInsights([Insight(
icon: "brain.head.profile",
title: "AI Unavailable",
description: "Apple Intelligence is required for personalized insights. Please enable it in Settings.",
mood: nil
)])
updateState(.error("AI not available"))
return
}
updateState(.loading)
// Fetch health data if enabled
var healthCorrelations: [HealthCorrelation] = []
if healthService.isEnabled && healthService.isAuthorized {
let healthData = await healthService.fetchHealthData(for: validEntries)
let correlations = healthService.analyzeCorrelations(entries: validEntries, healthData: healthData)
// Convert to HealthCorrelation format
healthCorrelations = correlations.map {
HealthCorrelation(
metric: $0.metric,
insight: $0.insight,
correlation: $0.correlation
)
}
}
do {
let insights = try await insightService.generateInsights(
for: validEntries,
periodName: periodName,
count: 5,
healthCorrelations: healthCorrelations
)
updateInsights(insights)
updateState(.loaded)
} catch {
// On error, provide a helpful message
updateInsights([Insight(
icon: "exclamationmark.triangle",
title: "Insights Unavailable",
description: "Unable to generate AI insights right now. Please try again later.",
mood: nil
)])
updateState(.error(error.localizedDescription))
}
}
}