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 += """ +
+

Reflect Mood Report

+

\(report.overview.dateRange)

+

Generated \(generatedDate)

+
+ """ + + // 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))

+ + + + + + + + + + + \(rows) + +
DateMoodNotesWeather
+ \(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). + """ + } +}