- Populate debug test data with random notes, guided reflections, and weather - Fix PDF export to use UIPrintPageRenderer for proper multi-page pagination - Add journal/reflection indicator icons to day list entry cells - Fix weather card icon contrast by using secondarySystemBackground - Align Generate Report and Export PDF button widths in ReportsView Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
481 lines
17 KiB
Swift
481 lines
17 KiB
Swift
//
|
|
// ReportPDFGenerator.swift
|
|
// Reflect
|
|
//
|
|
// Generates clinical PDF reports from MoodReport data using HTML + WKWebView.
|
|
//
|
|
|
|
import Foundation
|
|
import UIKit
|
|
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=612, 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 · 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 · 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 · 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: "&")
|
|
.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 finish rendering
|
|
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!) {
|
|
// Use UIPrintPageRenderer for proper multi-page pagination.
|
|
// createPDF(configuration:) only captures a single rect — it does NOT paginate.
|
|
let renderer = UIPrintPageRenderer()
|
|
renderer.addPrintFormatter(webView.viewPrintFormatter(), startingAtPageAt: 0)
|
|
|
|
// US Letter size (72 points per inch: 8.5 x 11 inches)
|
|
let pageRect = CGRect(x: 0, y: 0, width: 612, height: 792)
|
|
renderer.setValue(pageRect, forKey: "paperRect")
|
|
renderer.setValue(pageRect, forKey: "printableRect")
|
|
|
|
let pdfData = NSMutableData()
|
|
UIGraphicsBeginPDFContextToData(pdfData, pageRect, nil)
|
|
renderer.prepare(forDrawingPages: NSMakeRange(0, renderer.numberOfPages))
|
|
|
|
let bounds = UIGraphicsGetPDFContextBounds()
|
|
for i in 0..<renderer.numberOfPages {
|
|
UIGraphicsBeginPDFPage()
|
|
renderer.drawPage(at: i, in: bounds)
|
|
}
|
|
UIGraphicsEndPDFContext()
|
|
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.completion(.success(pdfData as Data))
|
|
}
|
|
}
|
|
|
|
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))
|
|
}
|
|
}
|