Add AI mood report feature with PDF export for therapist sharing

Adds a Reports tab to the Insights view with date range selection, two report
types (Quick Summary / Detailed), Foundation Models AI generation with batched
concurrent processing, and clinical PDF export via WKWebView HTML rendering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-11 10:13:54 -05:00
parent 31fb2a7fe2
commit 19b4c8b05b
9 changed files with 2149 additions and 148 deletions

View File

@@ -7,6 +7,11 @@
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
@@ -18,161 +23,65 @@ struct InsightsView: View {
@StateObject private var viewModel = InsightsViewModel()
@EnvironmentObject var iapManager: IAPManager
@State private var showSubscriptionStore = false
@State private var selectedTab: InsightsTab = .insights
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)
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(.largeTitle)
.foregroundStyle(
LinearGradient(
colors: [.purple, .blue],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.font(.caption.weight(.medium))
Text("AI")
.font(.caption.weight(.semibold))
}
// 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
)
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
LinearGradient(
colors: [.purple, .blue],
startPoint: .leading,
endPoint: .trailing
)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.padding(.horizontal, 24)
Spacer()
)
.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
}
.background(theme.currentTheme.bg)
.accessibilityIdentifier(AccessibilityID.Paywall.insightsOverlay)
}
}
.sheet(isPresented: $showSubscriptionStore) {
@@ -188,6 +97,133 @@ struct InsightsView: View {
}
.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)
Spacer()
}
.background(theme.currentTheme.bg)
.accessibilityIdentifier(AccessibilityID.Paywall.insightsOverlay)
}
}
// MARK: - Insights Section View

View File

@@ -0,0 +1,346 @@
//
// ReportDateRangePicker.swift
// Reflect
//
// Calendar-based date range picker for AI mood reports.
// Ported from SportsTime DateRangePicker with Reflect theming.
//
import SwiftUI
struct ReportDateRangePicker: View {
@Binding var startDate: Date
@Binding var endDate: Date
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@Environment(\.colorScheme) private var colorScheme
@State private var displayedMonth: Date = Date()
@State private var selectionState: SelectionState = .none
enum SelectionState {
case none
case startSelected
case complete
}
private var textColor: Color { theme.currentTheme.labelColor }
private let calendar = Calendar.current
private let daysOfWeek = ["S", "M", "T", "W", "T", "F", "S"]
private let daysOfWeekFull = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
private var monthYearString: String {
let formatter = DateFormatter()
formatter.dateFormat = "MMMM yyyy"
return formatter.string(from: displayedMonth)
}
private var daysInMonth: [Date?] {
guard let monthInterval = calendar.dateInterval(of: .month, for: displayedMonth),
let monthFirstWeek = calendar.dateInterval(of: .weekOfMonth, for: monthInterval.start) else {
return []
}
var days: [Date?] = []
let startOfMonth = monthInterval.start
guard let endOfMonth = calendar.date(byAdding: .day, value: -1, to: monthInterval.end) else {
return []
}
var currentDate = monthFirstWeek.start
while currentDate <= endOfMonth || days.count % 7 != 0 {
if currentDate >= startOfMonth && currentDate <= endOfMonth {
days.append(currentDate)
} else if currentDate < startOfMonth {
days.append(nil)
} else if days.count % 7 != 0 {
days.append(nil)
} else {
break
}
guard let nextDate = calendar.date(byAdding: .day, value: 1, to: currentDate) else {
break
}
currentDate = nextDate
}
return days
}
private var selectedDayCount: Int {
let components = calendar.dateComponents([.day], from: startDate, to: endDate)
return (components.day ?? 0) + 1
}
var body: some View {
VStack(spacing: 16) {
selectedRangeSummary
monthNavigation
daysOfWeekHeader
calendarGrid
dayCountBadge
}
.accessibilityIdentifier(AccessibilityID.Reports.dateRangePicker)
.onAppear {
displayedMonth = calendar.startOfDay(for: startDate)
if endDate > startDate {
selectionState = .complete
}
}
}
// MARK: - Selected Range Summary
private var selectedRangeSummary: some View {
HStack(spacing: 16) {
VStack(alignment: .leading, spacing: 4) {
Text("START")
.font(.caption2)
.foregroundStyle(textColor.opacity(0.5))
Text(startDate.formatted(.dateTime.month(.abbreviated).day().year()))
.font(.body)
.foregroundColor(.accentColor)
}
.frame(maxWidth: .infinity, alignment: .leading)
Image(systemName: "arrow.right")
.font(.subheadline)
.foregroundStyle(textColor.opacity(0.5))
.accessibilityHidden(true)
VStack(alignment: .trailing, spacing: 4) {
Text("END")
.font(.caption2)
.foregroundStyle(textColor.opacity(0.5))
Text(endDate.formatted(.dateTime.month(.abbreviated).day().year()))
.font(.body)
.foregroundColor(.accentColor)
}
.frame(maxWidth: .infinity, alignment: .trailing)
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(colorScheme == .dark ? Color(.systemGray6) : .white)
)
}
// MARK: - Month Navigation
private var monthNavigation: some View {
HStack {
Button {
if UIAccessibility.isReduceMotionEnabled {
displayedMonth = calendar.date(byAdding: .month, value: -1, to: displayedMonth) ?? displayedMonth
} else {
withAnimation(.easeInOut(duration: 0.2)) {
displayedMonth = calendar.date(byAdding: .month, value: -1, to: displayedMonth) ?? displayedMonth
}
}
} label: {
Image(systemName: "chevron.left")
.font(.body)
.foregroundColor(.accentColor)
.frame(minWidth: 44, minHeight: 44)
.background(Color.accentColor.opacity(0.15))
.clipShape(Circle())
}
.accessibilityLabel("Previous month")
Spacer()
Text(monthYearString)
.font(.headline)
.foregroundStyle(textColor)
Spacer()
Button {
if UIAccessibility.isReduceMotionEnabled {
displayedMonth = calendar.date(byAdding: .month, value: 1, to: displayedMonth) ?? displayedMonth
} else {
withAnimation(.easeInOut(duration: 0.2)) {
displayedMonth = calendar.date(byAdding: .month, value: 1, to: displayedMonth) ?? displayedMonth
}
}
} label: {
Image(systemName: "chevron.right")
.font(.body)
.foregroundColor(.accentColor)
.frame(minWidth: 44, minHeight: 44)
.background(Color.accentColor.opacity(0.15))
.clipShape(Circle())
}
.accessibilityLabel("Next month")
.disabled(isDisplayingCurrentMonth)
}
}
private var isDisplayingCurrentMonth: Bool {
let now = Date()
return calendar.component(.month, from: displayedMonth) == calendar.component(.month, from: now)
&& calendar.component(.year, from: displayedMonth) == calendar.component(.year, from: now)
}
// MARK: - Days of Week Header
private var daysOfWeekHeader: some View {
HStack(spacing: 0) {
ForEach(Array(daysOfWeek.enumerated()), id: \.offset) { index, day in
Text(day)
.font(.caption)
.foregroundStyle(textColor.opacity(0.5))
.frame(maxWidth: .infinity)
.accessibilityLabel(daysOfWeekFull[index])
}
}
}
// MARK: - Calendar Grid
private var calendarGrid: some View {
let columns = Array(repeating: GridItem(.flexible(), spacing: 4), count: 7)
return LazyVGrid(columns: columns, spacing: 4) {
ForEach(Array(daysInMonth.enumerated()), id: \.offset) { _, date in
if let date = date {
ReportDayCell(
date: date,
isStart: calendar.isDate(date, inSameDayAs: startDate),
isEnd: calendar.isDate(date, inSameDayAs: endDate),
isInRange: isDateInRange(date),
isToday: calendar.isDateInToday(date),
isFuture: isFutureDate(date),
textColor: textColor,
onTap: { handleDateTap(date) }
)
} else {
Color.clear
.frame(height: 40)
}
}
}
}
// MARK: - Day Count Badge
private var dayCountBadge: some View {
HStack(spacing: 4) {
Image(systemName: "calendar.badge.clock")
.foregroundColor(.accentColor)
.accessibilityHidden(true)
Text("\(selectedDayCount) day\(selectedDayCount == 1 ? "" : "s") selected")
.font(.subheadline)
.foregroundStyle(textColor.opacity(0.6))
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, 4)
}
// MARK: - Helpers
private func isDateInRange(_ date: Date) -> Bool {
let start = calendar.startOfDay(for: startDate)
let end = calendar.startOfDay(for: endDate)
let current = calendar.startOfDay(for: date)
return current > start && current < end
}
private func isFutureDate(_ date: Date) -> Bool {
calendar.startOfDay(for: date) > calendar.startOfDay(for: Date())
}
private func handleDateTap(_ date: Date) {
let tappedDate = calendar.startOfDay(for: date)
let today = calendar.startOfDay(for: Date())
// Don't allow selecting dates after today
if tappedDate > today { return }
switch selectionState {
case .none, .complete:
startDate = date
endDate = date
selectionState = .startSelected
case .startSelected:
if date >= startDate {
endDate = date
} else {
endDate = startDate
startDate = date
}
selectionState = .complete
}
}
}
// MARK: - Report Day Cell
private struct ReportDayCell: View {
let date: Date
let isStart: Bool
let isEnd: Bool
let isInRange: Bool
let isToday: Bool
let isFuture: Bool
let textColor: Color
let onTap: () -> Void
@Environment(\.colorScheme) private var colorScheme
private let calendar = Calendar.current
private var dayNumber: String {
"\(calendar.component(.day, from: date))"
}
var body: some View {
Button(action: onTap) {
ZStack {
// Range highlight background
if isInRange || isStart || isEnd {
HStack(spacing: 0) {
Rectangle()
.fill(Color.accentColor.opacity(0.15))
.frame(maxWidth: .infinity)
.opacity(isStart && !isEnd ? 0 : 1)
.offset(x: isStart ? 20 : 0)
Rectangle()
.fill(Color.accentColor.opacity(0.15))
.frame(maxWidth: .infinity)
.opacity(isEnd && !isStart ? 0 : 1)
.offset(x: isEnd ? -20 : 0)
}
.opacity(isStart && isEnd ? 0 : 1)
}
// Day circle
ZStack {
if isStart || isEnd {
Circle()
.fill(Color.accentColor)
} else if isToday {
Circle()
.stroke(Color.accentColor, lineWidth: 2)
}
Text(dayNumber)
.font(.subheadline)
.foregroundStyle(
isFuture ? textColor.opacity(0.25) :
(isStart || isEnd) ? .white :
isToday ? .accentColor :
textColor
)
}
.frame(width: 36, height: 36)
}
}
.buttonStyle(.plain)
.disabled(isFuture)
.frame(height: 40)
}
}

View File

@@ -0,0 +1,86 @@
//
// ReportGeneratingView.swift
// Reflect
//
// Overlay shown during AI report generation with progress and cancel.
//
import SwiftUI
struct ReportGeneratingView: View {
let progress: Double
let message: String
let onCancel: () -> Void
@Environment(\.colorScheme) private var colorScheme
@State private var isPulsing = false
var body: some View {
ZStack {
// Semi-transparent background
Color.black.opacity(0.4)
.ignoresSafeArea()
VStack(spacing: 24) {
// Sparkles icon
Image(systemName: "sparkles")
.font(.system(size: 44))
.foregroundStyle(
LinearGradient(
colors: [.purple, .blue],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.scaleEffect(isPulsing ? 1.1 : 1.0)
.opacity(isPulsing ? 0.8 : 1.0)
.animation(
UIAccessibility.isReduceMotionEnabled ? nil :
.easeInOut(duration: 1.2).repeatForever(autoreverses: true),
value: isPulsing
)
// Progress bar
VStack(spacing: 8) {
ProgressView(value: progress)
.progressViewStyle(.linear)
.tint(
LinearGradient(
colors: [.purple, .blue],
startPoint: .leading,
endPoint: .trailing
)
)
.accessibilityIdentifier(AccessibilityID.Reports.progressView)
Text(message)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
// Cancel button
Button(role: .cancel) {
onCancel()
} label: {
Text("Cancel")
.font(.subheadline.weight(.medium))
.foregroundColor(.secondary)
}
.accessibilityIdentifier(AccessibilityID.Reports.cancelButton)
}
.padding(32)
.background(
RoundedRectangle(cornerRadius: 20)
.fill(colorScheme == .dark ? Color(.systemGray6) : .white)
.shadow(radius: 20)
)
.padding(.horizontal, 40)
}
.onAppear {
if !UIAccessibility.isReduceMotionEnabled {
isPulsing = true
}
}
}
}

View File

@@ -0,0 +1,381 @@
//
// ReportsView.swift
// Reflect
//
// AI-powered mood report generation with date range selection and PDF export.
//
import SwiftUI
struct ReportsView: View {
@StateObject private var viewModel = ReportsViewModel()
@EnvironmentObject var iapManager: IAPManager
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@Environment(\.colorScheme) private var colorScheme
@State private var showSubscriptionStore = false
private var textColor: Color { theme.currentTheme.labelColor }
var body: some View {
ZStack {
ScrollView {
VStack(spacing: 20) {
// Report type selector
reportTypeSelector
// Date range picker
ReportDateRangePicker(
startDate: $viewModel.startDate,
endDate: $viewModel.endDate
)
.padding(.horizontal)
// Entry count / validation
entryValidationCard
// AI unavailable warning
if !viewModel.isAIAvailable {
aiUnavailableCard
}
// Generate button
generateButton
// Report ready card
if viewModel.generationState == .completed {
reportReadyCard
}
// Error message
if case .failed(let message) = viewModel.generationState {
errorCard(message: message)
}
}
.padding(.vertical)
.padding(.bottom, 100)
}
.disabled(iapManager.shouldShowPaywall)
// Generating overlay
if viewModel.generationState == .generating {
ReportGeneratingView(
progress: viewModel.progressValue,
message: viewModel.progressMessage,
onCancel: { viewModel.cancelGeneration() }
)
}
// Paywall overlay
if iapManager.shouldShowPaywall {
paywallOverlay
}
}
.sheet(isPresented: $viewModel.showShareSheet) {
if let url = viewModel.exportedPDFURL {
ExportShareSheet(items: [url])
}
}
.sheet(isPresented: $showSubscriptionStore) {
ReflectSubscriptionStoreView(source: "reports_gate")
}
.confirmationDialog(
String(localized: "Privacy Notice"),
isPresented: $viewModel.showPrivacyConfirmation,
titleVisibility: .visible
) {
Button(String(localized: "Share Report")) {
viewModel.exportPDF()
}
Button(String(localized: "Cancel"), role: .cancel) {}
} message: {
Text("This report contains your personal mood data and journal notes. Only share it with people you trust.")
}
.onAppear {
AnalyticsManager.shared.trackScreen(.reports)
}
}
// MARK: - Report Type Selector
private var reportTypeSelector: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Report Type")
.font(.headline)
.foregroundColor(textColor)
.padding(.horizontal)
ForEach(ReportType.allCases, id: \.self) { type in
Button {
viewModel.reportType = type
} label: {
HStack(spacing: 16) {
Image(systemName: type.icon)
.font(.title2)
.frame(width: 44, height: 44)
.background(viewModel.reportType == type ? Color.accentColor.opacity(0.15) : Color(.systemGray5))
.foregroundColor(viewModel.reportType == type ? .accentColor : .gray)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 2) {
Text(type.rawValue)
.font(.headline)
.foregroundColor(textColor)
Text(type.description)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
if viewModel.reportType == type {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.accentColor)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 14)
.fill(colorScheme == .dark ? Color(.systemGray6) : .white)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(viewModel.reportType == type ? Color.accentColor : Color.clear, lineWidth: 2)
)
)
}
.buttonStyle(.plain)
.accessibilityIdentifier(
type == .quickSummary ? AccessibilityID.Reports.quickSummaryButton : AccessibilityID.Reports.detailedReportButton
)
.padding(.horizontal)
}
}
}
// MARK: - Entry Validation Card
private var entryValidationCard: some View {
HStack(spacing: 12) {
Image(systemName: viewModel.validEntryCount >= 3 ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
.foregroundColor(viewModel.validEntryCount >= 3 ? .green : .orange)
VStack(alignment: .leading, spacing: 2) {
Text("\(viewModel.validEntryCount) mood entries in range")
.font(.subheadline.weight(.medium))
.foregroundColor(textColor)
if viewModel.validEntryCount < 3 {
Text("At least 3 entries required to generate a report")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
}
.padding()
.background(
RoundedRectangle(cornerRadius: 14)
.fill(colorScheme == .dark ? Color(.systemGray6) : .white)
)
.padding(.horizontal)
.accessibilityIdentifier(AccessibilityID.Reports.minimumEntriesWarning)
}
// MARK: - AI Unavailable Card
private var aiUnavailableCard: some View {
HStack(spacing: 12) {
Image(systemName: "brain.head.profile")
.foregroundColor(.orange)
VStack(alignment: .leading, spacing: 2) {
Text("Apple Intelligence Required")
.font(.subheadline.weight(.medium))
.foregroundColor(textColor)
Text("AI report generation requires Apple Intelligence to be enabled in Settings.")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding()
.background(
RoundedRectangle(cornerRadius: 14)
.fill(Color.orange.opacity(0.1))
)
.padding(.horizontal)
}
// MARK: - Generate Button
private var generateButton: some View {
Button {
viewModel.generateReport()
} label: {
HStack(spacing: 8) {
Image(systemName: "sparkles")
Text("Generate Report")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding()
.background(viewModel.canGenerate ? Color.accentColor : Color.gray)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.disabled(!viewModel.canGenerate)
.padding(.horizontal)
.accessibilityIdentifier(AccessibilityID.Reports.generateButton)
}
// MARK: - Report Ready Card
private var reportReadyCard: some View {
VStack(spacing: 16) {
HStack(spacing: 12) {
Image(systemName: "checkmark.circle.fill")
.font(.title2)
.foregroundColor(.green)
VStack(alignment: .leading, spacing: 2) {
Text("Report Ready")
.font(.headline)
.foregroundColor(textColor)
Text("\(viewModel.reportType.rawValue) with \(viewModel.validEntryCount) entries")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
}
Button {
viewModel.showPrivacyConfirmation = true
} label: {
HStack(spacing: 8) {
Image(systemName: "square.and.arrow.up")
Text("Export PDF")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding()
.background(Color.accentColor)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.accessibilityIdentifier(AccessibilityID.Reports.exportButton)
}
.padding()
.background(
RoundedRectangle(cornerRadius: 14)
.fill(colorScheme == .dark ? Color(.systemGray6) : .white)
)
.padding(.horizontal)
}
// MARK: - Error Card
private func errorCard(message: String) -> some View {
HStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
VStack(alignment: .leading, spacing: 2) {
Text("Generation Failed")
.font(.subheadline.weight(.medium))
.foregroundColor(textColor)
Text(message)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button("Retry") {
viewModel.generateReport()
}
.font(.subheadline.weight(.medium))
}
.padding()
.background(
RoundedRectangle(cornerRadius: 14)
.fill(Color.red.opacity(0.1))
)
.padding(.horizontal)
}
// MARK: - Paywall Overlay
private var paywallOverlay: some View {
VStack(spacing: 24) {
Spacer()
ZStack {
Circle()
.fill(
LinearGradient(
colors: [.purple.opacity(0.2), .blue.opacity(0.2)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 100, height: 100)
Image(systemName: "doc.text.magnifyingglass")
.font(.largeTitle)
.foregroundStyle(
LinearGradient(
colors: [.purple, .blue],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
VStack(spacing: 12) {
Text("Unlock AI Reports")
.font(.title2.weight(.bold))
.foregroundColor(textColor)
.multilineTextAlignment(.center)
Text("Generate clinical-quality mood reports to share with your therapist or track your progress over time.")
.font(.body)
.foregroundColor(textColor.opacity(0.7))
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
Button {
showSubscriptionStore = true
} label: {
HStack {
Image(systemName: "sparkles")
Text("Unlock Reports")
}
.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)
}
}

View File

@@ -0,0 +1,530 @@
//
// ReportsViewModel.swift
// Reflect
//
// ViewModel for AI mood report generation and PDF export.
//
import Foundation
import SwiftUI
import FoundationModels
// MARK: - Generation State
enum ReportGenerationState: Equatable {
case idle
case generating
case completed
case failed(String)
}
// MARK: - ViewModel
@MainActor
class ReportsViewModel: ObservableObject {
// MARK: - Published State
@Published var startDate: Date = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()
@Published var endDate: Date = Date()
@Published var reportType: ReportType = .quickSummary
@Published var generationState: ReportGenerationState = .idle
@Published var progressValue: Double = 0.0
@Published var progressMessage: String = ""
@Published var generatedReport: MoodReport?
@Published var exportedPDFURL: URL?
@Published var showShareSheet: Bool = false
@Published var showPrivacyConfirmation: Bool = false
@Published var errorMessage: String?
@Published var isAIAvailable: Bool = false
// MARK: - Computed Properties
var entriesInRange: [MoodEntryModel] {
let allEntries = DataController.shared.getData(
startDate: Calendar.current.startOfDay(for: startDate),
endDate: Calendar.current.startOfDay(for: Calendar.current.date(byAdding: .day, value: 1, to: endDate) ?? endDate),
includedDays: [1, 2, 3, 4, 5, 6, 7]
)
return allEntries.filter { ![.missing, .placeholder].contains($0.mood) }
}
var validEntryCount: Int { entriesInRange.count }
var canGenerate: Bool {
validEntryCount >= 3 && isAIAvailable
}
var daySpan: Int {
let components = Calendar.current.dateComponents([.day], from: startDate, to: endDate)
return (components.day ?? 0) + 1
}
// MARK: - Dependencies
private var insightService: Any?
private let summarizer = MoodDataSummarizer()
private let pdfGenerator = ReportPDFGenerator()
private let calendar = Calendar.current
private var generationTask: Task<Void, Never>?
// MARK: - Initialization
init() {
if #available(iOS 26, *) {
let service = FoundationModelsInsightService()
insightService = service
isAIAvailable = service.isAvailable
} else {
insightService = nil
isAIAvailable = false
}
}
deinit {
generationTask?.cancel()
}
// MARK: - Report Generation
func generateReport() {
generationTask?.cancel()
generationState = .generating
progressValue = 0.0
progressMessage = String(localized: "Preparing data...")
errorMessage = nil
generatedReport = nil
generationTask = Task {
do {
let entries = entriesInRange
let reportEntries = entries.map { ReportEntry(from: $0) }
let report: MoodReport
switch reportType {
case .quickSummary:
report = try await generateQuickSummary(entries: entries, reportEntries: reportEntries)
case .detailed:
report = try await generateDetailedReport(entries: entries, reportEntries: reportEntries)
}
guard !Task.isCancelled else { return }
generatedReport = report
generationState = .completed
progressValue = 1.0
progressMessage = String(localized: "Report ready")
AnalyticsManager.shared.track(.reportGenerated(
type: reportType.rawValue,
entryCount: validEntryCount,
daySpan: daySpan
))
} catch {
guard !Task.isCancelled else { return }
generationState = .failed(error.localizedDescription)
errorMessage = error.localizedDescription
AnalyticsManager.shared.track(.reportGenerationFailed(error: error.localizedDescription))
}
}
}
func cancelGeneration() {
generationTask?.cancel()
generationState = .idle
progressValue = 0.0
progressMessage = ""
AnalyticsManager.shared.track(.reportCancelled)
}
// MARK: - PDF Export
func exportPDF() {
guard let report = generatedReport else { return }
Task {
do {
let url = try await pdfGenerator.generatePDF(from: report)
exportedPDFURL = url
showShareSheet = true
AnalyticsManager.shared.track(.reportExported(
type: reportType.rawValue,
entryCount: validEntryCount
))
} catch {
errorMessage = error.localizedDescription
generationState = .failed(error.localizedDescription)
}
}
}
// MARK: - Quick Summary Generation
private func generateQuickSummary(entries: [MoodEntryModel], reportEntries: [ReportEntry]) async throws -> MoodReport {
let overview = buildOverview(entries: entries)
let weeks = splitIntoWeeks(entries: reportEntries)
progressValue = 0.3
progressMessage = String(localized: "Generating AI summary...")
var quickSummaryText: String?
if #available(iOS 26, *) {
let summary = summarizer.summarize(entries: entries, periodName: "selected period")
let promptData = summarizer.toPromptString(summary)
let session = LanguageModelSession(instructions: clinicalSystemInstructions)
let prompt = """
Analyze this mood data and generate a clinical summary report:
\(promptData)
Generate a factual, third-person clinical summary suitable for sharing with a therapist.
"""
do {
let response = try await session.respond(to: prompt, generating: AIQuickSummaryResponse.self)
guard !Task.isCancelled else { throw CancellationError() }
var text = response.content.summary
if !response.content.keyObservations.isEmpty {
text += "\n\nKey Observations:\n" + response.content.keyObservations.map { "- \($0)" }.joined(separator: "\n")
}
if !response.content.recommendations.isEmpty {
text += "\n\nRecommendations:\n" + response.content.recommendations.map { "- \($0)" }.joined(separator: "\n")
}
quickSummaryText = text
} catch is CancellationError {
throw CancellationError()
} catch {
quickSummaryText = "Summary unavailable: \(error.localizedDescription)"
}
}
progressValue = 0.9
let monthlySummaries = buildMonthlySummaries(entries: reportEntries)
let yearlySummaries = buildYearlySummaries(entries: reportEntries)
return MoodReport(
reportType: .quickSummary,
generatedAt: Date(),
overview: overview,
weeks: weeks,
monthlySummaries: monthlySummaries,
yearlySummaries: yearlySummaries,
quickSummary: quickSummaryText
)
}
// MARK: - Detailed Report Generation
private func generateDetailedReport(entries: [MoodEntryModel], reportEntries: [ReportEntry]) async throws -> MoodReport {
let overview = buildOverview(entries: entries)
var weeks = splitIntoWeeks(entries: reportEntries)
var monthlySummaries = buildMonthlySummaries(entries: reportEntries)
var yearlySummaries = buildYearlySummaries(entries: reportEntries)
let totalSections = weeks.count + monthlySummaries.count + yearlySummaries.count
var completedSections = 0
// Generate weekly AI summaries batched at 4 concurrent
if #available(iOS 26, *) {
let batchSize = 4
for batchStart in stride(from: 0, to: weeks.count, by: batchSize) {
guard !Task.isCancelled else { throw CancellationError() }
let batchEnd = min(batchStart + batchSize, weeks.count)
let batchIndices = batchStart..<batchEnd
await withTaskGroup(of: (Int, String?).self) { group in
for index in batchIndices {
group.addTask { @MainActor in
let week = weeks[index]
let summary = await self.generateWeeklySummary(week: week)
return (index, summary)
}
}
for await (index, summary) in group {
weeks[index].aiSummary = summary
completedSections += 1
progressValue = Double(completedSections) / Double(totalSections)
progressMessage = String(localized: "Generating weekly summary \(completedSections) of \(weeks.count)...")
}
}
}
// Generate monthly AI summaries concurrent
guard !Task.isCancelled else { throw CancellationError() }
progressMessage = String(localized: "Generating monthly summaries...")
await withTaskGroup(of: (Int, String?).self) { group in
for (index, monthSummary) in monthlySummaries.enumerated() {
group.addTask { @MainActor in
let summary = await self.generateMonthlySummary(month: monthSummary, allEntries: reportEntries)
return (index, summary)
}
}
for await (index, summary) in group {
monthlySummaries[index].aiSummary = summary
completedSections += 1
progressValue = Double(completedSections) / Double(totalSections)
}
}
// Generate yearly AI summaries concurrent
guard !Task.isCancelled else { throw CancellationError() }
if !yearlySummaries.isEmpty {
progressMessage = String(localized: "Generating yearly summaries...")
await withTaskGroup(of: (Int, String?).self) { group in
for (index, yearSummary) in yearlySummaries.enumerated() {
group.addTask { @MainActor in
let summary = await self.generateYearlySummary(year: yearSummary, allEntries: reportEntries)
return (index, summary)
}
}
for await (index, summary) in group {
yearlySummaries[index].aiSummary = summary
completedSections += 1
progressValue = Double(completedSections) / Double(totalSections)
}
}
}
}
return MoodReport(
reportType: .detailed,
generatedAt: Date(),
overview: overview,
weeks: weeks,
monthlySummaries: monthlySummaries,
yearlySummaries: yearlySummaries,
quickSummary: nil
)
}
// MARK: - AI Summary Generators
@available(iOS 26, *)
private func generateWeeklySummary(week: ReportWeek) async -> String? {
let session = LanguageModelSession(instructions: clinicalSystemInstructions)
let moodList = week.entries.sorted(by: { $0.date < $1.date }).map { entry in
let day = entry.date.formatted(.dateTime.weekday(.abbreviated))
let mood = entry.mood.widgetDisplayName
let notes = entry.notes ?? "no notes"
return "\(day): \(mood) (\(notes))"
}.joined(separator: "\n")
let prompt = """
Summarize this week's mood data (Week \(week.weekNumber)):
\(moodList)
Average mood: \(String(format: "%.1f", weekAverage(week)))/5
"""
do {
let response = try await session.respond(to: prompt, generating: AIWeeklySummary.self)
return response.content.summary
} catch {
return "Summary unavailable"
}
}
@available(iOS 26, *)
private func generateMonthlySummary(month: ReportMonthSummary, allEntries: [ReportEntry]) async -> String? {
let session = LanguageModelSession(instructions: clinicalSystemInstructions)
let monthEntries = allEntries.filter {
calendar.component(.month, from: $0.date) == month.month &&
calendar.component(.year, from: $0.date) == month.year
}
let moodDist = Dictionary(grouping: monthEntries, by: { $0.mood.widgetDisplayName })
.mapValues { $0.count }
.sorted { $0.value > $1.value }
.map { "\($0.key): \($0.value)" }
.joined(separator: ", ")
let prompt = """
Summarize this month's mood data (\(month.title)):
\(month.entryCount) entries, average mood: \(String(format: "%.1f", month.averageMood))/5
Distribution: \(moodDist)
"""
do {
let response = try await session.respond(to: prompt, generating: AIMonthSummary.self)
return response.content.summary
} catch {
return "Summary unavailable"
}
}
@available(iOS 26, *)
private func generateYearlySummary(year: ReportYearSummary, allEntries: [ReportEntry]) async -> String? {
let session = LanguageModelSession(instructions: clinicalSystemInstructions)
let yearEntries = allEntries.filter { calendar.component(.year, from: $0.date) == year.year }
let monthlyAvgs = Dictionary(grouping: yearEntries) { calendar.component(.month, from: $0.date) }
.sorted { $0.key < $1.key }
.map { (month, entries) in
let avg = Double(entries.reduce(0) { $0 + Int($1.mood.rawValue) + 1 }) / Double(entries.count)
let formatter = DateFormatter()
formatter.dateFormat = "MMM"
var comps = DateComponents()
comps.month = month
let date = calendar.date(from: comps) ?? Date()
return "\(formatter.string(from: date)): \(String(format: "%.1f", avg))"
}
.joined(separator: ", ")
let prompt = """
Summarize this year's mood data (\(year.year)):
\(year.entryCount) entries, average mood: \(String(format: "%.1f", year.averageMood))/5
Monthly averages: \(monthlyAvgs)
"""
do {
let response = try await session.respond(to: prompt, generating: AIYearSummary.self)
return response.content.summary
} catch {
return "Summary unavailable"
}
}
// MARK: - Data Building Helpers
private func buildOverview(entries: [MoodEntryModel]) -> ReportOverviewStats {
let validEntries = entries.filter { ![.missing, .placeholder].contains($0.mood) }
let total = validEntries.count
let avgMood = total > 0 ? Double(validEntries.reduce(0) { $0 + Int($1.moodValue) + 1 }) / Double(total) : 0
var distribution: [Mood: Int] = [:]
for entry in validEntries {
distribution[entry.mood, default: 0] += 1
}
let sorted = validEntries.sorted { $0.forDate < $1.forDate }
let trend: String
if sorted.count >= 4 {
let half = sorted.count / 2
let firstAvg = Double(sorted.prefix(half).reduce(0) { $0 + Int($1.moodValue) + 1 }) / Double(half)
let secondAvg = Double(sorted.suffix(half).reduce(0) { $0 + Int($1.moodValue) + 1 }) / Double(half)
let diff = secondAvg - firstAvg
trend = diff > 0.5 ? "Improving" : diff < -0.5 ? "Declining" : "Stable"
} else {
trend = "Stable"
}
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium
let rangeStr: String
if let first = sorted.first, let last = sorted.last {
rangeStr = "\(dateFormatter.string(from: first.forDate)) - \(dateFormatter.string(from: last.forDate))"
} else {
rangeStr = "No data"
}
return ReportOverviewStats(
totalEntries: total,
averageMood: avgMood,
moodDistribution: distribution,
trend: trend,
dateRange: rangeStr
)
}
private func splitIntoWeeks(entries: [ReportEntry]) -> [ReportWeek] {
let sorted = entries.sorted { $0.date < $1.date }
guard let firstDate = sorted.first?.date else { return [] }
var weeks: [ReportWeek] = []
var weekStart = calendar.startOfDay(for: firstDate)
var weekNumber = 1
while weekStart <= (sorted.last?.date ?? Date()) {
let weekEnd = calendar.date(byAdding: .day, value: 6, to: weekStart) ?? weekStart
let weekEntries = sorted.filter { entry in
let entryDay = calendar.startOfDay(for: entry.date)
return entryDay >= weekStart && entryDay <= weekEnd
}
if !weekEntries.isEmpty {
weeks.append(ReportWeek(
weekNumber: weekNumber,
startDate: weekStart,
endDate: weekEnd,
entries: weekEntries
))
}
weekStart = calendar.date(byAdding: .day, value: 7, to: weekStart) ?? weekStart
weekNumber += 1
}
return weeks
}
private func buildMonthlySummaries(entries: [ReportEntry]) -> [ReportMonthSummary] {
let grouped = Dictionary(grouping: entries) { entry in
let month = calendar.component(.month, from: entry.date)
let year = calendar.component(.year, from: entry.date)
return "\(year)-\(month)"
}
return grouped.map { (key, monthEntries) in
let components = key.split(separator: "-")
let year = Int(components[0]) ?? 0
let month = Int(components[1]) ?? 0
let avg = Double(monthEntries.reduce(0) { $0 + Int($1.mood.rawValue) + 1 }) / Double(monthEntries.count)
return ReportMonthSummary(
month: month,
year: year,
entryCount: monthEntries.count,
averageMood: avg
)
}
.sorted { ($0.year, $0.month) < ($1.year, $1.month) }
}
private func buildYearlySummaries(entries: [ReportEntry]) -> [ReportYearSummary] {
let grouped = Dictionary(grouping: entries) { calendar.component(.year, from: $0.date) }
guard grouped.count > 1 else { return [] } // Only generate if range spans multiple years
return grouped.map { (year, yearEntries) in
let avg = Double(yearEntries.reduce(0) { $0 + Int($1.mood.rawValue) + 1 }) / Double(yearEntries.count)
return ReportYearSummary(year: year, entryCount: yearEntries.count, averageMood: avg)
}
.sorted { $0.year < $1.year }
}
private func weekAverage(_ week: ReportWeek) -> Double {
let total = week.entries.reduce(0) { $0 + Int($1.mood.rawValue) + 1 }
return week.entries.isEmpty ? 0 : Double(total) / Double(week.entries.count)
}
// MARK: - Clinical System Instructions
private var clinicalSystemInstructions: String {
let languageCode = Locale.current.language.languageCode?.identifier ?? "en"
return """
You are a clinical mood data analyst generating a professional mood report. \
Use third-person perspective (e.g., "The individual", "The subject"). \
Be factual, neutral, and objective. Do not use casual language, emojis, or personality-driven tone. \
Reference specific data points and patterns. \
This report may be shared with a therapist or healthcare professional. \
Generate all text in the language with code: \(languageCode).
"""
}
}