Files
Reflect/Shared/Services/ReportPDFGenerator.swift
Trey t 5bd8f8076a Add guided reflection flow with mood-adaptive CBT/ACT questions
Walks users through 3-4 guided questions based on mood category:
positive (great/good) gets gratitude-oriented questions, neutral
(average) gets exploratory questions, and negative (bad/horrible)
gets empathetic questions. Stored as JSON in MoodEntryModel,
integrated into PDF reports, AI summaries, and CSV export.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 15:52:56 -05:00

469 lines
16 KiB
Swift

//
// 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 = """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
\(cssStyles)
</style>
</head>
<body>
"""
// Header
html += """
<div class="header">
<h1>Reflect Mood Report</h1>
<p class="subtitle">\(report.overview.dateRange)</p>
<p class="generated">Generated \(generatedDate)</p>
</div>
"""
// Overview Stats
html += generateOverviewSection(report.overview)
// Quick Summary (if available)
if let quickSummary = report.quickSummary {
html += """
<div class="section">
<h2>Summary</h2>
<div class="ai-summary">\(escapeHTML(quickSummary))</div>
</div>
"""
}
// 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 += """
<div class="section page-break">
<h2>Monthly Summaries</h2>
"""
for month in report.monthlySummaries {
html += """
<div class="summary-block">
<h3>\(escapeHTML(month.title))</h3>
<p class="stats">\(month.entryCount) entries &middot; Average mood: \(String(format: "%.1f", month.averageMood))/5</p>
\(month.aiSummary.map { "<div class=\"ai-summary\">\(escapeHTML($0))</div>" } ?? "")
</div>
"""
}
html += "</div>"
}
// Yearly Summaries
if !report.yearlySummaries.isEmpty {
html += """
<div class="section page-break">
<h2>Yearly Summaries</h2>
"""
for year in report.yearlySummaries {
html += """
<div class="summary-block">
<h3>\(year.year)</h3>
<p class="stats">\(year.entryCount) entries &middot; Average mood: \(String(format: "%.1f", year.averageMood))/5</p>
\(year.aiSummary.map { "<div class=\"ai-summary\">\(escapeHTML($0))</div>" } ?? "")
</div>
"""
}
html += "</div>"
}
// Footer
html += """
<div class="footer">
<p>Generated by Reflect &middot; This report is intended for personal use and sharing with healthcare professionals.</p>
</div>
</body>
</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 """
<div class="mood-bar-row">
<span class="mood-dot" style="background-color: \(moodHexColor(mood))"></span>
<span class="mood-label">\(mood.widgetDisplayName)</span>
<span class="mood-count">\(count) (\(pct)%)</span>
</div>
"""
}
.joined()
return """
<div class="section">
<h2>Overview</h2>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-value">\(overview.totalEntries)</div>
<div class="stat-label">Total Entries</div>
</div>
<div class="stat-item">
<div class="stat-value">\(String(format: "%.1f", overview.averageMood))</div>
<div class="stat-label">Avg Mood (1-5)</div>
</div>
<div class="stat-item">
<div class="stat-value">\(overview.trend)</div>
<div class="stat-label">Trend</div>
</div>
</div>
<div class="distribution">
<h3>Mood Distribution</h3>
\(distributionHTML)
</div>
</div>
"""
}
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 += """
<tr>
<td>\(escapeHTML(dateStr))</td>
<td><span class="mood-dot-inline" style="background-color: \(moodColor)"></span> \(escapeHTML(moodStr))</td>
<td class="notes-cell">\(escapeHTML(notesStr))</td>
<td>\(escapeHTML(weatherStr))</td>
</tr>
"""
if let reflection = entry.reflection, reflection.answeredCount > 0 {
rows += """
<tr class="reflection-row">
<td colspan="4">
<div class="reflection-block">
\(formatReflectionHTML(reflection))
</div>
</td>
</tr>
"""
}
}
return """
<div class="section">
<h3>Week \(week.weekNumber): \(escapeHTML(startStr)) - \(escapeHTML(endStr))</h3>
<table class="no-page-break">
<thead>
<tr>
<th>Date</th>
<th>Mood</th>
<th>Notes</th>
<th>Weather</th>
</tr>
</thead>
<tbody>
\(rows)
</tbody>
</table>
\(week.aiSummary.map { "<div class=\"ai-summary\">\(escapeHTML($0))</div>" } ?? "")
</div>
"""
}
// 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 formatReflectionHTML(_ reflection: GuidedReflection) -> String {
reflection.responses
.filter { !$0.answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
.map { "<p class=\"reflection-q\">\(escapeHTML($0.question))</p><p class=\"reflection-a\">\(escapeHTML($0.answer))</p>" }
.joined()
}
private func escapeHTML(_ string: String) -> String {
string
.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
}
// 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; }
.reflection-row td { border-bottom: none; padding-top: 0; }
.reflection-block { background: #f9f7ff; padding: 8px 12px; margin: 4px 0 8px 0; border-radius: 4px; }
.reflection-q { font-style: italic; font-size: 9pt; color: #666; margin-bottom: 2px; }
.reflection-a { font-size: 10pt; color: #333; 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<Data, Error>) -> Void
init(completion: @escaping (Result<Data, Error>) -> 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))
}
}