diff --git a/Shared/AccessibilityIdentifiers.swift b/Shared/AccessibilityIdentifiers.swift
index 110b838..9ba20ff 100644
--- a/Shared/AccessibilityIdentifiers.swift
+++ b/Shared/AccessibilityIdentifiers.swift
@@ -153,6 +153,20 @@ enum AccessibilityID {
static let skipButton = "onboarding_skip_button"
}
+ // MARK: - Reports
+ enum Reports {
+ static let segmentedPicker = "reports_segmented_picker"
+ static let dateRangePicker = "reports_date_range_picker"
+ static let quickSummaryButton = "reports_quick_summary_button"
+ static let detailedReportButton = "reports_detailed_report_button"
+ static let generateButton = "reports_generate_button"
+ static let progressView = "reports_progress_view"
+ static let cancelButton = "reports_cancel_button"
+ static let exportButton = "reports_export_button"
+ static let privacyConfirmation = "reports_privacy_confirmation"
+ static let minimumEntriesWarning = "reports_minimum_entries_warning"
+ }
+
// MARK: - Common
enum Common {
static let lockScreen = "lock_screen"
diff --git a/Shared/Analytics.swift b/Shared/Analytics.swift
index ea3ccd3..b153e53 100644
--- a/Shared/Analytics.swift
+++ b/Shared/Analytics.swift
@@ -368,6 +368,7 @@ extension AnalyticsManager {
case specialThanks = "special_thanks"
case reminderTimePicker = "reminder_time_picker"
case exportView = "export_view"
+ case reports = "reports"
}
}
@@ -463,6 +464,12 @@ extension AnalyticsManager {
// MARK: Sharing
case shareTemplateViewed(template: String)
+ // MARK: Reports
+ case reportGenerated(type: String, entryCount: Int, daySpan: Int)
+ case reportExported(type: String, entryCount: Int)
+ case reportGenerationFailed(error: String)
+ case reportCancelled
+
// MARK: Error
case storageFallbackActivated
@@ -627,6 +634,16 @@ extension AnalyticsManager {
case .shareTemplateViewed(let template):
return ("share_template_viewed", ["template": template])
+ // Reports
+ case .reportGenerated(let type, let entryCount, let daySpan):
+ return ("report_generated", ["type": type, "entry_count": entryCount, "day_span": daySpan])
+ case .reportExported(let type, let entryCount):
+ return ("report_exported", ["type": type, "entry_count": entryCount])
+ case .reportGenerationFailed(let error):
+ return ("report_generation_failed", ["error": error])
+ case .reportCancelled:
+ return ("report_cancelled", nil)
+
// Error
case .storageFallbackActivated:
return ("storage_fallback_activated", nil)
diff --git a/Shared/Models/AIReport.swift b/Shared/Models/AIReport.swift
new file mode 100644
index 0000000..a8c5b42
--- /dev/null
+++ b/Shared/Models/AIReport.swift
@@ -0,0 +1,146 @@
+//
+// AIReport.swift
+// Reflect
+//
+// Data models for AI-generated mood reports with PDF export.
+//
+
+import Foundation
+import FoundationModels
+
+// MARK: - Report Type
+
+enum ReportType: String, CaseIterable {
+ case quickSummary = "Quick Summary"
+ case detailed = "Detailed Report"
+
+ var icon: String {
+ switch self {
+ case .quickSummary: return "doc.text"
+ case .detailed: return "doc.text.magnifyingglass"
+ }
+ }
+
+ var description: String {
+ switch self {
+ case .quickSummary: return "AI overview of your mood patterns"
+ case .detailed: return "Week-by-week analysis with full data"
+ }
+ }
+}
+
+// MARK: - Report Entry (decoupled from SwiftData)
+
+struct ReportEntry {
+ let date: Date
+ let mood: Mood
+ let notes: String?
+ let weather: WeatherData?
+
+ init(from model: MoodEntryModel) {
+ self.date = model.forDate
+ self.mood = model.mood
+ self.notes = model.notes
+ self.weather = model.weatherJSON.flatMap { WeatherData.decode(from: $0) }
+ }
+}
+
+// MARK: - Report Overview Stats
+
+struct ReportOverviewStats {
+ let totalEntries: Int
+ let averageMood: Double
+ let moodDistribution: [Mood: Int]
+ let trend: String
+ let dateRange: String
+}
+
+// MARK: - Report Week
+
+struct ReportWeek: Identifiable {
+ let id = UUID()
+ let weekNumber: Int
+ let startDate: Date
+ let endDate: Date
+ let entries: [ReportEntry]
+ var aiSummary: String?
+}
+
+// MARK: - Report Month Summary
+
+struct ReportMonthSummary: Identifiable {
+ let id = UUID()
+ let month: Int
+ let year: Int
+ let entryCount: Int
+ let averageMood: Double
+ var aiSummary: String?
+
+ var title: String {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "MMMM yyyy"
+ var components = DateComponents()
+ components.month = month
+ components.year = year
+ let date = Calendar.current.date(from: components) ?? Date()
+ return formatter.string(from: date)
+ }
+}
+
+// MARK: - Report Year Summary
+
+struct ReportYearSummary: Identifiable {
+ let id = UUID()
+ let year: Int
+ let entryCount: Int
+ let averageMood: Double
+ var aiSummary: String?
+}
+
+// MARK: - Assembled Report
+
+struct MoodReport {
+ let reportType: ReportType
+ let generatedAt: Date
+ let overview: ReportOverviewStats
+ let weeks: [ReportWeek]
+ let monthlySummaries: [ReportMonthSummary]
+ let yearlySummaries: [ReportYearSummary]
+ var quickSummary: String?
+}
+
+// MARK: - @Generable AI Response Structs
+
+@available(iOS 26, *)
+@Generable
+struct AIWeeklySummary: Equatable {
+ @Guide(description: "A clinical, factual summary of the user's mood patterns for this week. Use third-person perspective (e.g., 'The individual'). 2-3 sentences covering mood trends, notable patterns, and any significant changes. Neutral, professional tone suitable for a therapist to read.")
+ var summary: String
+}
+
+@available(iOS 26, *)
+@Generable
+struct AIMonthSummary: Equatable {
+ @Guide(description: "A clinical, factual summary of the user's mood patterns for this month. Use third-person perspective. 3-4 sentences covering overall mood trend, week-over-week changes, and notable patterns. Professional tone suitable for clinical review.")
+ var summary: String
+}
+
+@available(iOS 26, *)
+@Generable
+struct AIYearSummary: Equatable {
+ @Guide(description: "A clinical, factual summary of the user's mood patterns for this year. Use third-person perspective. 3-5 sentences covering seasonal patterns, long-term trends, and significant periods. Professional tone suitable for clinical review.")
+ var summary: String
+}
+
+@available(iOS 26, *)
+@Generable
+struct AIQuickSummaryResponse: Equatable {
+ @Guide(description: "A comprehensive clinical summary of the user's mood data for the selected period. Use third-person perspective (e.g., 'The individual'). 4-6 sentences covering: overall mood patterns, notable trends, day-of-week patterns, and any areas of concern or improvement. Factual, neutral, professional tone suitable for sharing with a therapist.")
+ var summary: String
+
+ @Guide(description: "2-3 key clinical observations as brief bullet points. Each should be a single factual sentence about a pattern or trend observed in the data.")
+ var keyObservations: [String]
+
+ @Guide(description: "1-2 brief, neutral recommendations based on observed patterns. Frame as observations rather than prescriptions (e.g., 'Mood data suggests weekday routines may benefit from...').")
+ var recommendations: [String]
+}
diff --git a/Shared/Services/ReportPDFGenerator.swift b/Shared/Services/ReportPDFGenerator.swift
new file mode 100644
index 0000000..a9cccf9
--- /dev/null
+++ b/Shared/Services/ReportPDFGenerator.swift
@@ -0,0 +1,445 @@
+//
+// ReportPDFGenerator.swift
+// Reflect
+//
+// Generates clinical PDF reports from MoodReport data using HTML + WKWebView.
+//
+
+import Foundation
+import WebKit
+
+@MainActor
+final class ReportPDFGenerator {
+
+ enum PDFError: Error, LocalizedError {
+ case htmlRenderFailed
+ case pdfGenerationFailed(underlying: Error)
+ case fileWriteFailed
+
+ var errorDescription: String? {
+ switch self {
+ case .htmlRenderFailed: return "Failed to render report HTML"
+ case .pdfGenerationFailed(let error): return "PDF generation failed: \(error.localizedDescription)"
+ case .fileWriteFailed: return "Failed to save PDF file"
+ }
+ }
+ }
+
+ // MARK: - Public API
+
+ func generatePDF(from report: MoodReport) async throws -> URL {
+ let html = generateHTML(from: report)
+ let pdfData = try await renderHTMLToPDF(html: html)
+
+ let dateFormatter = DateFormatter()
+ dateFormatter.dateFormat = "yyyy-MM-dd"
+ let startStr = dateFormatter.string(from: report.weeks.first?.startDate ?? Date())
+ let endStr = dateFormatter.string(from: report.weeks.last?.endDate ?? Date())
+ let fileName = "Reflect-AI-Report-\(startStr)-to-\(endStr).pdf"
+ let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName)
+
+ do {
+ try pdfData.write(to: fileURL)
+ return fileURL
+ } catch {
+ throw PDFError.fileWriteFailed
+ }
+ }
+
+ // MARK: - HTML Generation
+
+ func generateHTML(from report: MoodReport) -> String {
+ let dateFormatter = DateFormatter()
+ dateFormatter.dateStyle = .long
+
+ let generatedDate = dateFormatter.string(from: report.generatedAt)
+ let useFahrenheit = Locale.current.measurementSystem == .us
+
+ var html = """
+
+
+
+
+
+
+
+
+ """
+
+ // Header
+ html += """
+
+ """
+
+ // Overview Stats
+ html += generateOverviewSection(report.overview)
+
+ // Quick Summary (if available)
+ if let quickSummary = report.quickSummary {
+ html += """
+
+
Summary
+
\(escapeHTML(quickSummary))
+
+ """
+ }
+
+ // Weekly sections (Detailed report only)
+ if report.reportType == .detailed {
+ for week in report.weeks {
+ html += generateWeekSection(week, useFahrenheit: useFahrenheit)
+ }
+ }
+
+ // Monthly Summaries
+ if !report.monthlySummaries.isEmpty {
+ html += """
+
+
Monthly Summaries
+ """
+ for month in report.monthlySummaries {
+ html += """
+
+
\(escapeHTML(month.title))
+
\(month.entryCount) entries · Average mood: \(String(format: "%.1f", month.averageMood))/5
+ \(month.aiSummary.map { "
\(escapeHTML($0))
" } ?? "")
+
+ """
+ }
+ html += "
"
+ }
+
+ // Yearly Summaries
+ if !report.yearlySummaries.isEmpty {
+ html += """
+
+
Yearly Summaries
+ """
+ for year in report.yearlySummaries {
+ html += """
+
+
\(year.year)
+
\(year.entryCount) entries · Average mood: \(String(format: "%.1f", year.averageMood))/5
+ \(year.aiSummary.map { "
\(escapeHTML($0))
" } ?? "")
+
+ """
+ }
+ html += "
"
+ }
+
+ // Footer
+ html += """
+
+
+
+ """
+
+ return html
+ }
+
+ // MARK: - Section Generators
+
+ private func generateOverviewSection(_ overview: ReportOverviewStats) -> String {
+ let distributionHTML = [Mood.great, .good, .average, .bad, .horrible]
+ .compactMap { mood -> String? in
+ guard let count = overview.moodDistribution[mood], count > 0 else { return nil }
+ let pct = overview.totalEntries > 0 ? Int(Double(count) / Double(overview.totalEntries) * 100) : 0
+ return """
+
+
+ \(mood.widgetDisplayName)
+ \(count) (\(pct)%)
+
+ """
+ }
+ .joined()
+
+ return """
+
+
Overview
+
+
+
\(overview.totalEntries)
+
Total Entries
+
+
+
\(String(format: "%.1f", overview.averageMood))
+
Avg Mood (1-5)
+
+
+
\(overview.trend)
+
Trend
+
+
+
+
Mood Distribution
+ \(distributionHTML)
+
+
+ """
+ }
+
+ private func generateWeekSection(_ week: ReportWeek, useFahrenheit: Bool) -> String {
+ let weekDateFormatter = DateFormatter()
+ weekDateFormatter.dateFormat = "MMM d"
+
+ let startStr = weekDateFormatter.string(from: week.startDate)
+ let endStr = weekDateFormatter.string(from: week.endDate)
+
+ let entryDateFormatter = DateFormatter()
+ entryDateFormatter.dateFormat = "EEE, MMM d"
+
+ var rows = ""
+ for entry in week.entries.sorted(by: { $0.date < $1.date }) {
+ let dateStr = entryDateFormatter.string(from: entry.date)
+ let moodStr = entry.mood.widgetDisplayName
+ let moodColor = moodHexColor(entry.mood)
+ let notesStr = entry.notes ?? "-"
+ let weatherStr = formatWeather(entry.weather, useFahrenheit: useFahrenheit)
+
+ rows += """
+
+ | \(escapeHTML(dateStr)) |
+ \(escapeHTML(moodStr)) |
+ \(escapeHTML(notesStr)) |
+ \(escapeHTML(weatherStr)) |
+
+ """
+ }
+
+ return """
+
+
Week \(week.weekNumber): \(escapeHTML(startStr)) - \(escapeHTML(endStr))
+
+
+
+ | Date |
+ Mood |
+ Notes |
+ Weather |
+
+
+
+ \(rows)
+
+
+ \(week.aiSummary.map { "
\(escapeHTML($0))
" } ?? "")
+
+ """
+ }
+
+ // MARK: - Helpers
+
+ private func formatWeather(_ weather: WeatherData?, useFahrenheit: Bool) -> String {
+ guard let w = weather else { return "-" }
+ let temp: String
+ if useFahrenheit {
+ let f = w.temperature * 9 / 5 + 32
+ temp = "\(Int(round(f)))°F"
+ } else {
+ temp = "\(Int(round(w.temperature)))°C"
+ }
+ return "\(w.condition), \(temp)"
+ }
+
+ private func moodHexColor(_ mood: Mood) -> String {
+ switch mood {
+ case .great: return "#4CAF50"
+ case .good: return "#8BC34A"
+ case .average: return "#FFC107"
+ case .bad: return "#FF9800"
+ case .horrible: return "#F44336"
+ default: return "#9E9E9E"
+ }
+ }
+
+ private func escapeHTML(_ string: String) -> String {
+ string
+ .replacingOccurrences(of: "&", with: "&")
+ .replacingOccurrences(of: "<", with: "<")
+ .replacingOccurrences(of: ">", with: ">")
+ .replacingOccurrences(of: "\"", with: """)
+ }
+
+ // MARK: - PDF Rendering
+
+ private func renderHTMLToPDF(html: String) async throws -> Data {
+ let webView = WKWebView(frame: CGRect(x: 0, y: 0, width: 612, height: 792))
+ webView.isOpaque = false
+
+ // Load HTML and wait for it to render
+ return try await withCheckedThrowingContinuation { continuation in
+ let delegate = PDFNavigationDelegate { result in
+ switch result {
+ case .success(let data):
+ continuation.resume(returning: data)
+ case .failure(let error):
+ continuation.resume(throwing: error)
+ }
+ }
+
+ // Prevent delegate from being deallocated
+ objc_setAssociatedObject(webView, "delegate", delegate, .OBJC_ASSOCIATION_RETAIN)
+ webView.navigationDelegate = delegate
+ webView.loadHTMLString(html, baseURL: nil)
+ }
+ }
+
+ // MARK: - CSS
+
+ private var cssStyles: String {
+ """
+ * { margin: 0; padding: 0; box-sizing: border-box; }
+ body {
+ font-family: Georgia, 'Times New Roman', serif;
+ font-size: 11pt;
+ line-height: 1.5;
+ color: #333;
+ padding: 40px;
+ }
+ h1, h2, h3 {
+ font-family: -apple-system, Helvetica, Arial, sans-serif;
+ color: #1a1a1a;
+ }
+ h1 { font-size: 22pt; margin-bottom: 4px; }
+ h2 { font-size: 16pt; margin-bottom: 12px; border-bottom: 1px solid #ddd; padding-bottom: 6px; }
+ h3 { font-size: 13pt; margin-bottom: 8px; }
+ .header { text-align: center; margin-bottom: 30px; }
+ .subtitle { font-size: 13pt; color: #555; margin-bottom: 2px; }
+ .generated { font-size: 10pt; color: #888; }
+ .section { margin-bottom: 24px; }
+ .stats-grid {
+ display: flex;
+ gap: 20px;
+ margin-bottom: 16px;
+ }
+ .stat-item {
+ flex: 1;
+ text-align: center;
+ padding: 12px;
+ background: #f8f8f8;
+ border-radius: 8px;
+ }
+ .stat-value {
+ font-family: -apple-system, Helvetica, Arial, sans-serif;
+ font-size: 20pt;
+ font-weight: 700;
+ color: #1a1a1a;
+ }
+ .stat-label {
+ font-size: 9pt;
+ color: #888;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ }
+ .distribution { margin-top: 12px; }
+ .mood-bar-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 4px 0;
+ }
+ .mood-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ display: inline-block;
+ }
+ .mood-dot-inline {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ display: inline-block;
+ vertical-align: middle;
+ }
+ .mood-label { width: 80px; font-size: 10pt; }
+ .mood-count { font-size: 10pt; color: #666; }
+ table {
+ width: 100%;
+ border-collapse: collapse;
+ margin: 8px 0 12px 0;
+ font-size: 10pt;
+ }
+ thead { background: #f0f0f0; }
+ th {
+ font-family: -apple-system, Helvetica, Arial, sans-serif;
+ font-weight: 600;
+ text-align: left;
+ padding: 6px 8px;
+ font-size: 9pt;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+ color: #555;
+ }
+ td { padding: 6px 8px; border-bottom: 1px solid #eee; }
+ tr:nth-child(even) { background: #fafafa; }
+ .notes-cell { max-width: 200px; overflow: hidden; text-overflow: ellipsis; }
+ .ai-summary {
+ background: #f4f0ff;
+ border-left: 3px solid #7c5cbf;
+ padding: 10px 14px;
+ margin: 8px 0;
+ font-style: italic;
+ font-size: 10.5pt;
+ line-height: 1.6;
+ }
+ .summary-block {
+ margin-bottom: 16px;
+ page-break-inside: avoid;
+ }
+ .stats { font-size: 10pt; color: #666; margin-bottom: 6px; }
+ .page-break { page-break-before: always; }
+ .no-page-break { page-break-inside: avoid; }
+ .footer {
+ margin-top: 40px;
+ padding-top: 12px;
+ border-top: 1px solid #ddd;
+ text-align: center;
+ font-size: 9pt;
+ color: #999;
+ }
+ """
+ }
+}
+
+// MARK: - WKNavigationDelegate for PDF
+
+private class PDFNavigationDelegate: NSObject, WKNavigationDelegate {
+ let completion: (Result) -> Void
+
+ init(completion: @escaping (Result) -> Void) {
+ self.completion = completion
+ }
+
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+ let config = WKPDFConfiguration()
+ config.rect = CGRect(x: 0, y: 0, width: 612, height: 792) // US Letter
+
+ webView.createPDF(configuration: config) { [weak self] result in
+ DispatchQueue.main.async {
+ switch result {
+ case .success(let data):
+ self?.completion(.success(data))
+ case .failure(let error):
+ self?.completion(.failure(ReportPDFGenerator.PDFError.pdfGenerationFailed(underlying: error)))
+ }
+ }
+ }
+ }
+
+ func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
+ completion(.failure(ReportPDFGenerator.PDFError.htmlRenderFailed))
+ }
+
+ func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
+ completion(.failure(ReportPDFGenerator.PDFError.htmlRenderFailed))
+ }
+}
diff --git a/Shared/Views/InsightsView/InsightsView.swift b/Shared/Views/InsightsView/InsightsView.swift
index 96a4c6a..303e047 100644
--- a/Shared/Views/InsightsView/InsightsView.swift
+++ b/Shared/Views/InsightsView/InsightsView.swift
@@ -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
diff --git a/Shared/Views/InsightsView/ReportDateRangePicker.swift b/Shared/Views/InsightsView/ReportDateRangePicker.swift
new file mode 100644
index 0000000..4920d80
--- /dev/null
+++ b/Shared/Views/InsightsView/ReportDateRangePicker.swift
@@ -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)
+ }
+}
diff --git a/Shared/Views/InsightsView/ReportGeneratingView.swift b/Shared/Views/InsightsView/ReportGeneratingView.swift
new file mode 100644
index 0000000..d8e2589
--- /dev/null
+++ b/Shared/Views/InsightsView/ReportGeneratingView.swift
@@ -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
+ }
+ }
+ }
+}
diff --git a/Shared/Views/InsightsView/ReportsView.swift b/Shared/Views/InsightsView/ReportsView.swift
new file mode 100644
index 0000000..62ceb2c
--- /dev/null
+++ b/Shared/Views/InsightsView/ReportsView.swift
@@ -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)
+ }
+}
diff --git a/Shared/Views/InsightsView/ReportsViewModel.swift b/Shared/Views/InsightsView/ReportsViewModel.swift
new file mode 100644
index 0000000..dcc9bd8
--- /dev/null
+++ b/Shared/Views/InsightsView/ReportsViewModel.swift
@@ -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?
+
+ // 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.. 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).
+ """
+ }
+}