\(report.overview.dateRange)
+Generated \(generatedDate)
+\(month.entryCount) entries · Average mood: \(String(format: "%.1f", month.averageMood))/5
+ \(month.aiSummary.map { "\(year.entryCount) entries · Average mood: \(String(format: "%.1f", year.averageMood))/5
+ \(year.aiSummary.map { "| Date | +Mood | +Notes | +Weather | +
|---|
\(escapeHTML($0.question))
\(escapeHTML($0.answer))
" } + .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 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) -> 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/Services/WeatherManager.swift b/Shared/Services/WeatherManager.swift new file mode 100644 index 0000000..fb63adf --- /dev/null +++ b/Shared/Services/WeatherManager.swift @@ -0,0 +1,149 @@ +// +// WeatherManager.swift +// Reflect +// +// WeatherKit fetch service for attaching weather data to mood entries. +// + +import Foundation +import WeatherKit +import CoreLocation +import os.log + +@MainActor +final class WeatherManager { + static let shared = WeatherManager() + + private static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "com.88oakapps.reflect", + category: "WeatherManager" + ) + + private static let retryQueueKey = "weatherRetryQueue" + + private init() {} + + // MARK: - Fetch Weather + + func fetchWeather(for date: Date, at location: CLLocation) async throws -> WeatherData { + let calendar = Calendar.current + let startOfDay = calendar.startOfDay(for: date) + let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)! + + let (daily, current) = try await WeatherService.shared.weather( + for: location, + including: .daily(startDate: startOfDay, endDate: endOfDay), .current + ) + + guard let dayWeather = daily.forecast.first else { + throw WeatherError.noDataAvailable + } + + return WeatherData( + conditionSymbol: dayWeather.symbolName, + condition: dayWeather.condition.description, + temperature: (dayWeather.highTemperature.value + dayWeather.lowTemperature.value) / 2.0, + highTemperature: dayWeather.highTemperature.value, + lowTemperature: dayWeather.lowTemperature.value, + humidity: current.humidity, + latitude: location.coordinate.latitude, + longitude: location.coordinate.longitude, + fetchedAt: Date() + ) + } + + // MARK: - Fetch and Save + + func fetchAndSaveWeather(for date: Date) async { + do { + let location = try await LocationManager.shared.currentLocation + + let weatherData = try await fetchWeather(for: date, at: location) + + guard let json = weatherData.encode() else { + Self.logger.error("Failed to encode weather data") + return + } + + DataController.shared.updateWeather(forDate: date, weatherJSON: json) + AnalyticsManager.shared.track(.weatherFetched) + + Self.logger.info("Weather saved for \(date)") + } catch { + Self.logger.error("Weather fetch failed: \(error.localizedDescription)") + AnalyticsManager.shared.track(.weatherFetchFailed(error: error.localizedDescription)) + addToRetryQueue(date: date) + } + } + + // MARK: - Retry Queue + + func retryPendingWeatherFetches() async { + let dates = getRetryQueue() + guard !dates.isEmpty else { return } + + Self.logger.info("Retrying weather fetch for \(dates.count) entries") + + var remainingDates: [Date] = [] + + for date in dates { + do { + let location = try await LocationManager.shared.currentLocation + let weatherData = try await fetchWeather(for: date, at: location) + + guard let json = weatherData.encode() else { continue } + + DataController.shared.updateWeather(forDate: date, weatherJSON: json) + Self.logger.info("Retry succeeded for \(date)") + } catch { + Self.logger.error("Retry failed for \(date): \(error.localizedDescription)") + remainingDates.append(date) + } + } + + saveRetryQueue(remainingDates) + } + + private func addToRetryQueue(date: Date) { + var queue = getRetryQueue() + let startOfDay = Calendar.current.startOfDay(for: date) + + // Don't add duplicates + guard !queue.contains(where: { Calendar.current.isDate($0, inSameDayAs: startOfDay) }) else { return } + + queue.append(startOfDay) + + // Keep only last 7 days of retries + let sevenDaysAgo = Calendar.current.date(byAdding: .day, value: -7, to: Date())! + queue = queue.filter { $0 > sevenDaysAgo } + + saveRetryQueue(queue) + } + + private func getRetryQueue() -> [Date] { + guard let strings = GroupUserDefaults.groupDefaults.stringArray(forKey: Self.retryQueueKey) else { + return [] + } + let formatter = ISO8601DateFormatter() + return strings.compactMap { formatter.date(from: $0) } + } + + private func saveRetryQueue(_ dates: [Date]) { + let formatter = ISO8601DateFormatter() + let strings = dates.map { formatter.string(from: $0) } + GroupUserDefaults.groupDefaults.set(strings, forKey: Self.retryQueueKey) + } + + // MARK: - Error + + enum WeatherError: LocalizedError { + case noDataAvailable + + var errorDescription: String? { + switch self { + case .noDataAvailable: + return "No weather data available for the requested date" + } + } + } +} diff --git a/Shared/Views/DayView/WeatherCardView.swift b/Shared/Views/DayView/WeatherCardView.swift new file mode 100644 index 0000000..5588f23 --- /dev/null +++ b/Shared/Views/DayView/WeatherCardView.swift @@ -0,0 +1,70 @@ +// +// WeatherCardView.swift +// Reflect +// +// Visual weather card shown in EntryDetailView. +// + +import SwiftUI + +struct WeatherCardView: View { + let weatherData: WeatherData + + private var highTemp: String { + formatTemperature(weatherData.highTemperature) + } + + private var lowTemp: String { + formatTemperature(weatherData.lowTemperature) + } + + private var humidityPercent: String { + "\(Int(weatherData.humidity * 100))%" + } + + var body: some View { + HStack(spacing: 14) { + Image(systemName: weatherData.conditionSymbol) + .font(.system(size: 36)) + .symbolRenderingMode(.multicolor) + .frame(width: 44) + + VStack(alignment: .leading, spacing: 4) { + Text(weatherData.condition) + .font(.subheadline) + .fontWeight(.medium) + + HStack(spacing: 8) { + Label(String(localized: "H: \(highTemp)"), systemImage: "thermometer.high") + Label(String(localized: "L: \(lowTemp)"), systemImage: "thermometer.low") + Label(humidityPercent, systemImage: "humidity") + } + .font(.caption) + .foregroundStyle(.secondary) + .labelStyle(.titleOnly) + } + + Spacer() + } + .padding() + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color(.systemBackground)) + ) + } + + private func formatTemperature(_ celsius: Double) -> String { + let measurement = Measurement(value: celsius, unit: UnitTemperature.celsius) + let formatter = MeasurementFormatter() + formatter.unitOptions = .providedUnit + formatter.numberFormatter.maximumFractionDigits = 0 + formatter.unitStyle = .short + // Use locale-aware conversion + let locale = Locale.current + if locale.measurementSystem == .us { + let fahrenheit = measurement.converted(to: .fahrenheit) + return formatter.string(from: fahrenheit) + } + return formatter.string(from: measurement) + } +} diff --git a/Shared/Views/GuidedReflectionView.swift b/Shared/Views/GuidedReflectionView.swift new file mode 100644 index 0000000..7206b05 --- /dev/null +++ b/Shared/Views/GuidedReflectionView.swift @@ -0,0 +1,297 @@ +// +// GuidedReflectionView.swift +// Reflect +// +// Card-step guided reflection sheet — one question at a time. +// + +import SwiftUI + +struct GuidedReflectionView: View { + + @Environment(\.dismiss) private var dismiss + @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system + @AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome + @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default + + let entry: MoodEntryModel + + @State private var reflection: GuidedReflection + @State private var currentStep: Int = 0 + @State private var isSaving = false + @State private var showDiscardAlert = false + @FocusState private var isTextFieldFocused: Bool + + /// Snapshot of the initial state to detect unsaved changes + private let initialReflection: GuidedReflection + + private let maxCharacters = 500 + + private var textColor: Color { theme.currentTheme.labelColor } + + private var totalSteps: Int { reflection.totalQuestions } + + private var hasUnsavedChanges: Bool { + reflection != initialReflection + } + + init(entry: MoodEntryModel) { + self.entry = entry + let existing = entry.reflectionJSON.flatMap { GuidedReflection.decode(from: $0) } + ?? GuidedReflection.createNew(for: entry.mood) + self._reflection = State(initialValue: existing) + self.initialReflection = existing + } + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + // Entry header + entryHeader + .padding() + .background(Color(.systemGray6)) + + Divider() + + // Progress dots + progressDots + .padding(.top, 20) + .padding(.bottom, 8) + + // Question + answer area + questionContent + .padding(.horizontal) + + Spacer() + + // Navigation buttons + navigationButtons + .padding() + } + .navigationTitle(String(localized: "guided_reflection_title")) + .navigationBarTitleDisplayMode(.inline) + .accessibilityIdentifier(AccessibilityID.GuidedReflection.sheet) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(String(localized: "Cancel")) { + if hasUnsavedChanges { + showDiscardAlert = true + } else { + dismiss() + } + } + .accessibilityIdentifier(AccessibilityID.GuidedReflection.cancelButton) + } + + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { + isTextFieldFocused = false + } + } + } + .alert( + String(localized: "guided_reflection_unsaved_title"), + isPresented: $showDiscardAlert + ) { + Button(String(localized: "guided_reflection_discard"), role: .destructive) { + dismiss() + } + Button(String(localized: "Cancel"), role: .cancel) { } + } message: { + Text(String(localized: "guided_reflection_unsaved_message")) + } + .trackScreen(.guidedReflection) + } + } + + // MARK: - Entry Header + + private var entryHeader: some View { + HStack(spacing: 12) { + Circle() + .fill(moodTint.color(forMood: entry.mood).opacity(0.2)) + .frame(width: 50, height: 50) + .overlay( + imagePack.icon(forMood: entry.mood) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 28, height: 28) + .foregroundColor(moodTint.color(forMood: entry.mood)) + ) + + VStack(alignment: .leading, spacing: 4) { + Text(entry.forDate, format: .dateTime.weekday(.wide).month().day().year()) + .font(.headline) + .foregroundColor(textColor) + + Text(entry.moodString) + .font(.subheadline) + .foregroundColor(moodTint.color(forMood: entry.mood)) + } + + Spacer() + } + } + + // MARK: - Progress Dots + + private var progressDots: some View { + HStack(spacing: 8) { + ForEach(0..