Enrich test data, fix multi-page PDF export, and polish UI
- 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>
This commit is contained in:
@@ -63,15 +63,15 @@ extension DataController {
|
|||||||
|
|
||||||
for idx in 1..<1000 {
|
for idx in 1..<1000 {
|
||||||
let date = Calendar.current.date(byAdding: .day, value: -idx, to: Date())!
|
let date = Calendar.current.date(byAdding: .day, value: -idx, to: Date())!
|
||||||
var moodValue = Int.random(in: 3...4)
|
let mood = Self.randomMood()
|
||||||
if Int.random(in: 0...400) % 5 == 0 {
|
|
||||||
moodValue = Int.random(in: 0...4)
|
|
||||||
}
|
|
||||||
|
|
||||||
let entry = MoodEntryModel(
|
let entry = MoodEntryModel(
|
||||||
forDate: date,
|
forDate: date,
|
||||||
mood: Mood(rawValue: moodValue) ?? .average,
|
mood: mood,
|
||||||
entryType: .listView
|
entryType: .listView,
|
||||||
|
notes: Self.randomNotes(),
|
||||||
|
weatherJSON: Self.randomWeatherJSON(for: date),
|
||||||
|
reflectionJSON: Self.randomReflectionJSON(for: mood, on: date)
|
||||||
)
|
)
|
||||||
modelContext.insert(entry)
|
modelContext.insert(entry)
|
||||||
}
|
}
|
||||||
@@ -85,15 +85,15 @@ extension DataController {
|
|||||||
|
|
||||||
for idx in 1...730 {
|
for idx in 1...730 {
|
||||||
let date = Calendar.current.date(byAdding: .day, value: -idx, to: Date())!
|
let date = Calendar.current.date(byAdding: .day, value: -idx, to: Date())!
|
||||||
var moodValue = Int.random(in: 3...4)
|
let mood = Self.randomMood()
|
||||||
if Int.random(in: 0...400) % 5 == 0 {
|
|
||||||
moodValue = Int.random(in: 0...4)
|
|
||||||
}
|
|
||||||
|
|
||||||
let entry = MoodEntryModel(
|
let entry = MoodEntryModel(
|
||||||
forDate: date,
|
forDate: date,
|
||||||
mood: Mood(rawValue: moodValue) ?? .average,
|
mood: mood,
|
||||||
entryType: .listView
|
entryType: .listView,
|
||||||
|
notes: Self.randomNotes(),
|
||||||
|
weatherJSON: Self.randomWeatherJSON(for: date),
|
||||||
|
reflectionJSON: Self.randomReflectionJSON(for: mood, on: date)
|
||||||
)
|
)
|
||||||
modelContext.insert(entry)
|
modelContext.insert(entry)
|
||||||
}
|
}
|
||||||
@@ -102,6 +102,121 @@ extension DataController {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
private static func randomMood() -> Mood {
|
||||||
|
var moodValue = Int.random(in: 3...4)
|
||||||
|
if Int.random(in: 0...400) % 5 == 0 {
|
||||||
|
moodValue = Int.random(in: 0...4)
|
||||||
|
}
|
||||||
|
return Mood(rawValue: moodValue) ?? .average
|
||||||
|
}
|
||||||
|
|
||||||
|
// ~40% of entries get notes
|
||||||
|
private static func randomNotes() -> String? {
|
||||||
|
guard Bool.random() && Bool.random() == false else { return nil }
|
||||||
|
return sampleNotes.randomElement()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let sampleNotes: [String] = [
|
||||||
|
"Had a productive morning and got a lot done.",
|
||||||
|
"Went for a long walk in the park today.",
|
||||||
|
"Feeling grateful for the little things.",
|
||||||
|
"Tough meeting at work but pushed through.",
|
||||||
|
"Spent quality time with family this evening.",
|
||||||
|
"Couldn't sleep well last night, feeling tired.",
|
||||||
|
"Great workout at the gym today!",
|
||||||
|
"Read a really interesting book chapter.",
|
||||||
|
"Cooked a new recipe and it turned out great.",
|
||||||
|
"Feeling a bit anxious about tomorrow.",
|
||||||
|
"Had coffee with an old friend, felt wonderful.",
|
||||||
|
"Rainy day but cozy inside with a good movie.",
|
||||||
|
"Got some bad news, trying to stay positive.",
|
||||||
|
"Meditation session helped clear my mind.",
|
||||||
|
"Busy day but managed to stay focused.",
|
||||||
|
"Enjoyed a quiet evening at home.",
|
||||||
|
"Felt overwhelmed by my to-do list.",
|
||||||
|
"Beautiful sunset on the drive home.",
|
||||||
|
"Tried journaling for the first time in a while.",
|
||||||
|
"Had a really meaningful conversation today.",
|
||||||
|
]
|
||||||
|
|
||||||
|
// ~25% of entries get guided reflections
|
||||||
|
private static func randomReflectionJSON(for mood: Mood, on date: Date) -> String? {
|
||||||
|
guard Int.random(in: 0...3) == 0 else { return nil }
|
||||||
|
let category = MoodCategory(from: mood)
|
||||||
|
let questions = GuidedReflection.questions(for: category)
|
||||||
|
let answers = sampleAnswers(for: category)
|
||||||
|
let responses = questions.enumerated().map { index, question in
|
||||||
|
GuidedReflection.Response(
|
||||||
|
id: index,
|
||||||
|
question: question,
|
||||||
|
answer: answers[index % answers.count]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let reflection = GuidedReflection(
|
||||||
|
moodCategory: category,
|
||||||
|
responses: responses,
|
||||||
|
completedAt: date
|
||||||
|
)
|
||||||
|
return reflection.encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func sampleAnswers(for category: MoodCategory) -> [String] {
|
||||||
|
switch category {
|
||||||
|
case .positive:
|
||||||
|
return [
|
||||||
|
"I felt a sense of calm and contentment throughout the day.",
|
||||||
|
"Connecting with a friend really lifted my spirits.",
|
||||||
|
"I want to keep making time for the things that bring me joy.",
|
||||||
|
]
|
||||||
|
case .neutral:
|
||||||
|
return [
|
||||||
|
"A mix of emotions today, nothing too strong in either direction.",
|
||||||
|
"Work was steady but unremarkable.",
|
||||||
|
"I'd like to be more intentional about how I spend my evenings.",
|
||||||
|
"Maybe adding a short walk after lunch could help.",
|
||||||
|
]
|
||||||
|
case .negative:
|
||||||
|
return [
|
||||||
|
"I've been worrying about things outside my control.",
|
||||||
|
"A stressful interaction this morning set the tone for the day.",
|
||||||
|
"I think I need some quiet time to recharge.",
|
||||||
|
"Taking a few deep breaths and stepping away from screens.",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let weatherConditions: [(symbol: String, condition: String)] = [
|
||||||
|
("sun.max.fill", "Clear"),
|
||||||
|
("cloud.sun.fill", "Partly Cloudy"),
|
||||||
|
("cloud.fill", "Cloudy"),
|
||||||
|
("cloud.rain.fill", "Rain"),
|
||||||
|
("cloud.heavyrain.fill", "Heavy Rain"),
|
||||||
|
("cloud.drizzle.fill", "Drizzle"),
|
||||||
|
("cloud.bolt.fill", "Thunderstorms"),
|
||||||
|
("cloud.snow.fill", "Snow"),
|
||||||
|
("cloud.fog.fill", "Foggy"),
|
||||||
|
("wind", "Windy"),
|
||||||
|
("sun.haze.fill", "Hazy"),
|
||||||
|
]
|
||||||
|
|
||||||
|
private static func randomWeatherJSON(for date: Date) -> String? {
|
||||||
|
let condition = weatherConditions.randomElement()!
|
||||||
|
let high = Double.random(in: 5...35)
|
||||||
|
let low = high - Double.random(in: 5...15)
|
||||||
|
let weather = WeatherData(
|
||||||
|
conditionSymbol: condition.symbol,
|
||||||
|
condition: condition.condition,
|
||||||
|
temperature: (high + low) / 2.0,
|
||||||
|
highTemperature: high,
|
||||||
|
lowTemperature: low,
|
||||||
|
humidity: Double.random(in: 0.2...0.95),
|
||||||
|
latitude: 37.7749,
|
||||||
|
longitude: -122.4194,
|
||||||
|
fetchedAt: date
|
||||||
|
)
|
||||||
|
return weather.encode()
|
||||||
|
}
|
||||||
|
|
||||||
func longestStreak() -> [MoodEntryModel] {
|
func longestStreak() -> [MoodEntryModel] {
|
||||||
let descriptor = FetchDescriptor<MoodEntryModel>(
|
let descriptor = FetchDescriptor<MoodEntryModel>(
|
||||||
sortBy: [SortDescriptor(\.forDate, order: .forward)]
|
sortBy: [SortDescriptor(\.forDate, order: .forward)]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import UIKit
|
||||||
import WebKit
|
import WebKit
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -60,7 +61,7 @@ final class ReportPDFGenerator {
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=612, initial-scale=1">
|
||||||
<style>
|
<style>
|
||||||
\(cssStyles)
|
\(cssStyles)
|
||||||
</style>
|
</style>
|
||||||
@@ -294,7 +295,7 @@ final class ReportPDFGenerator {
|
|||||||
let webView = WKWebView(frame: CGRect(x: 0, y: 0, width: 612, height: 792))
|
let webView = WKWebView(frame: CGRect(x: 0, y: 0, width: 612, height: 792))
|
||||||
webView.isOpaque = false
|
webView.isOpaque = false
|
||||||
|
|
||||||
// Load HTML and wait for it to render
|
// Load HTML and wait for it to finish rendering
|
||||||
return try await withCheckedThrowingContinuation { continuation in
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
let delegate = PDFNavigationDelegate { result in
|
let delegate = PDFNavigationDelegate { result in
|
||||||
switch result {
|
switch result {
|
||||||
@@ -443,18 +444,29 @@ private class PDFNavigationDelegate: NSObject, WKNavigationDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
let config = WKPDFConfiguration()
|
// Use UIPrintPageRenderer for proper multi-page pagination.
|
||||||
config.rect = CGRect(x: 0, y: 0, width: 612, height: 792) // US Letter
|
// createPDF(configuration:) only captures a single rect — it does NOT paginate.
|
||||||
|
let renderer = UIPrintPageRenderer()
|
||||||
|
renderer.addPrintFormatter(webView.viewPrintFormatter(), startingAtPageAt: 0)
|
||||||
|
|
||||||
webView.createPDF(configuration: config) { [weak self] result in
|
// US Letter size (72 points per inch: 8.5 x 11 inches)
|
||||||
DispatchQueue.main.async {
|
let pageRect = CGRect(x: 0, y: 0, width: 612, height: 792)
|
||||||
switch result {
|
renderer.setValue(pageRect, forKey: "paperRect")
|
||||||
case .success(let data):
|
renderer.setValue(pageRect, forKey: "printableRect")
|
||||||
self?.completion(.success(data))
|
|
||||||
case .failure(let error):
|
let pdfData = NSMutableData()
|
||||||
self?.completion(.failure(ReportPDFGenerator.PDFError.pdfGenerationFailed(underlying: error)))
|
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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ struct WeatherCardView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 16)
|
RoundedRectangle(cornerRadius: 16)
|
||||||
.fill(Color(.systemBackground))
|
.fill(Color(.secondarySystemBackground))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,18 @@ struct EntryListView: View {
|
|||||||
entry.moodValue == Mood.missing.rawValue
|
entry.moodValue == Mood.missing.rawValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var hasNotes: Bool {
|
||||||
|
if let notes = entry.notes, !notes.isEmpty { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasReflection: Bool {
|
||||||
|
if let json = entry.reflectionJSON,
|
||||||
|
let reflection = GuidedReflection.decode(from: json),
|
||||||
|
reflection.answeredCount > 0 { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Cached Date Strings (avoids repeated ICU/Calendar operations)
|
// MARK: - Cached Date Strings (avoids repeated ICU/Calendar operations)
|
||||||
private var dateCache: DateFormattingCache { DateFormattingCache.shared }
|
private var dateCache: DateFormattingCache { DateFormattingCache.shared }
|
||||||
private var cachedDay: String { dateCache.string(for: entry.forDate, format: .day) }
|
private var cachedDay: String { dateCache.string(for: entry.forDate, format: .day) }
|
||||||
@@ -92,6 +104,23 @@ struct EntryListView: View {
|
|||||||
orbitStyle
|
orbitStyle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.overlay(alignment: .bottomTrailing) {
|
||||||
|
if !isMissing && (hasNotes || hasReflection) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
if hasNotes {
|
||||||
|
Image(systemName: "note.text")
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
if hasReflection {
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.trailing, 12)
|
||||||
|
.padding(.bottom, 6)
|
||||||
|
}
|
||||||
|
}
|
||||||
.accessibilityElement(children: .combine)
|
.accessibilityElement(children: .combine)
|
||||||
.accessibilityIdentifier(AccessibilityID.DayView.entryRow(dateString: cachedYearMonthDayDigits))
|
.accessibilityIdentifier(AccessibilityID.DayView.entryRow(dateString: cachedYearMonthDayDigits))
|
||||||
.accessibilityLabel(accessibilityDescription)
|
.accessibilityLabel(accessibilityDescription)
|
||||||
|
|||||||
@@ -254,6 +254,12 @@ struct ReportsView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
.padding()
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.fill(colorScheme == .dark ? Color(.systemGray6) : .white)
|
||||||
|
)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
viewModel.showPrivacyConfirmation = true
|
viewModel.showPrivacyConfirmation = true
|
||||||
@@ -269,14 +275,9 @@ struct ReportsView: View {
|
|||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
.accessibilityIdentifier(AccessibilityID.Reports.exportButton)
|
.accessibilityIdentifier(AccessibilityID.Reports.exportButton)
|
||||||
}
|
}
|
||||||
.padding()
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 14)
|
|
||||||
.fill(colorScheme == .dark ? Color(.systemGray6) : .white)
|
|
||||||
)
|
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Error Card
|
// MARK: - Error Card
|
||||||
|
|||||||
Reference in New Issue
Block a user