Complete rename across all bundle IDs, App Groups, CloudKit containers, StoreKit product IDs, data store filenames, URL schemes, logger subsystems, Swift identifiers, user-facing strings (7 languages), file names, directory names, Xcode project, schemes, assets, and documentation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
796 lines
32 KiB
Swift
796 lines
32 KiB
Swift
//
|
|
// ExportService.swift
|
|
// Reflect
|
|
//
|
|
// Handles exporting mood data to CSV and PDF formats with beautiful visualizations.
|
|
//
|
|
|
|
import Foundation
|
|
import SwiftUI
|
|
import PDFKit
|
|
import UIKit
|
|
|
|
class ExportService {
|
|
|
|
static let shared = ExportService()
|
|
|
|
// MARK: - Date Formatter
|
|
|
|
private let dateFormatter: DateFormatter = {
|
|
let formatter = DateFormatter()
|
|
formatter.dateStyle = .medium
|
|
formatter.timeStyle = .short
|
|
return formatter
|
|
}()
|
|
|
|
private let shortDateFormatter: DateFormatter = {
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "MMM d"
|
|
return formatter
|
|
}()
|
|
|
|
private let isoFormatter: ISO8601DateFormatter = {
|
|
let formatter = ISO8601DateFormatter()
|
|
formatter.formatOptions = [.withInternetDateTime]
|
|
return formatter
|
|
}()
|
|
|
|
// MARK: - Colors (using default mood tint colors)
|
|
|
|
private let moodColors: [Mood: UIColor] = [
|
|
.great: UIColor(red: 0.29, green: 0.78, blue: 0.55, alpha: 1.0), // Green
|
|
.good: UIColor(red: 0.56, green: 0.79, blue: 0.35, alpha: 1.0), // Light Green
|
|
.average: UIColor(red: 1.0, green: 0.76, blue: 0.03, alpha: 1.0), // Yellow
|
|
.bad: UIColor(red: 1.0, green: 0.58, blue: 0.0, alpha: 1.0), // Orange
|
|
.horrible: UIColor(red: 0.91, green: 0.30, blue: 0.24, alpha: 1.0) // Red
|
|
]
|
|
|
|
private func trackDataExported(format: String, count: Int) {
|
|
Task { @MainActor in
|
|
AnalyticsManager.shared.track(.dataExported(format: format, count: count))
|
|
}
|
|
}
|
|
|
|
// MARK: - CSV Export
|
|
|
|
func generateCSV(entries: [MoodEntryModel]) -> String {
|
|
var csv = "Date,Mood,Mood Value,Notes,Weekday,Entry Type,Timestamp\n"
|
|
|
|
let sortedEntries = entries.sorted { $0.forDate > $1.forDate }
|
|
|
|
for entry in sortedEntries {
|
|
let date = dateFormatter.string(from: entry.forDate)
|
|
let mood = entry.mood.widgetDisplayName
|
|
let moodValue = entry.moodValue + 1 // 1-5 scale
|
|
let notes = escapeCSV(entry.notes ?? "")
|
|
let weekday = weekdayName(from: entry.weekDay)
|
|
let entryType = EntryType(rawValue: entry.entryType)?.description ?? "Unknown"
|
|
let timestamp = isoFormatter.string(from: entry.timestamp)
|
|
|
|
csv += "\(date),\(mood),\(moodValue),\(notes),\(weekday),\(entryType),\(timestamp)\n"
|
|
}
|
|
|
|
return csv
|
|
}
|
|
|
|
func exportCSV(entries: [MoodEntryModel]) -> URL? {
|
|
let csv = generateCSV(entries: entries)
|
|
|
|
let filename = "Reflect-Export-\(formattedDate()).csv"
|
|
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
|
|
|
|
do {
|
|
try csv.write(to: tempURL, atomically: true, encoding: .utf8)
|
|
trackDataExported(format: "csv", count: entries.count)
|
|
return tempURL
|
|
} catch {
|
|
print("ExportService: Failed to write CSV: \(error)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - PDF Export
|
|
|
|
func generatePDF(entries: [MoodEntryModel], title: String = "Mood Report") -> Data? {
|
|
let sortedEntries = entries.sorted { $0.forDate > $1.forDate }
|
|
let validEntries = sortedEntries.filter { ![.missing, .placeholder].contains($0.mood) }
|
|
|
|
guard !validEntries.isEmpty else { return nil }
|
|
|
|
// Calculate statistics
|
|
let stats = calculateStats(entries: validEntries)
|
|
|
|
// PDF Setup
|
|
let pageWidth: CGFloat = 612 // US Letter
|
|
let pageHeight: CGFloat = 792
|
|
let margin: CGFloat = 40
|
|
let contentWidth = pageWidth - (margin * 2)
|
|
|
|
let pdfMetaData = [
|
|
kCGPDFContextCreator: "Reflect App",
|
|
kCGPDFContextTitle: title
|
|
]
|
|
|
|
let format = UIGraphicsPDFRendererFormat()
|
|
format.documentInfo = pdfMetaData as [String: Any]
|
|
|
|
let renderer = UIGraphicsPDFRenderer(
|
|
bounds: CGRect(x: 0, y: 0, width: pageWidth, height: pageHeight),
|
|
format: format
|
|
)
|
|
|
|
let data = renderer.pdfData { context in
|
|
// PAGE 1: Overview
|
|
context.beginPage()
|
|
var yPosition: CGFloat = margin
|
|
|
|
// Header with gradient background
|
|
yPosition = drawHeader(at: yPosition, title: title, stats: stats, margin: margin, width: contentWidth, pageWidth: pageWidth, in: context)
|
|
|
|
// Summary Cards
|
|
yPosition = drawSummaryCards(at: yPosition, stats: stats, margin: margin, width: contentWidth, in: context)
|
|
|
|
// Mood Distribution Chart
|
|
yPosition = drawMoodDistributionChart(at: yPosition, stats: stats, margin: margin, width: contentWidth, in: context)
|
|
|
|
// Weekday Analysis
|
|
if yPosition < pageHeight - 250 {
|
|
yPosition = drawWeekdayAnalysis(at: yPosition, entries: validEntries, margin: margin, width: contentWidth, in: context)
|
|
}
|
|
|
|
// PAGE 2: Trends & Details
|
|
context.beginPage()
|
|
yPosition = margin + 20
|
|
|
|
// Page 2 Header
|
|
yPosition = drawSectionTitle("Trends & Patterns", at: yPosition, margin: margin)
|
|
|
|
// Mood Trend Line (last 30 entries)
|
|
yPosition = drawTrendChart(at: yPosition, entries: validEntries, margin: margin, width: contentWidth, in: context)
|
|
|
|
// Streaks Section
|
|
yPosition = drawStreaksSection(at: yPosition, stats: stats, margin: margin, width: contentWidth, in: context)
|
|
|
|
// Recent Entries Table
|
|
yPosition = drawRecentEntries(at: yPosition, entries: validEntries, margin: margin, width: contentWidth, pageHeight: pageHeight, in: context)
|
|
|
|
// Footer
|
|
drawFooter(pageWidth: pageWidth, pageHeight: pageHeight, margin: margin, in: context)
|
|
}
|
|
|
|
trackDataExported(format: "pdf", count: entries.count)
|
|
return data
|
|
}
|
|
|
|
func exportPDF(entries: [MoodEntryModel], title: String = "Mood Report") -> URL? {
|
|
guard let data = generatePDF(entries: entries, title: title) else {
|
|
return nil
|
|
}
|
|
|
|
let filename = "Reflect-Report-\(formattedDate()).pdf"
|
|
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
|
|
|
|
do {
|
|
try data.write(to: tempURL)
|
|
return tempURL
|
|
} catch {
|
|
print("ExportService: Failed to write PDF: \(error)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - PDF Drawing: Header
|
|
|
|
private func drawHeader(at y: CGFloat, title: String, stats: ExportStats, margin: CGFloat, width: CGFloat, pageWidth: CGFloat, in context: UIGraphicsPDFRendererContext) -> CGFloat {
|
|
let headerHeight: CGFloat = 120
|
|
|
|
// Draw gradient background
|
|
let gradientColors = [
|
|
UIColor(red: 0.29, green: 0.56, blue: 0.89, alpha: 1.0).cgColor,
|
|
UIColor(red: 0.56, green: 0.35, blue: 0.89, alpha: 1.0).cgColor
|
|
]
|
|
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
|
if let gradient = CGGradient(colorsSpace: colorSpace, colors: gradientColors as CFArray, locations: [0, 1]) {
|
|
context.cgContext.saveGState()
|
|
context.cgContext.addRect(CGRect(x: 0, y: y, width: pageWidth, height: headerHeight))
|
|
context.cgContext.clip()
|
|
context.cgContext.drawLinearGradient(gradient, start: CGPoint(x: 0, y: y), end: CGPoint(x: pageWidth, y: y + headerHeight), options: [])
|
|
context.cgContext.restoreGState()
|
|
}
|
|
|
|
// Title
|
|
let titleFont = UIFont.systemFont(ofSize: 28, weight: .bold)
|
|
let titleAttributes: [NSAttributedString.Key: Any] = [
|
|
.font: titleFont,
|
|
.foregroundColor: UIColor.white
|
|
]
|
|
let titleString = NSAttributedString(string: title, attributes: titleAttributes)
|
|
titleString.draw(at: CGPoint(x: margin, y: y + 25))
|
|
|
|
// Subtitle with date range
|
|
let subtitleFont = UIFont.systemFont(ofSize: 14, weight: .medium)
|
|
let subtitleAttributes: [NSAttributedString.Key: Any] = [
|
|
.font: subtitleFont,
|
|
.foregroundColor: UIColor.white.withAlphaComponent(0.9)
|
|
]
|
|
let subtitleString = NSAttributedString(string: stats.dateRange, attributes: subtitleAttributes)
|
|
subtitleString.draw(at: CGPoint(x: margin, y: y + 60))
|
|
|
|
// Generated date
|
|
let dateFont = UIFont.systemFont(ofSize: 11, weight: .regular)
|
|
let dateAttributes: [NSAttributedString.Key: Any] = [
|
|
.font: dateFont,
|
|
.foregroundColor: UIColor.white.withAlphaComponent(0.7)
|
|
]
|
|
let generatedString = NSAttributedString(string: "Generated \(dateFormatter.string(from: Date()))", attributes: dateAttributes)
|
|
generatedString.draw(at: CGPoint(x: margin, y: y + 85))
|
|
|
|
return y + headerHeight + 25
|
|
}
|
|
|
|
// MARK: - PDF Drawing: Summary Cards
|
|
|
|
private func drawSummaryCards(at y: CGFloat, stats: ExportStats, margin: CGFloat, width: CGFloat, in context: UIGraphicsPDFRendererContext) -> CGFloat {
|
|
let cardHeight: CGFloat = 80
|
|
let cardWidth = (width - 20) / 3
|
|
let cornerRadius: CGFloat = 10
|
|
|
|
let cards: [(String, String, UIColor)] = [
|
|
("\(stats.totalEntries)", "Total Entries", UIColor(red: 0.29, green: 0.78, blue: 0.55, alpha: 1.0)),
|
|
(String(format: "%.1f", stats.averageMood), "Avg Mood (1-5)", UIColor(red: 0.29, green: 0.56, blue: 0.89, alpha: 1.0)),
|
|
(stats.mostCommonMood, "Top Mood", moodColors[Mood.allValues.first { $0.widgetDisplayName == stats.mostCommonMood } ?? .average] ?? .gray)
|
|
]
|
|
|
|
for (index, card) in cards.enumerated() {
|
|
let xPos = margin + CGFloat(index) * (cardWidth + 10)
|
|
|
|
// Card background
|
|
let cardRect = CGRect(x: xPos, y: y, width: cardWidth, height: cardHeight)
|
|
let cardPath = UIBezierPath(roundedRect: cardRect, cornerRadius: cornerRadius)
|
|
|
|
// Light background
|
|
UIColor(white: 0.97, alpha: 1.0).setFill()
|
|
cardPath.fill()
|
|
|
|
// Accent bar on left
|
|
let accentRect = CGRect(x: xPos, y: y, width: 4, height: cardHeight)
|
|
let accentPath = UIBezierPath(roundedRect: accentRect, byRoundingCorners: [.topLeft, .bottomLeft], cornerRadii: CGSize(width: cornerRadius, height: cornerRadius))
|
|
card.2.setFill()
|
|
accentPath.fill()
|
|
|
|
// Value
|
|
let valueFont = UIFont.systemFont(ofSize: 24, weight: .bold)
|
|
let valueAttributes: [NSAttributedString.Key: Any] = [
|
|
.font: valueFont,
|
|
.foregroundColor: UIColor.darkGray
|
|
]
|
|
let valueString = NSAttributedString(string: card.0, attributes: valueAttributes)
|
|
valueString.draw(at: CGPoint(x: xPos + 15, y: y + 18))
|
|
|
|
// Label
|
|
let labelFont = UIFont.systemFont(ofSize: 11, weight: .medium)
|
|
let labelAttributes: [NSAttributedString.Key: Any] = [
|
|
.font: labelFont,
|
|
.foregroundColor: UIColor.gray
|
|
]
|
|
let labelString = NSAttributedString(string: card.1, attributes: labelAttributes)
|
|
labelString.draw(at: CGPoint(x: xPos + 15, y: y + 50))
|
|
}
|
|
|
|
return y + cardHeight + 30
|
|
}
|
|
|
|
// MARK: - PDF Drawing: Mood Distribution Chart
|
|
|
|
private func drawMoodDistributionChart(at y: CGFloat, stats: ExportStats, margin: CGFloat, width: CGFloat, in context: UIGraphicsPDFRendererContext) -> CGFloat {
|
|
let currentY = drawSectionTitle("Mood Distribution", at: y, margin: margin)
|
|
|
|
let chartHeight: CGFloat = 140
|
|
let barHeight: CGFloat = 22
|
|
let barSpacing: CGFloat = 8
|
|
let labelWidth: CGFloat = 70
|
|
let percentWidth: CGFloat = 50
|
|
|
|
let sortedMoods: [Mood] = [.great, .good, .average, .bad, .horrible]
|
|
|
|
for (index, mood) in sortedMoods.enumerated() {
|
|
let count = stats.moodDistribution[mood.widgetDisplayName] ?? 0
|
|
let percentage = stats.totalEntries > 0 ? (Double(count) / Double(stats.totalEntries)) * 100 : 0
|
|
let barY = currentY + CGFloat(index) * (barHeight + barSpacing)
|
|
|
|
// Mood label
|
|
let labelFont = UIFont.systemFont(ofSize: 12, weight: .medium)
|
|
let labelAttributes: [NSAttributedString.Key: Any] = [
|
|
.font: labelFont,
|
|
.foregroundColor: UIColor.darkGray
|
|
]
|
|
let labelString = NSAttributedString(string: mood.widgetDisplayName, attributes: labelAttributes)
|
|
labelString.draw(at: CGPoint(x: margin, y: barY + 3))
|
|
|
|
// Bar background
|
|
let barX = margin + labelWidth
|
|
let maxBarWidth = width - labelWidth - percentWidth - 10
|
|
let barRect = CGRect(x: barX, y: barY, width: maxBarWidth, height: barHeight)
|
|
let barPath = UIBezierPath(roundedRect: barRect, cornerRadius: 4)
|
|
UIColor(white: 0.92, alpha: 1.0).setFill()
|
|
barPath.fill()
|
|
|
|
// Filled bar
|
|
let filledWidth = maxBarWidth * CGFloat(percentage / 100)
|
|
if filledWidth > 0 {
|
|
let filledRect = CGRect(x: barX, y: barY, width: max(8, filledWidth), height: barHeight)
|
|
let filledPath = UIBezierPath(roundedRect: filledRect, cornerRadius: 4)
|
|
(moodColors[mood] ?? .gray).setFill()
|
|
filledPath.fill()
|
|
}
|
|
|
|
// Percentage label
|
|
let percentFont = UIFont.systemFont(ofSize: 12, weight: .semibold)
|
|
let percentAttributes: [NSAttributedString.Key: Any] = [
|
|
.font: percentFont,
|
|
.foregroundColor: moodColors[mood] ?? .gray
|
|
]
|
|
let percentString = NSAttributedString(string: String(format: "%.0f%%", percentage), attributes: percentAttributes)
|
|
percentString.draw(at: CGPoint(x: margin + width - percentWidth + 10, y: barY + 3))
|
|
}
|
|
|
|
return currentY + chartHeight + 20
|
|
}
|
|
|
|
// MARK: - PDF Drawing: Weekday Analysis
|
|
|
|
private func drawWeekdayAnalysis(at y: CGFloat, entries: [MoodEntryModel], margin: CGFloat, width: CGFloat, in context: UIGraphicsPDFRendererContext) -> CGFloat {
|
|
let currentY = drawSectionTitle("Mood by Day of Week", at: y, margin: margin)
|
|
|
|
let weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
|
|
var weekdayAverages: [Double] = Array(repeating: 0, count: 7)
|
|
var weekdayCounts: [Int] = Array(repeating: 0, count: 7)
|
|
|
|
for entry in entries {
|
|
let weekdayIndex = entry.weekDay - 1
|
|
if weekdayIndex >= 0 && weekdayIndex < 7 {
|
|
weekdayAverages[weekdayIndex] += Double(entry.moodValue + 1)
|
|
weekdayCounts[weekdayIndex] += 1
|
|
}
|
|
}
|
|
|
|
for i in 0..<7 {
|
|
if weekdayCounts[i] > 0 {
|
|
weekdayAverages[i] /= Double(weekdayCounts[i])
|
|
}
|
|
}
|
|
|
|
// Draw bar chart
|
|
let chartHeight: CGFloat = 100
|
|
let barWidth = (width - 60) / 7
|
|
let maxValue: Double = 5.0
|
|
|
|
for (index, avg) in weekdayAverages.enumerated() {
|
|
let barX = margin + CGFloat(index) * (barWidth + 5) + 10
|
|
let barHeight = chartHeight * CGFloat(avg / maxValue)
|
|
|
|
// Bar
|
|
if barHeight > 0 {
|
|
let barRect = CGRect(x: barX, y: currentY + chartHeight - barHeight, width: barWidth - 5, height: barHeight)
|
|
let barPath = UIBezierPath(roundedRect: barRect, byRoundingCorners: [.topLeft, .topRight], cornerRadii: CGSize(width: 4, height: 4))
|
|
|
|
// Color based on average
|
|
let color: UIColor
|
|
if avg >= 4.0 { color = moodColors[.great]! }
|
|
else if avg >= 3.0 { color = moodColors[.good]! }
|
|
else if avg >= 2.5 { color = moodColors[.average]! }
|
|
else if avg >= 1.5 { color = moodColors[.bad]! }
|
|
else { color = moodColors[.horrible]! }
|
|
|
|
color.setFill()
|
|
barPath.fill()
|
|
}
|
|
|
|
// Day label
|
|
let labelFont = UIFont.systemFont(ofSize: 10, weight: .medium)
|
|
let labelAttributes: [NSAttributedString.Key: Any] = [
|
|
.font: labelFont,
|
|
.foregroundColor: UIColor.gray
|
|
]
|
|
let labelString = NSAttributedString(string: weekdays[index], attributes: labelAttributes)
|
|
let labelSize = labelString.size()
|
|
labelString.draw(at: CGPoint(x: barX + (barWidth - 5 - labelSize.width) / 2, y: currentY + chartHeight + 5))
|
|
|
|
// Value label
|
|
if weekdayCounts[index] > 0 {
|
|
let valueFont = UIFont.systemFont(ofSize: 9, weight: .semibold)
|
|
let valueAttributes: [NSAttributedString.Key: Any] = [
|
|
.font: valueFont,
|
|
.foregroundColor: UIColor.darkGray
|
|
]
|
|
let valueString = NSAttributedString(string: String(format: "%.1f", avg), attributes: valueAttributes)
|
|
let valueSize = valueString.size()
|
|
valueString.draw(at: CGPoint(x: barX + (barWidth - 5 - valueSize.width) / 2, y: currentY + chartHeight - barHeight - 15))
|
|
}
|
|
}
|
|
|
|
return currentY + chartHeight + 40
|
|
}
|
|
|
|
// MARK: - PDF Drawing: Trend Chart
|
|
|
|
private func drawTrendChart(at y: CGFloat, entries: [MoodEntryModel], margin: CGFloat, width: CGFloat, in context: UIGraphicsPDFRendererContext) -> CGFloat {
|
|
var currentY = y + 10
|
|
|
|
let chartHeight: CGFloat = 120
|
|
let recentEntries = Array(entries.prefix(30).reversed())
|
|
|
|
guard recentEntries.count >= 2 else {
|
|
return currentY
|
|
}
|
|
|
|
// Section label
|
|
let labelFont = UIFont.systemFont(ofSize: 12, weight: .medium)
|
|
let labelAttributes: [NSAttributedString.Key: Any] = [
|
|
.font: labelFont,
|
|
.foregroundColor: UIColor.gray
|
|
]
|
|
let labelString = NSAttributedString(string: "Last \(recentEntries.count) Entries", attributes: labelAttributes)
|
|
labelString.draw(at: CGPoint(x: margin, y: currentY))
|
|
currentY += 25
|
|
|
|
// Draw chart background
|
|
let chartRect = CGRect(x: margin, y: currentY, width: width, height: chartHeight)
|
|
let chartPath = UIBezierPath(roundedRect: chartRect, cornerRadius: 8)
|
|
UIColor(white: 0.97, alpha: 1.0).setFill()
|
|
chartPath.fill()
|
|
|
|
// Draw grid lines
|
|
context.cgContext.setStrokeColor(UIColor(white: 0.9, alpha: 1.0).cgColor)
|
|
context.cgContext.setLineWidth(1)
|
|
for i in 1...4 {
|
|
let gridY = currentY + chartHeight - (chartHeight * CGFloat(i) / 5)
|
|
context.cgContext.move(to: CGPoint(x: margin + 10, y: gridY))
|
|
context.cgContext.addLine(to: CGPoint(x: margin + width - 10, y: gridY))
|
|
context.cgContext.strokePath()
|
|
}
|
|
|
|
// Draw trend line
|
|
let pointSpacing = (width - 40) / CGFloat(recentEntries.count - 1)
|
|
var points: [CGPoint] = []
|
|
|
|
for (index, entry) in recentEntries.enumerated() {
|
|
let x = margin + 20 + CGFloat(index) * pointSpacing
|
|
let normalizedMood = CGFloat(entry.moodValue) / 4.0 // 0-4 scale to 0-1
|
|
let pointY = currentY + chartHeight - 15 - (normalizedMood * (chartHeight - 30))
|
|
points.append(CGPoint(x: x, y: pointY))
|
|
}
|
|
|
|
// Draw the line
|
|
let linePath = UIBezierPath()
|
|
linePath.move(to: points[0])
|
|
for point in points.dropFirst() {
|
|
linePath.addLine(to: point)
|
|
}
|
|
UIColor(red: 0.29, green: 0.56, blue: 0.89, alpha: 1.0).setStroke()
|
|
linePath.lineWidth = 2.5
|
|
linePath.lineCapStyle = .round
|
|
linePath.lineJoinStyle = .round
|
|
linePath.stroke()
|
|
|
|
// Draw points
|
|
for (index, point) in points.enumerated() {
|
|
let entry = recentEntries[index]
|
|
let color = moodColors[entry.mood] ?? .gray
|
|
|
|
let pointRect = CGRect(x: point.x - 4, y: point.y - 4, width: 8, height: 8)
|
|
let pointPath = UIBezierPath(ovalIn: pointRect)
|
|
color.setFill()
|
|
pointPath.fill()
|
|
|
|
UIColor.white.setStroke()
|
|
pointPath.lineWidth = 1.5
|
|
pointPath.stroke()
|
|
}
|
|
|
|
return currentY + chartHeight + 30
|
|
}
|
|
|
|
// MARK: - PDF Drawing: Streaks Section
|
|
|
|
private func drawStreaksSection(at y: CGFloat, stats: ExportStats, margin: CGFloat, width: CGFloat, in context: UIGraphicsPDFRendererContext) -> CGFloat {
|
|
let currentY = drawSectionTitle("Streaks & Consistency", at: y, margin: margin)
|
|
|
|
let cardWidth = (width - 15) / 2
|
|
let cardHeight: CGFloat = 60
|
|
|
|
let streaks: [(String, String, String)] = [
|
|
("\(stats.currentStreak)", "Current Streak", "days in a row"),
|
|
("\(stats.longestStreak)", "Longest Streak", "days tracked"),
|
|
("\(stats.positiveStreak)", "Best Mood Streak", "good/great days"),
|
|
(String(format: "%.0f%%", stats.stabilityScore * 100), "Mood Stability", "consistency score")
|
|
]
|
|
|
|
for (index, streak) in streaks.enumerated() {
|
|
let row = index / 2
|
|
let col = index % 2
|
|
let xPos = margin + CGFloat(col) * (cardWidth + 15)
|
|
let yPos = currentY + CGFloat(row) * (cardHeight + 10)
|
|
|
|
// Card background
|
|
let cardRect = CGRect(x: xPos, y: yPos, width: cardWidth, height: cardHeight)
|
|
let cardPath = UIBezierPath(roundedRect: cardRect, cornerRadius: 8)
|
|
UIColor(white: 0.97, alpha: 1.0).setFill()
|
|
cardPath.fill()
|
|
|
|
// Value
|
|
let valueFont = UIFont.systemFont(ofSize: 22, weight: .bold)
|
|
let valueAttributes: [NSAttributedString.Key: Any] = [
|
|
.font: valueFont,
|
|
.foregroundColor: UIColor(red: 0.29, green: 0.56, blue: 0.89, alpha: 1.0)
|
|
]
|
|
let valueString = NSAttributedString(string: streak.0, attributes: valueAttributes)
|
|
valueString.draw(at: CGPoint(x: xPos + 12, y: yPos + 10))
|
|
|
|
// Label
|
|
let labelFont = UIFont.systemFont(ofSize: 10, weight: .medium)
|
|
let labelAttributes: [NSAttributedString.Key: Any] = [
|
|
.font: labelFont,
|
|
.foregroundColor: UIColor.gray
|
|
]
|
|
let labelString = NSAttributedString(string: "\(streak.1) - \(streak.2)", attributes: labelAttributes)
|
|
labelString.draw(at: CGPoint(x: xPos + 12, y: yPos + 38))
|
|
}
|
|
|
|
return currentY + (cardHeight + 10) * 2 + 20
|
|
}
|
|
|
|
// MARK: - PDF Drawing: Recent Entries
|
|
|
|
private func drawRecentEntries(at y: CGFloat, entries: [MoodEntryModel], margin: CGFloat, width: CGFloat, pageHeight: CGFloat, in context: UIGraphicsPDFRendererContext) -> CGFloat {
|
|
var currentY = drawSectionTitle("Recent Entries", at: y, margin: margin)
|
|
|
|
let recentEntries = Array(entries.prefix(15))
|
|
let rowHeight: CGFloat = 28
|
|
|
|
// Header
|
|
let headerFont = UIFont.systemFont(ofSize: 10, weight: .semibold)
|
|
let headerAttributes: [NSAttributedString.Key: Any] = [
|
|
.font: headerFont,
|
|
.foregroundColor: UIColor.gray
|
|
]
|
|
|
|
NSAttributedString(string: "Date", attributes: headerAttributes).draw(at: CGPoint(x: margin, y: currentY))
|
|
NSAttributedString(string: "Mood", attributes: headerAttributes).draw(at: CGPoint(x: margin + 120, y: currentY))
|
|
NSAttributedString(string: "Notes", attributes: headerAttributes).draw(at: CGPoint(x: margin + 200, y: currentY))
|
|
|
|
currentY += 20
|
|
|
|
// Divider
|
|
context.cgContext.setStrokeColor(UIColor(white: 0.9, alpha: 1.0).cgColor)
|
|
context.cgContext.setLineWidth(1)
|
|
context.cgContext.move(to: CGPoint(x: margin, y: currentY))
|
|
context.cgContext.addLine(to: CGPoint(x: margin + width, y: currentY))
|
|
context.cgContext.strokePath()
|
|
currentY += 8
|
|
|
|
// Entries
|
|
let bodyFont = UIFont.systemFont(ofSize: 10, weight: .regular)
|
|
|
|
for entry in recentEntries {
|
|
if currentY > pageHeight - 60 { break }
|
|
|
|
// Date
|
|
let dateAttributes: [NSAttributedString.Key: Any] = [
|
|
.font: bodyFont,
|
|
.foregroundColor: UIColor.darkGray
|
|
]
|
|
let dateString = NSAttributedString(string: shortDateFormatter.string(from: entry.forDate), attributes: dateAttributes)
|
|
dateString.draw(at: CGPoint(x: margin, y: currentY + 5))
|
|
|
|
// Mood indicator dot
|
|
let dotRect = CGRect(x: margin + 120, y: currentY + 8, width: 10, height: 10)
|
|
let dotPath = UIBezierPath(ovalIn: dotRect)
|
|
(moodColors[entry.mood] ?? .gray).setFill()
|
|
dotPath.fill()
|
|
|
|
// Mood name
|
|
let moodAttributes: [NSAttributedString.Key: Any] = [
|
|
.font: bodyFont,
|
|
.foregroundColor: moodColors[entry.mood] ?? .gray
|
|
]
|
|
let moodString = NSAttributedString(string: entry.mood.widgetDisplayName, attributes: moodAttributes)
|
|
moodString.draw(at: CGPoint(x: margin + 135, y: currentY + 5))
|
|
|
|
// Notes (truncated)
|
|
if let notes = entry.notes, !notes.isEmpty {
|
|
let truncatedNotes = notes.count > 40 ? String(notes.prefix(40)) + "..." : notes
|
|
let notesAttributes: [NSAttributedString.Key: Any] = [
|
|
.font: bodyFont,
|
|
.foregroundColor: UIColor.gray
|
|
]
|
|
let notesString = NSAttributedString(string: truncatedNotes, attributes: notesAttributes)
|
|
notesString.draw(at: CGPoint(x: margin + 200, y: currentY + 5))
|
|
}
|
|
|
|
currentY += rowHeight
|
|
}
|
|
|
|
return currentY + 20
|
|
}
|
|
|
|
// MARK: - PDF Drawing: Footer
|
|
|
|
private func drawFooter(pageWidth: CGFloat, pageHeight: CGFloat, margin: CGFloat, in context: UIGraphicsPDFRendererContext) {
|
|
let footerY = pageHeight - 30
|
|
|
|
let footerFont = UIFont.systemFont(ofSize: 9, weight: .regular)
|
|
let footerAttributes: [NSAttributedString.Key: Any] = [
|
|
.font: footerFont,
|
|
.foregroundColor: UIColor.lightGray
|
|
]
|
|
let footerString = NSAttributedString(string: "Generated by Reflect - Your Mood Tracking Companion", attributes: footerAttributes)
|
|
let footerSize = footerString.size()
|
|
footerString.draw(at: CGPoint(x: (pageWidth - footerSize.width) / 2, y: footerY))
|
|
}
|
|
|
|
// MARK: - PDF Drawing: Section Title
|
|
|
|
private func drawSectionTitle(_ title: String, at y: CGFloat, margin: CGFloat) -> CGFloat {
|
|
let font = UIFont.systemFont(ofSize: 16, weight: .bold)
|
|
let attributes: [NSAttributedString.Key: Any] = [
|
|
.font: font,
|
|
.foregroundColor: UIColor.darkGray
|
|
]
|
|
let string = NSAttributedString(string: title, attributes: attributes)
|
|
string.draw(at: CGPoint(x: margin, y: y))
|
|
return y + 30
|
|
}
|
|
|
|
// MARK: - Statistics
|
|
|
|
struct ExportStats {
|
|
let totalEntries: Int
|
|
let dateRange: String
|
|
let averageMood: Double
|
|
let mostCommonMood: String
|
|
let moodDistribution: [String: Int]
|
|
let currentStreak: Int
|
|
let longestStreak: Int
|
|
let positiveStreak: Int
|
|
let stabilityScore: Double
|
|
}
|
|
|
|
private func calculateStats(entries: [MoodEntryModel]) -> ExportStats {
|
|
let validEntries = entries.filter { ![.missing, .placeholder].contains($0.mood) }
|
|
|
|
var moodCounts: [String: Int] = [:]
|
|
var totalMoodValue = 0
|
|
|
|
for entry in validEntries {
|
|
let moodName = entry.mood.widgetDisplayName
|
|
moodCounts[moodName, default: 0] += 1
|
|
totalMoodValue += entry.moodValue + 1
|
|
}
|
|
|
|
let avgMood = validEntries.isEmpty ? 0 : Double(totalMoodValue) / Double(validEntries.count)
|
|
let mostCommon = moodCounts.max(by: { $0.value < $1.value })?.key ?? "N/A"
|
|
|
|
var dateRange = "No entries"
|
|
if let first = validEntries.last?.forDate, let last = validEntries.first?.forDate {
|
|
dateRange = "\(shortDateFormatter.string(from: first)) - \(shortDateFormatter.string(from: last))"
|
|
}
|
|
|
|
// Calculate streaks
|
|
let calendar = Calendar.current
|
|
let sortedByDateDesc = validEntries.sorted { $0.forDate > $1.forDate }
|
|
var currentStreak = 0
|
|
var longestStreak = 1
|
|
var tempStreak = 1
|
|
|
|
if let mostRecent = sortedByDateDesc.first?.forDate {
|
|
let today = calendar.startOfDay(for: Date())
|
|
let yesterday = calendar.date(byAdding: .day, value: -1, to: today)!
|
|
|
|
if calendar.isDate(mostRecent, inSameDayAs: today) || calendar.isDate(mostRecent, inSameDayAs: yesterday) {
|
|
currentStreak = 1
|
|
for i in 1..<sortedByDateDesc.count {
|
|
let dayDiff = calendar.dateComponents([.day], from: sortedByDateDesc[i].forDate, to: sortedByDateDesc[i-1].forDate).day ?? 0
|
|
if dayDiff == 1 {
|
|
currentStreak += 1
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let sortedByDateAsc = validEntries.sorted { $0.forDate < $1.forDate }
|
|
for i in 1..<sortedByDateAsc.count {
|
|
let dayDiff = calendar.dateComponents([.day], from: sortedByDateAsc[i-1].forDate, to: sortedByDateAsc[i].forDate).day ?? 0
|
|
if dayDiff == 1 {
|
|
tempStreak += 1
|
|
longestStreak = max(longestStreak, tempStreak)
|
|
} else {
|
|
tempStreak = 1
|
|
}
|
|
}
|
|
|
|
// Positive streak
|
|
var positiveStreak = 0
|
|
var tempPositive = 0
|
|
for entry in sortedByDateAsc {
|
|
if [Mood.good, Mood.great].contains(entry.mood) {
|
|
tempPositive += 1
|
|
positiveStreak = max(positiveStreak, tempPositive)
|
|
} else {
|
|
tempPositive = 0
|
|
}
|
|
}
|
|
|
|
// Stability score
|
|
var swings = 0
|
|
for i in 1..<sortedByDateAsc.count {
|
|
let diff = abs(sortedByDateAsc[i].moodValue - sortedByDateAsc[i-1].moodValue)
|
|
if diff >= 2 { swings += 1 }
|
|
}
|
|
let stabilityScore = sortedByDateAsc.count > 1 ? 1.0 - min(Double(swings) / Double(sortedByDateAsc.count - 1), 1.0) : 1.0
|
|
|
|
return ExportStats(
|
|
totalEntries: validEntries.count,
|
|
dateRange: dateRange,
|
|
averageMood: avgMood,
|
|
mostCommonMood: mostCommon,
|
|
moodDistribution: moodCounts,
|
|
currentStreak: currentStreak,
|
|
longestStreak: longestStreak,
|
|
positiveStreak: positiveStreak,
|
|
stabilityScore: stabilityScore
|
|
)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func escapeCSV(_ string: String) -> String {
|
|
var result = string
|
|
if result.contains("\"") || result.contains(",") || result.contains("\n") {
|
|
result = result.replacingOccurrences(of: "\"", with: "\"\"")
|
|
result = "\"\(result)\""
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func weekdayName(from weekday: Int) -> String {
|
|
let weekdays = ["", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
|
|
return weekdays[safe: weekday] ?? "Unknown"
|
|
}
|
|
|
|
private func formattedDate() -> String {
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "yyyy-MM-dd"
|
|
return formatter.string(from: Date())
|
|
}
|
|
}
|
|
|
|
// MARK: - EntryType Description
|
|
|
|
extension EntryType: CustomStringConvertible {
|
|
public var description: String {
|
|
switch self {
|
|
case .listView: return "Manual"
|
|
case .widget: return "Widget"
|
|
case .watch: return "Watch"
|
|
case .shortcut: return "Shortcut"
|
|
case .filledInMissing: return "Auto-filled"
|
|
case .notification: return "Notification"
|
|
case .header: return "Header"
|
|
case .siri: return "Siri"
|
|
case .controlCenter: return "Control Center"
|
|
case .liveActivity: return "Live Activity"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Safe Array Access
|
|
|
|
private extension Array {
|
|
subscript(safe index: Int) -> Element? {
|
|
return indices.contains(index) ? self[index] : nil
|
|
}
|
|
}
|