Merge branch '1.1'
This commit is contained in:
@@ -1,52 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(find:*)",
|
|
||||||
"Bash(xcodebuild:*)",
|
|
||||||
"Bash(cat:*)",
|
|
||||||
"Bash(grep:*)",
|
|
||||||
"WebSearch",
|
|
||||||
"WebFetch(domain:swiftwithmajid.com)",
|
|
||||||
"WebFetch(domain:azamsharp.com)",
|
|
||||||
"WebFetch(domain:www.createwithswift.com)",
|
|
||||||
"Skill(frontend-design:frontend-design)",
|
|
||||||
"Bash(npx claude-plugins:*)",
|
|
||||||
"Bash(cloc:*)",
|
|
||||||
"Bash(swift -parse:*)",
|
|
||||||
"Bash(swiftc:*)",
|
|
||||||
"Bash(git add:*)",
|
|
||||||
"WebFetch(domain:apps.apple.com)",
|
|
||||||
"Bash(ls:*)",
|
|
||||||
"Bash(python3:*)",
|
|
||||||
"Bash( comm -23 /tmp/code_keys.txt /tmp/xcstrings_keys.txt)",
|
|
||||||
"Bash( comm -13 /tmp/code_keys.txt /tmp/xcstrings_keys.txt)",
|
|
||||||
"Bash(xargs cat:*)",
|
|
||||||
"Bash(xcrun simctl:*)",
|
|
||||||
"Bash(curl:*)",
|
|
||||||
"Bash(open:*)",
|
|
||||||
"Bash(npx skills:*)",
|
|
||||||
"Bash(npx create-video@latest:*)",
|
|
||||||
"Bash(npm install:*)",
|
|
||||||
"Bash(npm run build:*)",
|
|
||||||
"Bash(npx remotion render:*)",
|
|
||||||
"Bash(ffmpeg:*)",
|
|
||||||
"Bash(sips:*)",
|
|
||||||
"Bash(unzip:*)",
|
|
||||||
"Bash(plutil:*)",
|
|
||||||
"Bash(done)",
|
|
||||||
"Bash(for:*)",
|
|
||||||
"Bash(# Check Button and Label strings grep -rE ''\\(Button|Label|navigationTitle\\)\\\\\\(\"\"'' /Users/treyt/Desktop/code/Feels/Shared/ --include=\"\"*.swift\"\")",
|
|
||||||
"Bash(# Double-check a few of these strings to make sure they''re not used echo \"\"=== Checking ''Custom'' ===\"\" grep -rn ''\"\"Custom\"\"'' /Users/treyt/Desktop/code/Feels/Shared/ --include=\"\"*.swift\"\")",
|
|
||||||
"Bash(echo \"=== How ''3D card flip'' is used ===\" grep -rn \"3D card flip\" /Users/treyt/Desktop/code/Feels/Shared/ --include=\"*.swift\")",
|
|
||||||
"Bash(ffprobe:*)",
|
|
||||||
"Bash(npx remotion:*)",
|
|
||||||
"Bash(npx tsc:*)",
|
|
||||||
"Bash(npm start)",
|
|
||||||
"Bash(npm run:*)"
|
|
||||||
],
|
|
||||||
"ask": [
|
|
||||||
"Bash(git commit:*)",
|
|
||||||
"Bash(git push:*)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/remotion-best-practices
|
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,3 +1,9 @@
|
|||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Claude Code
|
||||||
|
.claude/
|
||||||
|
|
||||||
# Xcode
|
# Xcode
|
||||||
#
|
#
|
||||||
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
|
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>CloudKit</string>
|
<string>CloudKit</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>com.apple.developer.weatherkit</key>
|
||||||
|
<true/>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.com.88oakapps.reflect</string>
|
<string>group.com.88oakapps.reflect</string>
|
||||||
|
|||||||
@@ -5,7 +5,10 @@
|
|||||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
<array>
|
<array>
|
||||||
<string>com.88oakapps.reflect.dbUpdateMissing</string>
|
<string>com.88oakapps.reflect.dbUpdateMissing</string>
|
||||||
|
<string>com.88oakapps.reflect.weatherRetry</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
|
<string>Reflect uses your location to show weather details for your mood entries.</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -58,6 +58,20 @@ enum AccessibilityID {
|
|||||||
static let cancelButton = "note_editor_cancel"
|
static let cancelButton = "note_editor_cancel"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Guided Reflection
|
||||||
|
enum GuidedReflection {
|
||||||
|
static let sheet = "guided_reflection_sheet"
|
||||||
|
static let progressDots = "guided_reflection_progress"
|
||||||
|
static let textEditor = "guided_reflection_text_editor"
|
||||||
|
static let nextButton = "guided_reflection_next"
|
||||||
|
static let backButton = "guided_reflection_back"
|
||||||
|
static let saveButton = "guided_reflection_save"
|
||||||
|
static let cancelButton = "guided_reflection_cancel"
|
||||||
|
static func questionLabel(step: Int) -> String {
|
||||||
|
"guided_reflection_question_\(step)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Settings
|
// MARK: - Settings
|
||||||
enum Settings {
|
enum Settings {
|
||||||
static let header = "settings_header"
|
static let header = "settings_header"
|
||||||
@@ -153,6 +167,20 @@ enum AccessibilityID {
|
|||||||
static let skipButton = "onboarding_skip_button"
|
static let skipButton = "onboarding_skip_button"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Reports
|
||||||
|
enum Reports {
|
||||||
|
static let segmentedPicker = "reports_segmented_picker"
|
||||||
|
static let dateRangePicker = "reports_date_range_picker"
|
||||||
|
static let quickSummaryButton = "reports_quick_summary_button"
|
||||||
|
static let detailedReportButton = "reports_detailed_report_button"
|
||||||
|
static let generateButton = "reports_generate_button"
|
||||||
|
static let progressView = "reports_progress_view"
|
||||||
|
static let cancelButton = "reports_cancel_button"
|
||||||
|
static let exportButton = "reports_export_button"
|
||||||
|
static let privacyConfirmation = "reports_privacy_confirmation"
|
||||||
|
static let minimumEntriesWarning = "reports_minimum_entries_warning"
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Common
|
// MARK: - Common
|
||||||
enum Common {
|
enum Common {
|
||||||
static let lockScreen = "lock_screen"
|
static let lockScreen = "lock_screen"
|
||||||
|
|||||||
@@ -350,6 +350,7 @@ extension AnalyticsManager {
|
|||||||
case paywall = "paywall"
|
case paywall = "paywall"
|
||||||
case entryDetail = "entry_detail"
|
case entryDetail = "entry_detail"
|
||||||
case noteEditor = "note_editor"
|
case noteEditor = "note_editor"
|
||||||
|
case guidedReflection = "guided_reflection"
|
||||||
case lockScreen = "lock_screen"
|
case lockScreen = "lock_screen"
|
||||||
case sharing = "sharing"
|
case sharing = "sharing"
|
||||||
case themePicker = "theme_picker"
|
case themePicker = "theme_picker"
|
||||||
@@ -368,6 +369,7 @@ extension AnalyticsManager {
|
|||||||
case specialThanks = "special_thanks"
|
case specialThanks = "special_thanks"
|
||||||
case reminderTimePicker = "reminder_time_picker"
|
case reminderTimePicker = "reminder_time_picker"
|
||||||
case exportView = "export_view"
|
case exportView = "export_view"
|
||||||
|
case reports = "reports"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,6 +383,8 @@ extension AnalyticsManager {
|
|||||||
case noteUpdated(characterCount: Int)
|
case noteUpdated(characterCount: Int)
|
||||||
case photoAdded
|
case photoAdded
|
||||||
case photoDeleted
|
case photoDeleted
|
||||||
|
case reflectionStarted
|
||||||
|
case reflectionCompleted(answeredCount: Int)
|
||||||
case missingEntriesFilled(count: Int)
|
case missingEntriesFilled(count: Int)
|
||||||
case entryDeleted(mood: Int)
|
case entryDeleted(mood: Int)
|
||||||
case allDataCleared
|
case allDataCleared
|
||||||
@@ -451,6 +455,11 @@ extension AnalyticsManager {
|
|||||||
case healthKitNotAuthorized
|
case healthKitNotAuthorized
|
||||||
case healthKitSyncCompleted(total: Int, success: Int, failed: Int)
|
case healthKitSyncCompleted(total: Int, success: Int, failed: Int)
|
||||||
|
|
||||||
|
// MARK: Weather
|
||||||
|
case weatherToggled(enabled: Bool)
|
||||||
|
case weatherFetched
|
||||||
|
case weatherFetchFailed(error: String)
|
||||||
|
|
||||||
// MARK: Navigation
|
// MARK: Navigation
|
||||||
case tabSwitched(tab: String)
|
case tabSwitched(tab: String)
|
||||||
case viewHeaderChanged(header: String)
|
case viewHeaderChanged(header: String)
|
||||||
@@ -458,6 +467,12 @@ extension AnalyticsManager {
|
|||||||
// MARK: Sharing
|
// MARK: Sharing
|
||||||
case shareTemplateViewed(template: String)
|
case shareTemplateViewed(template: String)
|
||||||
|
|
||||||
|
// MARK: Reports
|
||||||
|
case reportGenerated(type: String, entryCount: Int, daySpan: Int)
|
||||||
|
case reportExported(type: String, entryCount: Int)
|
||||||
|
case reportGenerationFailed(error: String)
|
||||||
|
case reportCancelled
|
||||||
|
|
||||||
// MARK: Error
|
// MARK: Error
|
||||||
case storageFallbackActivated
|
case storageFallbackActivated
|
||||||
|
|
||||||
@@ -479,6 +494,10 @@ extension AnalyticsManager {
|
|||||||
return ("photo_added", nil)
|
return ("photo_added", nil)
|
||||||
case .photoDeleted:
|
case .photoDeleted:
|
||||||
return ("photo_deleted", nil)
|
return ("photo_deleted", nil)
|
||||||
|
case .reflectionStarted:
|
||||||
|
return ("reflection_started", nil)
|
||||||
|
case .reflectionCompleted(let count):
|
||||||
|
return ("reflection_completed", ["answered_count": count])
|
||||||
case .missingEntriesFilled(let count):
|
case .missingEntriesFilled(let count):
|
||||||
return ("missing_entries_filled", ["count": count])
|
return ("missing_entries_filled", ["count": count])
|
||||||
case .entryDeleted(let mood):
|
case .entryDeleted(let mood):
|
||||||
@@ -604,6 +623,14 @@ extension AnalyticsManager {
|
|||||||
case .healthKitSyncCompleted(let total, let success, let failed):
|
case .healthKitSyncCompleted(let total, let success, let failed):
|
||||||
return ("healthkit_sync_completed", ["total": total, "success": success, "failed": failed])
|
return ("healthkit_sync_completed", ["total": total, "success": success, "failed": failed])
|
||||||
|
|
||||||
|
// Weather
|
||||||
|
case .weatherToggled(let enabled):
|
||||||
|
return ("weather_toggled", ["enabled": enabled])
|
||||||
|
case .weatherFetched:
|
||||||
|
return ("weather_fetched", nil)
|
||||||
|
case .weatherFetchFailed(let error):
|
||||||
|
return ("weather_fetch_failed", ["error": error])
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
case .tabSwitched(let tab):
|
case .tabSwitched(let tab):
|
||||||
return ("tab_switched", ["tab": tab])
|
return ("tab_switched", ["tab": tab])
|
||||||
@@ -614,6 +641,16 @@ extension AnalyticsManager {
|
|||||||
case .shareTemplateViewed(let template):
|
case .shareTemplateViewed(let template):
|
||||||
return ("share_template_viewed", ["template": template])
|
return ("share_template_viewed", ["template": template])
|
||||||
|
|
||||||
|
// Reports
|
||||||
|
case .reportGenerated(let type, let entryCount, let daySpan):
|
||||||
|
return ("report_generated", ["type": type, "entry_count": entryCount, "day_span": daySpan])
|
||||||
|
case .reportExported(let type, let entryCount):
|
||||||
|
return ("report_exported", ["type": type, "entry_count": entryCount])
|
||||||
|
case .reportGenerationFailed(let error):
|
||||||
|
return ("report_generation_failed", ["error": error])
|
||||||
|
case .reportCancelled:
|
||||||
|
return ("report_cancelled", nil)
|
||||||
|
|
||||||
// Error
|
// Error
|
||||||
case .storageFallbackActivated:
|
case .storageFallbackActivated:
|
||||||
return ("storage_fallback_activated", nil)
|
return ("storage_fallback_activated", nil)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import BackgroundTasks
|
|||||||
|
|
||||||
class BGTask {
|
class BGTask {
|
||||||
static let updateDBMissingID = "com.88oakapps.reflect.dbUpdateMissing"
|
static let updateDBMissingID = "com.88oakapps.reflect.dbUpdateMissing"
|
||||||
|
static let weatherRetryID = "com.88oakapps.reflect.weatherRetry"
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
class func runFillInMissingDatesTask(task: BGProcessingTask) {
|
class func runFillInMissingDatesTask(task: BGProcessingTask) {
|
||||||
@@ -27,6 +28,33 @@ class BGTask {
|
|||||||
task.setTaskCompleted(success: true)
|
task.setTaskCompleted(success: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class func runWeatherRetryTask(task: BGProcessingTask) {
|
||||||
|
BGTask.scheduleWeatherRetry()
|
||||||
|
|
||||||
|
task.expirationHandler = {
|
||||||
|
task.setTaskCompleted(success: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await WeatherManager.shared.retryPendingWeatherFetches()
|
||||||
|
task.setTaskCompleted(success: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class func scheduleWeatherRetry() {
|
||||||
|
let request = BGProcessingTaskRequest(identifier: BGTask.weatherRetryID)
|
||||||
|
request.requiresNetworkConnectivity = true
|
||||||
|
request.requiresExternalPower = false
|
||||||
|
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try BGTaskScheduler.shared.submit(request)
|
||||||
|
} catch {
|
||||||
|
print("Could not schedule weather retry: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class func scheduleBackgroundProcessing() {
|
class func scheduleBackgroundProcessing() {
|
||||||
let request = BGProcessingTaskRequest(identifier: BGTask.updateDBMissingID)
|
let request = BGProcessingTaskRequest(identifier: BGTask.updateDBMissingID)
|
||||||
request.requiresNetworkConnectivity = false
|
request.requiresNetworkConnectivity = false
|
||||||
|
|||||||
148
Shared/Models/AIReport.swift
Normal file
148
Shared/Models/AIReport.swift
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
//
|
||||||
|
// AIReport.swift
|
||||||
|
// Reflect
|
||||||
|
//
|
||||||
|
// Data models for AI-generated mood reports with PDF export.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import FoundationModels
|
||||||
|
|
||||||
|
// MARK: - Report Type
|
||||||
|
|
||||||
|
enum ReportType: String, CaseIterable {
|
||||||
|
case quickSummary = "Quick Summary"
|
||||||
|
case detailed = "Detailed Report"
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .quickSummary: return "doc.text"
|
||||||
|
case .detailed: return "doc.text.magnifyingglass"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .quickSummary: return "AI overview of your mood patterns"
|
||||||
|
case .detailed: return "Week-by-week analysis with full data"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Report Entry (decoupled from SwiftData)
|
||||||
|
|
||||||
|
struct ReportEntry {
|
||||||
|
let date: Date
|
||||||
|
let mood: Mood
|
||||||
|
let notes: String?
|
||||||
|
let weather: WeatherData?
|
||||||
|
let reflection: GuidedReflection?
|
||||||
|
|
||||||
|
init(from model: MoodEntryModel) {
|
||||||
|
self.date = model.forDate
|
||||||
|
self.mood = model.mood
|
||||||
|
self.notes = model.notes
|
||||||
|
self.weather = model.weatherJSON.flatMap { WeatherData.decode(from: $0) }
|
||||||
|
self.reflection = model.reflectionJSON.flatMap { GuidedReflection.decode(from: $0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Report Overview Stats
|
||||||
|
|
||||||
|
struct ReportOverviewStats {
|
||||||
|
let totalEntries: Int
|
||||||
|
let averageMood: Double
|
||||||
|
let moodDistribution: [Mood: Int]
|
||||||
|
let trend: String
|
||||||
|
let dateRange: String
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Report Week
|
||||||
|
|
||||||
|
struct ReportWeek: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let weekNumber: Int
|
||||||
|
let startDate: Date
|
||||||
|
let endDate: Date
|
||||||
|
let entries: [ReportEntry]
|
||||||
|
var aiSummary: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Report Month Summary
|
||||||
|
|
||||||
|
struct ReportMonthSummary: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let month: Int
|
||||||
|
let year: Int
|
||||||
|
let entryCount: Int
|
||||||
|
let averageMood: Double
|
||||||
|
var aiSummary: String?
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "MMMM yyyy"
|
||||||
|
var components = DateComponents()
|
||||||
|
components.month = month
|
||||||
|
components.year = year
|
||||||
|
let date = Calendar.current.date(from: components) ?? Date()
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Report Year Summary
|
||||||
|
|
||||||
|
struct ReportYearSummary: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let year: Int
|
||||||
|
let entryCount: Int
|
||||||
|
let averageMood: Double
|
||||||
|
var aiSummary: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Assembled Report
|
||||||
|
|
||||||
|
struct MoodReport {
|
||||||
|
let reportType: ReportType
|
||||||
|
let generatedAt: Date
|
||||||
|
let overview: ReportOverviewStats
|
||||||
|
let weeks: [ReportWeek]
|
||||||
|
let monthlySummaries: [ReportMonthSummary]
|
||||||
|
let yearlySummaries: [ReportYearSummary]
|
||||||
|
var quickSummary: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - @Generable AI Response Structs
|
||||||
|
|
||||||
|
@available(iOS 26, *)
|
||||||
|
@Generable
|
||||||
|
struct AIWeeklySummary: Equatable {
|
||||||
|
@Guide(description: "A clinical, factual summary of the user's mood patterns for this week. Use third-person perspective (e.g., 'The individual'). 2-3 sentences covering mood trends, notable patterns, and any significant changes. Neutral, professional tone suitable for a therapist to read.")
|
||||||
|
var summary: String
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 26, *)
|
||||||
|
@Generable
|
||||||
|
struct AIMonthSummary: Equatable {
|
||||||
|
@Guide(description: "A clinical, factual summary of the user's mood patterns for this month. Use third-person perspective. 3-4 sentences covering overall mood trend, week-over-week changes, and notable patterns. Professional tone suitable for clinical review.")
|
||||||
|
var summary: String
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 26, *)
|
||||||
|
@Generable
|
||||||
|
struct AIYearSummary: Equatable {
|
||||||
|
@Guide(description: "A clinical, factual summary of the user's mood patterns for this year. Use third-person perspective. 3-5 sentences covering seasonal patterns, long-term trends, and significant periods. Professional tone suitable for clinical review.")
|
||||||
|
var summary: String
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 26, *)
|
||||||
|
@Generable
|
||||||
|
struct AIQuickSummaryResponse: Equatable {
|
||||||
|
@Guide(description: "A comprehensive clinical summary of the user's mood data for the selected period. Use third-person perspective (e.g., 'The individual'). 4-6 sentences covering: overall mood patterns, notable trends, day-of-week patterns, and any areas of concern or improvement. Factual, neutral, professional tone suitable for sharing with a therapist.")
|
||||||
|
var summary: String
|
||||||
|
|
||||||
|
@Guide(description: "2-3 key clinical observations as brief bullet points. Each should be a single factual sentence about a pattern or trend observed in the data.")
|
||||||
|
var keyObservations: [String]
|
||||||
|
|
||||||
|
@Guide(description: "1-2 brief, neutral recommendations based on observed patterns. Frame as observations rather than prescriptions (e.g., 'Mood data suggests weekday routines may benefit from...').")
|
||||||
|
var recommendations: [String]
|
||||||
|
}
|
||||||
110
Shared/Models/GuidedReflection.swift
Normal file
110
Shared/Models/GuidedReflection.swift
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
//
|
||||||
|
// GuidedReflection.swift
|
||||||
|
// Reflect
|
||||||
|
//
|
||||||
|
// Codable model for guided reflection responses, stored as JSON in MoodEntryModel.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Mood Category
|
||||||
|
|
||||||
|
enum MoodCategory: String, Codable {
|
||||||
|
case positive // great, good → 3 questions
|
||||||
|
case neutral // average → 4 questions
|
||||||
|
case negative // bad, horrible → 4 questions
|
||||||
|
|
||||||
|
init(from mood: Mood) {
|
||||||
|
switch mood {
|
||||||
|
case .great, .good: self = .positive
|
||||||
|
case .average: self = .neutral
|
||||||
|
case .horrible, .bad: self = .negative
|
||||||
|
default: self = .neutral
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var questionCount: Int {
|
||||||
|
switch self {
|
||||||
|
case .positive: return 3
|
||||||
|
case .neutral, .negative: return 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Guided Reflection
|
||||||
|
|
||||||
|
struct GuidedReflection: Codable, Equatable {
|
||||||
|
|
||||||
|
struct Response: Codable, Equatable, Identifiable {
|
||||||
|
var id: Int // question index (0-based)
|
||||||
|
let question: String
|
||||||
|
var answer: String
|
||||||
|
}
|
||||||
|
|
||||||
|
let moodCategory: MoodCategory
|
||||||
|
var responses: [Response]
|
||||||
|
var completedAt: Date?
|
||||||
|
|
||||||
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
|
var isComplete: Bool {
|
||||||
|
responses.count == moodCategory.questionCount &&
|
||||||
|
responses.allSatisfy { !$0.answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||||
|
}
|
||||||
|
|
||||||
|
var answeredCount: Int {
|
||||||
|
responses.filter { !$0.answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalQuestions: Int {
|
||||||
|
moodCategory.questionCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Factory
|
||||||
|
|
||||||
|
static func createNew(for mood: Mood) -> GuidedReflection {
|
||||||
|
let category = MoodCategory(from: mood)
|
||||||
|
let questionTexts = questions(for: category)
|
||||||
|
let responses = questionTexts.enumerated().map { index, question in
|
||||||
|
Response(id: index, question: question, answer: "")
|
||||||
|
}
|
||||||
|
return GuidedReflection(moodCategory: category, responses: responses, completedAt: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func questions(for category: MoodCategory) -> [String] {
|
||||||
|
switch category {
|
||||||
|
case .positive:
|
||||||
|
return [
|
||||||
|
String(localized: "guided_reflection_positive_q1"),
|
||||||
|
String(localized: "guided_reflection_positive_q2"),
|
||||||
|
String(localized: "guided_reflection_positive_q3"),
|
||||||
|
]
|
||||||
|
case .neutral:
|
||||||
|
return [
|
||||||
|
String(localized: "guided_reflection_neutral_q1"),
|
||||||
|
String(localized: "guided_reflection_neutral_q2"),
|
||||||
|
String(localized: "guided_reflection_neutral_q3"),
|
||||||
|
String(localized: "guided_reflection_neutral_q4"),
|
||||||
|
]
|
||||||
|
case .negative:
|
||||||
|
return [
|
||||||
|
String(localized: "guided_reflection_negative_q1"),
|
||||||
|
String(localized: "guided_reflection_negative_q2"),
|
||||||
|
String(localized: "guided_reflection_negative_q3"),
|
||||||
|
String(localized: "guided_reflection_negative_q4"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - JSON Helpers
|
||||||
|
|
||||||
|
func encode() -> String? {
|
||||||
|
guard let data = try? JSONEncoder().encode(self) else { return nil }
|
||||||
|
return String(data: data, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(from json: String) -> GuidedReflection? {
|
||||||
|
guard let data = json.data(using: .utf8) else { return nil }
|
||||||
|
return try? JSONDecoder().decode(GuidedReflection.self, from: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,12 @@ final class MoodEntryModel {
|
|||||||
var notes: String?
|
var notes: String?
|
||||||
var photoID: UUID?
|
var photoID: UUID?
|
||||||
|
|
||||||
|
// Weather
|
||||||
|
var weatherJSON: String?
|
||||||
|
|
||||||
|
// Guided Reflection
|
||||||
|
var reflectionJSON: String?
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
var mood: Mood {
|
var mood: Mood {
|
||||||
Mood(rawValue: moodValue) ?? .missing
|
Mood(rawValue: moodValue) ?? .missing
|
||||||
@@ -58,7 +64,9 @@ final class MoodEntryModel {
|
|||||||
canEdit: Bool = true,
|
canEdit: Bool = true,
|
||||||
canDelete: Bool = true,
|
canDelete: Bool = true,
|
||||||
notes: String? = nil,
|
notes: String? = nil,
|
||||||
photoID: UUID? = nil
|
photoID: UUID? = nil,
|
||||||
|
weatherJSON: String? = nil,
|
||||||
|
reflectionJSON: String? = nil
|
||||||
) {
|
) {
|
||||||
self.forDate = forDate
|
self.forDate = forDate
|
||||||
self.moodValue = mood.rawValue
|
self.moodValue = mood.rawValue
|
||||||
@@ -69,6 +77,8 @@ final class MoodEntryModel {
|
|||||||
self.canDelete = canDelete
|
self.canDelete = canDelete
|
||||||
self.notes = notes
|
self.notes = notes
|
||||||
self.photoID = photoID
|
self.photoID = photoID
|
||||||
|
self.weatherJSON = weatherJSON
|
||||||
|
self.reflectionJSON = reflectionJSON
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convenience initializer for raw values
|
// Convenience initializer for raw values
|
||||||
@@ -81,7 +91,9 @@ final class MoodEntryModel {
|
|||||||
canEdit: Bool = true,
|
canEdit: Bool = true,
|
||||||
canDelete: Bool = true,
|
canDelete: Bool = true,
|
||||||
notes: String? = nil,
|
notes: String? = nil,
|
||||||
photoID: UUID? = nil
|
photoID: UUID? = nil,
|
||||||
|
weatherJSON: String? = nil,
|
||||||
|
reflectionJSON: String? = nil
|
||||||
) {
|
) {
|
||||||
self.forDate = forDate
|
self.forDate = forDate
|
||||||
self.moodValue = moodValue
|
self.moodValue = moodValue
|
||||||
@@ -92,5 +104,7 @@ final class MoodEntryModel {
|
|||||||
self.canDelete = canDelete
|
self.canDelete = canDelete
|
||||||
self.notes = notes
|
self.notes = notes
|
||||||
self.photoID = photoID
|
self.photoID = photoID
|
||||||
|
self.weatherJSON = weatherJSON
|
||||||
|
self.reflectionJSON = reflectionJSON
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ class UserDefaultsStore {
|
|||||||
case lockScreenStyle
|
case lockScreenStyle
|
||||||
case celebrationAnimation
|
case celebrationAnimation
|
||||||
case hapticFeedbackEnabled
|
case hapticFeedbackEnabled
|
||||||
|
case weatherEnabled
|
||||||
|
|
||||||
case contentViewCurrentSelectedHeaderViewBackDays
|
case contentViewCurrentSelectedHeaderViewBackDays
|
||||||
case contentViewHeaderTag
|
case contentViewHeaderTag
|
||||||
|
|||||||
32
Shared/Models/WeatherData.swift
Normal file
32
Shared/Models/WeatherData.swift
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
//
|
||||||
|
// WeatherData.swift
|
||||||
|
// Reflect
|
||||||
|
//
|
||||||
|
// Codable weather model stored as JSON string in MoodEntryModel.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct WeatherData: Codable {
|
||||||
|
let conditionSymbol: String // SF Symbol from WeatherKit (e.g. "cloud.sun.fill")
|
||||||
|
let condition: String // "Partly Cloudy"
|
||||||
|
let temperature: Double // Current/average in Celsius
|
||||||
|
let highTemperature: Double // Day high in Celsius
|
||||||
|
let lowTemperature: Double // Day low in Celsius
|
||||||
|
let humidity: Double // 0.0–1.0
|
||||||
|
let latitude: Double
|
||||||
|
let longitude: Double
|
||||||
|
let fetchedAt: Date
|
||||||
|
|
||||||
|
// MARK: - JSON Helpers
|
||||||
|
|
||||||
|
func encode() -> String? {
|
||||||
|
guard let data = try? JSONEncoder().encode(self) else { return nil }
|
||||||
|
return String(data: data, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(from json: String) -> WeatherData? {
|
||||||
|
guard let data = json.data(using: .utf8) else { return nil }
|
||||||
|
return try? JSONDecoder().decode(WeatherData.self, from: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,10 +65,11 @@ final class MoodLogger {
|
|||||||
|
|
||||||
Self.logger.info("Applying side effects for mood \(mood.rawValue) on \(date)")
|
Self.logger.info("Applying side effects for mood \(mood.rawValue) on \(date)")
|
||||||
|
|
||||||
|
let hasAccess = !IAPManager.shared.shouldShowPaywall
|
||||||
|
|
||||||
// 1. Sync to HealthKit if enabled, requested, and user has full access
|
// 1. Sync to HealthKit if enabled, requested, and user has full access
|
||||||
if syncHealthKit {
|
if syncHealthKit {
|
||||||
let healthKitEnabled = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.healthKitEnabled.rawValue)
|
let healthKitEnabled = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.healthKitEnabled.rawValue)
|
||||||
let hasAccess = !IAPManager.shared.shouldShowPaywall
|
|
||||||
if healthKitEnabled && hasAccess {
|
if healthKitEnabled && hasAccess {
|
||||||
Task {
|
Task {
|
||||||
try? await HealthKitManager.shared.saveMood(mood, for: date)
|
try? await HealthKitManager.shared.saveMood(mood, for: date)
|
||||||
@@ -101,6 +102,14 @@ final class MoodLogger {
|
|||||||
|
|
||||||
// 8. Mark side effects as applied for this date
|
// 8. Mark side effects as applied for this date
|
||||||
markSideEffectsApplied(for: date)
|
markSideEffectsApplied(for: date)
|
||||||
|
|
||||||
|
// 9. Fetch weather if enabled and user has full access
|
||||||
|
let weatherEnabled = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.weatherEnabled.rawValue)
|
||||||
|
if weatherEnabled && hasAccess {
|
||||||
|
Task {
|
||||||
|
await WeatherManager.shared.fetchAndSaveWeather(for: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a mood entry for a specific date with all associated cleanup.
|
/// Delete a mood entry for a specific date with all associated cleanup.
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ protocol MoodDataWriting {
|
|||||||
@discardableResult
|
@discardableResult
|
||||||
func updatePhoto(forDate date: Date, photoID: UUID?) -> Bool
|
func updatePhoto(forDate date: Date, photoID: UUID?) -> Bool
|
||||||
|
|
||||||
|
/// Update weather for an entry
|
||||||
|
@discardableResult
|
||||||
|
func updateWeather(forDate date: Date, weatherJSON: String?) -> Bool
|
||||||
|
|
||||||
/// Fill in missing dates with placeholder entries
|
/// Fill in missing dates with placeholder entries
|
||||||
func fillInMissingDates()
|
func fillInMissingDates()
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,31 @@ extension DataController {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Weather
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func updateWeather(forDate date: Date, weatherJSON: String?) -> Bool {
|
||||||
|
guard let entry = getEntry(byDate: date) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.weatherJSON = weatherJSON
|
||||||
|
saveAndRunDataListeners()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Guided Reflection
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func updateReflection(forDate date: Date, reflectionJSON: String?) -> Bool {
|
||||||
|
guard let entry = getEntry(byDate: date) else { return false }
|
||||||
|
entry.reflectionJSON = reflectionJSON
|
||||||
|
saveAndRunDataListeners()
|
||||||
|
let count = reflectionJSON.flatMap { GuidedReflection.decode(from: $0) }?.answeredCount ?? 0
|
||||||
|
AnalyticsManager.shared.track(.reflectionCompleted(answeredCount: count))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Photo
|
// MARK: - Photo
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ struct ReflectApp: App {
|
|||||||
guard let processingTask = task as? BGProcessingTask else { return }
|
guard let processingTask = task as? BGProcessingTask else { return }
|
||||||
BGTask.runFillInMissingDatesTask(task: processingTask)
|
BGTask.runFillInMissingDatesTask(task: processingTask)
|
||||||
}
|
}
|
||||||
|
BGTaskScheduler.shared.register(forTaskWithIdentifier: BGTask.weatherRetryID, using: nil) { task in
|
||||||
|
guard let processingTask = task as? BGProcessingTask else { return }
|
||||||
|
BGTask.runWeatherRetryTask(task: processingTask)
|
||||||
|
}
|
||||||
UNUserNotificationCenter.current().setBadgeCount(0)
|
UNUserNotificationCenter.current().setBadgeCount(0)
|
||||||
|
|
||||||
// Reset tips session on app launch
|
// Reset tips session on app launch
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class ExportService {
|
|||||||
// MARK: - CSV Export
|
// MARK: - CSV Export
|
||||||
|
|
||||||
func generateCSV(entries: [MoodEntryModel]) -> String {
|
func generateCSV(entries: [MoodEntryModel]) -> String {
|
||||||
var csv = "Date,Mood,Mood Value,Notes,Weekday,Entry Type,Timestamp\n"
|
var csv = "Date,Mood,Mood Value,Notes,Reflection,Weekday,Entry Type,Timestamp\n"
|
||||||
|
|
||||||
let sortedEntries = entries.sorted { $0.forDate > $1.forDate }
|
let sortedEntries = entries.sorted { $0.forDate > $1.forDate }
|
||||||
|
|
||||||
@@ -63,11 +63,14 @@ class ExportService {
|
|||||||
let mood = entry.mood.widgetDisplayName
|
let mood = entry.mood.widgetDisplayName
|
||||||
let moodValue = entry.moodValue + 1 // 1-5 scale
|
let moodValue = entry.moodValue + 1 // 1-5 scale
|
||||||
let notes = escapeCSV(entry.notes ?? "")
|
let notes = escapeCSV(entry.notes ?? "")
|
||||||
|
let reflectionText = entry.reflectionJSON
|
||||||
|
.flatMap { GuidedReflection.decode(from: $0) }
|
||||||
|
.map { $0.responses.filter { !$0.answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.map { "Q: \($0.question) A: \($0.answer)" }.joined(separator: " | ") } ?? ""
|
||||||
let weekday = weekdayName(from: entry.weekDay)
|
let weekday = weekdayName(from: entry.weekDay)
|
||||||
let entryType = EntryType(rawValue: entry.entryType)?.description ?? "Unknown"
|
let entryType = EntryType(rawValue: entry.entryType)?.description ?? "Unknown"
|
||||||
let timestamp = isoFormatter.string(from: entry.timestamp)
|
let timestamp = isoFormatter.string(from: entry.timestamp)
|
||||||
|
|
||||||
csv += "\(date),\(mood),\(moodValue),\(notes),\(weekday),\(entryType),\(timestamp)\n"
|
csv += "\(date),\(mood),\(moodValue),\(notes),\(escapeCSV(reflectionText)),\(weekday),\(entryType),\(timestamp)\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
return csv
|
return csv
|
||||||
|
|||||||
71
Shared/Services/LocationManager.swift
Normal file
71
Shared/Services/LocationManager.swift
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
//
|
||||||
|
// LocationManager.swift
|
||||||
|
// Reflect
|
||||||
|
//
|
||||||
|
// CoreLocation wrapper with async/await for one-shot location requests.
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreLocation
|
||||||
|
import os.log
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class LocationManager: NSObject {
|
||||||
|
static let shared = LocationManager()
|
||||||
|
|
||||||
|
private static let logger = Logger(
|
||||||
|
subsystem: Bundle.main.bundleIdentifier ?? "com.88oakapps.reflect",
|
||||||
|
category: "LocationManager"
|
||||||
|
)
|
||||||
|
|
||||||
|
private let manager = CLLocationManager()
|
||||||
|
private var locationContinuation: CheckedContinuation<CLLocation, Error>?
|
||||||
|
|
||||||
|
var authorizationStatus: CLAuthorizationStatus {
|
||||||
|
manager.authorizationStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
private override init() {
|
||||||
|
super.init()
|
||||||
|
manager.delegate = self
|
||||||
|
manager.desiredAccuracy = kCLLocationAccuracyKilometer
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestAuthorization() {
|
||||||
|
manager.requestWhenInUseAuthorization()
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentLocation: CLLocation {
|
||||||
|
get async throws {
|
||||||
|
// Return last known location if recent enough (within 10 minutes)
|
||||||
|
if let last = manager.location,
|
||||||
|
abs(last.timestamp.timeIntervalSinceNow) < 600 {
|
||||||
|
return last
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
self.locationContinuation = continuation
|
||||||
|
self.manager.requestLocation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CLLocationManagerDelegate
|
||||||
|
|
||||||
|
extension LocationManager: CLLocationManagerDelegate {
|
||||||
|
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let location = locations.first else { return }
|
||||||
|
locationContinuation?.resume(returning: location)
|
||||||
|
locationContinuation = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||||
|
Task { @MainActor in
|
||||||
|
Self.logger.error("Location request failed: \(error.localizedDescription)")
|
||||||
|
locationContinuation?.resume(throwing: error)
|
||||||
|
locationContinuation = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
468
Shared/Services/ReportPDFGenerator.swift
Normal file
468
Shared/Services/ReportPDFGenerator.swift
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
//
|
||||||
|
// 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 · 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 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
149
Shared/Services/WeatherManager.swift
Normal file
149
Shared/Services/WeatherManager.swift
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
Shared/Views/DayView/WeatherCardView.swift
Normal file
70
Shared/Views/DayView/WeatherCardView.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
297
Shared/Views/GuidedReflectionView.swift
Normal file
297
Shared/Views/GuidedReflectionView.swift
Normal file
@@ -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..<totalSteps, id: \.self) { index in
|
||||||
|
Circle()
|
||||||
|
.fill(index == currentStep ? moodTint.color(forMood: entry.mood) : Color(.systemGray4))
|
||||||
|
.frame(width: 10, height: 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.GuidedReflection.progressDots)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Question Content
|
||||||
|
|
||||||
|
private var questionContent: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
if currentStep < reflection.responses.count {
|
||||||
|
let response = reflection.responses[currentStep]
|
||||||
|
|
||||||
|
Text(response.question)
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.GuidedReflection.questionLabel(step: currentStep))
|
||||||
|
.id("question_\(currentStep)")
|
||||||
|
|
||||||
|
TextEditor(text: $reflection.responses[currentStep].answer)
|
||||||
|
.focused($isTextFieldFocused)
|
||||||
|
.frame(minHeight: 120, maxHeight: 200)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.padding(12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(Color(.systemBackground))
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(Color(.systemGray4), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.onChange(of: reflection.responses[currentStep].answer) { _, newValue in
|
||||||
|
if newValue.count > maxCharacters {
|
||||||
|
reflection.responses[currentStep].answer = String(newValue.prefix(maxCharacters))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.GuidedReflection.textEditor)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Text("\(reflection.responses[currentStep].answer.count)/\(maxCharacters)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(
|
||||||
|
reflection.responses[currentStep].answer.count >= maxCharacters ? .red : .secondary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Navigation Buttons
|
||||||
|
|
||||||
|
private var navigationButtons: some View {
|
||||||
|
HStack {
|
||||||
|
// Back button
|
||||||
|
if currentStep > 0 {
|
||||||
|
Button {
|
||||||
|
navigateBack()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
Text(String(localized: "guided_reflection_back"))
|
||||||
|
}
|
||||||
|
.font(.body)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.GuidedReflection.backButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Next / Save button
|
||||||
|
if currentStep < totalSteps - 1 {
|
||||||
|
Button {
|
||||||
|
navigateForward()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(String(localized: "guided_reflection_next"))
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
}
|
||||||
|
.font(.body)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.GuidedReflection.nextButton)
|
||||||
|
} else {
|
||||||
|
Button {
|
||||||
|
saveReflection()
|
||||||
|
} label: {
|
||||||
|
Text(String(localized: "guided_reflection_save"))
|
||||||
|
.font(.body)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
.disabled(isSaving)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.GuidedReflection.saveButton)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Navigation
|
||||||
|
|
||||||
|
private func navigateForward() {
|
||||||
|
isTextFieldFocused = false
|
||||||
|
let animate = !UIAccessibility.isReduceMotionEnabled
|
||||||
|
if animate {
|
||||||
|
withAnimation(.easeInOut(duration: 0.3)) {
|
||||||
|
currentStep += 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentStep += 1
|
||||||
|
}
|
||||||
|
focusTextFieldDelayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func navigateBack() {
|
||||||
|
isTextFieldFocused = false
|
||||||
|
let animate = !UIAccessibility.isReduceMotionEnabled
|
||||||
|
if animate {
|
||||||
|
withAnimation(.easeInOut(duration: 0.3)) {
|
||||||
|
currentStep -= 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentStep -= 1
|
||||||
|
}
|
||||||
|
focusTextFieldDelayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func focusTextFieldDelayed() {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
|
isTextFieldFocused = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Save
|
||||||
|
|
||||||
|
private func saveReflection() {
|
||||||
|
isSaving = true
|
||||||
|
|
||||||
|
if reflection.isComplete {
|
||||||
|
reflection.completedAt = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = reflection.encode()
|
||||||
|
let success = DataController.shared.updateReflection(forDate: entry.forDate, reflectionJSON: json)
|
||||||
|
|
||||||
|
if success {
|
||||||
|
dismiss()
|
||||||
|
} else {
|
||||||
|
isSaving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,11 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
enum InsightsTab: String, CaseIterable {
|
||||||
|
case insights = "Insights"
|
||||||
|
case reports = "Reports"
|
||||||
|
}
|
||||||
|
|
||||||
struct InsightsView: View {
|
struct InsightsView: View {
|
||||||
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
|
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
|
||||||
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
|
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
|
||||||
@@ -18,161 +23,65 @@ struct InsightsView: View {
|
|||||||
@StateObject private var viewModel = InsightsViewModel()
|
@StateObject private var viewModel = InsightsViewModel()
|
||||||
@EnvironmentObject var iapManager: IAPManager
|
@EnvironmentObject var iapManager: IAPManager
|
||||||
@State private var showSubscriptionStore = false
|
@State private var showSubscriptionStore = false
|
||||||
|
@State private var selectedTab: InsightsTab = .insights
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
VStack(spacing: 0) {
|
||||||
ScrollView {
|
// Header
|
||||||
VStack(spacing: 20) {
|
HStack {
|
||||||
// Header
|
Text("Insights")
|
||||||
HStack {
|
.font(.title.weight(.bold))
|
||||||
Text("Insights")
|
.foregroundColor(textColor)
|
||||||
.font(.title.weight(.bold))
|
.accessibilityIdentifier(AccessibilityID.Insights.header)
|
||||||
.foregroundColor(textColor)
|
Spacer()
|
||||||
.accessibilityIdentifier(AccessibilityID.Insights.header)
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
// AI badge
|
|
||||||
if viewModel.isAIAvailable {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Image(systemName: "sparkles")
|
|
||||||
.font(.caption.weight(.medium))
|
|
||||||
Text("AI")
|
|
||||||
.font(.caption.weight(.semibold))
|
|
||||||
}
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.padding(.horizontal, 8)
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
.background(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [.purple, .blue],
|
|
||||||
startPoint: .leading,
|
|
||||||
endPoint: .trailing
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.clipShape(Capsule())
|
|
||||||
.aiInsightsTip()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal)
|
|
||||||
|
|
||||||
// This Month Section
|
|
||||||
InsightsSectionView(
|
|
||||||
title: "This Month",
|
|
||||||
icon: "calendar",
|
|
||||||
insights: viewModel.monthInsights,
|
|
||||||
loadingState: viewModel.monthLoadingState,
|
|
||||||
textColor: textColor,
|
|
||||||
moodTint: moodTint,
|
|
||||||
imagePack: imagePack,
|
|
||||||
colorScheme: colorScheme
|
|
||||||
)
|
|
||||||
.accessibilityIdentifier(AccessibilityID.Insights.monthSection)
|
|
||||||
|
|
||||||
// This Year Section
|
|
||||||
InsightsSectionView(
|
|
||||||
title: "This Year",
|
|
||||||
icon: "calendar.badge.clock",
|
|
||||||
insights: viewModel.yearInsights,
|
|
||||||
loadingState: viewModel.yearLoadingState,
|
|
||||||
textColor: textColor,
|
|
||||||
moodTint: moodTint,
|
|
||||||
imagePack: imagePack,
|
|
||||||
colorScheme: colorScheme
|
|
||||||
)
|
|
||||||
.accessibilityIdentifier(AccessibilityID.Insights.yearSection)
|
|
||||||
|
|
||||||
// All Time Section
|
|
||||||
InsightsSectionView(
|
|
||||||
title: "All Time",
|
|
||||||
icon: "infinity",
|
|
||||||
insights: viewModel.allTimeInsights,
|
|
||||||
loadingState: viewModel.allTimeLoadingState,
|
|
||||||
textColor: textColor,
|
|
||||||
moodTint: moodTint,
|
|
||||||
imagePack: imagePack,
|
|
||||||
colorScheme: colorScheme
|
|
||||||
)
|
|
||||||
.accessibilityIdentifier(AccessibilityID.Insights.allTimeSection)
|
|
||||||
}
|
|
||||||
.padding(.vertical)
|
|
||||||
.padding(.bottom, 100)
|
|
||||||
}
|
|
||||||
.refreshable {
|
|
||||||
viewModel.refreshInsights()
|
|
||||||
// Small delay to show refresh animation
|
|
||||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
|
||||||
}
|
|
||||||
.disabled(iapManager.shouldShowPaywall)
|
|
||||||
|
|
||||||
if iapManager.shouldShowPaywall {
|
|
||||||
// Premium insights prompt
|
|
||||||
VStack(spacing: 24) {
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
// Icon
|
|
||||||
ZStack {
|
|
||||||
Circle()
|
|
||||||
.fill(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [.purple.opacity(0.2), .blue.opacity(0.2)],
|
|
||||||
startPoint: .topLeading,
|
|
||||||
endPoint: .bottomTrailing
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.frame(width: 100, height: 100)
|
|
||||||
|
|
||||||
|
// AI badge
|
||||||
|
if viewModel.isAIAvailable {
|
||||||
|
HStack(spacing: 4) {
|
||||||
Image(systemName: "sparkles")
|
Image(systemName: "sparkles")
|
||||||
.font(.largeTitle)
|
.font(.caption.weight(.medium))
|
||||||
.foregroundStyle(
|
Text("AI")
|
||||||
LinearGradient(
|
.font(.caption.weight(.semibold))
|
||||||
colors: [.purple, .blue],
|
|
||||||
startPoint: .topLeading,
|
|
||||||
endPoint: .bottomTrailing
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
.foregroundColor(.white)
|
||||||
// Text
|
.padding(.horizontal, 8)
|
||||||
VStack(spacing: 12) {
|
.padding(.vertical, 4)
|
||||||
Text("Unlock AI-Powered Insights")
|
.background(
|
||||||
.font(.title2.weight(.bold))
|
LinearGradient(
|
||||||
.foregroundColor(textColor)
|
colors: [.purple, .blue],
|
||||||
.multilineTextAlignment(.center)
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
Text("Discover patterns in your mood, get personalized recommendations, and understand what affects how you feel.")
|
|
||||||
.font(.body)
|
|
||||||
.foregroundColor(textColor.opacity(0.7))
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.padding(.horizontal, 32)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe button
|
|
||||||
Button {
|
|
||||||
showSubscriptionStore = true
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "sparkles")
|
|
||||||
Text("Get Personal Insights")
|
|
||||||
}
|
|
||||||
.font(.headline.weight(.bold))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 16)
|
|
||||||
.background(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [.purple, .blue],
|
|
||||||
startPoint: .leading,
|
|
||||||
endPoint: .trailing
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
)
|
||||||
}
|
.clipShape(Capsule())
|
||||||
.padding(.horizontal, 24)
|
.aiInsightsTip()
|
||||||
|
}
|
||||||
Spacer()
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
// Segmented picker
|
||||||
|
Picker("", selection: $selectedTab) {
|
||||||
|
ForEach(InsightsTab.allCases, id: \.self) { tab in
|
||||||
|
Text(tab.rawValue).tag(tab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 12)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Reports.segmentedPicker)
|
||||||
|
|
||||||
|
// Content
|
||||||
|
ZStack {
|
||||||
|
if selectedTab == .insights {
|
||||||
|
insightsContent
|
||||||
|
} else {
|
||||||
|
ReportsView()
|
||||||
|
}
|
||||||
|
|
||||||
|
if iapManager.shouldShowPaywall {
|
||||||
|
paywallOverlay
|
||||||
}
|
}
|
||||||
.background(theme.currentTheme.bg)
|
|
||||||
.accessibilityIdentifier(AccessibilityID.Paywall.insightsOverlay)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showSubscriptionStore) {
|
.sheet(isPresented: $showSubscriptionStore) {
|
||||||
@@ -188,6 +97,133 @@ struct InsightsView: View {
|
|||||||
}
|
}
|
||||||
.padding(.top)
|
.padding(.top)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Insights Content
|
||||||
|
|
||||||
|
private var insightsContent: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
// This Month Section
|
||||||
|
InsightsSectionView(
|
||||||
|
title: "This Month",
|
||||||
|
icon: "calendar",
|
||||||
|
insights: viewModel.monthInsights,
|
||||||
|
loadingState: viewModel.monthLoadingState,
|
||||||
|
textColor: textColor,
|
||||||
|
moodTint: moodTint,
|
||||||
|
imagePack: imagePack,
|
||||||
|
colorScheme: colorScheme
|
||||||
|
)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Insights.monthSection)
|
||||||
|
|
||||||
|
// This Year Section
|
||||||
|
InsightsSectionView(
|
||||||
|
title: "This Year",
|
||||||
|
icon: "calendar.badge.clock",
|
||||||
|
insights: viewModel.yearInsights,
|
||||||
|
loadingState: viewModel.yearLoadingState,
|
||||||
|
textColor: textColor,
|
||||||
|
moodTint: moodTint,
|
||||||
|
imagePack: imagePack,
|
||||||
|
colorScheme: colorScheme
|
||||||
|
)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Insights.yearSection)
|
||||||
|
|
||||||
|
// All Time Section
|
||||||
|
InsightsSectionView(
|
||||||
|
title: "All Time",
|
||||||
|
icon: "infinity",
|
||||||
|
insights: viewModel.allTimeInsights,
|
||||||
|
loadingState: viewModel.allTimeLoadingState,
|
||||||
|
textColor: textColor,
|
||||||
|
moodTint: moodTint,
|
||||||
|
imagePack: imagePack,
|
||||||
|
colorScheme: colorScheme
|
||||||
|
)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Insights.allTimeSection)
|
||||||
|
}
|
||||||
|
.padding(.vertical)
|
||||||
|
.padding(.bottom, 100)
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
viewModel.refreshInsights()
|
||||||
|
// Small delay to show refresh animation
|
||||||
|
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||||
|
}
|
||||||
|
.disabled(iapManager.shouldShowPaywall)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Paywall Overlay
|
||||||
|
|
||||||
|
private var paywallOverlay: some View {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Icon
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [.purple.opacity(0.2), .blue.opacity(0.2)],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: 100, height: 100)
|
||||||
|
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
.font(.largeTitle)
|
||||||
|
.foregroundStyle(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [.purple, .blue],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Text("Unlock AI-Powered Insights")
|
||||||
|
.font(.title2.weight(.bold))
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Text("Discover patterns in your mood, get personalized recommendations, and understand what affects how you feel.")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(textColor.opacity(0.7))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe button
|
||||||
|
Button {
|
||||||
|
showSubscriptionStore = true
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
Text("Get Personal Insights")
|
||||||
|
}
|
||||||
|
.font(.headline.weight(.bold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
.background(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [.purple, .blue],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.background(theme.currentTheme.bg)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Paywall.insightsOverlay)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Insights Section View
|
// MARK: - Insights Section View
|
||||||
|
|||||||
346
Shared/Views/InsightsView/ReportDateRangePicker.swift
Normal file
346
Shared/Views/InsightsView/ReportDateRangePicker.swift
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
//
|
||||||
|
// ReportDateRangePicker.swift
|
||||||
|
// Reflect
|
||||||
|
//
|
||||||
|
// Calendar-based date range picker for AI mood reports.
|
||||||
|
// Ported from SportsTime DateRangePicker with Reflect theming.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ReportDateRangePicker: View {
|
||||||
|
@Binding var startDate: Date
|
||||||
|
@Binding var endDate: Date
|
||||||
|
|
||||||
|
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
@State private var displayedMonth: Date = Date()
|
||||||
|
@State private var selectionState: SelectionState = .none
|
||||||
|
|
||||||
|
enum SelectionState {
|
||||||
|
case none
|
||||||
|
case startSelected
|
||||||
|
case complete
|
||||||
|
}
|
||||||
|
|
||||||
|
private var textColor: Color { theme.currentTheme.labelColor }
|
||||||
|
private let calendar = Calendar.current
|
||||||
|
private let daysOfWeek = ["S", "M", "T", "W", "T", "F", "S"]
|
||||||
|
private let daysOfWeekFull = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
|
||||||
|
|
||||||
|
private var monthYearString: String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "MMMM yyyy"
|
||||||
|
return formatter.string(from: displayedMonth)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var daysInMonth: [Date?] {
|
||||||
|
guard let monthInterval = calendar.dateInterval(of: .month, for: displayedMonth),
|
||||||
|
let monthFirstWeek = calendar.dateInterval(of: .weekOfMonth, for: monthInterval.start) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
var days: [Date?] = []
|
||||||
|
let startOfMonth = monthInterval.start
|
||||||
|
guard let endOfMonth = calendar.date(byAdding: .day, value: -1, to: monthInterval.end) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentDate = monthFirstWeek.start
|
||||||
|
|
||||||
|
while currentDate <= endOfMonth || days.count % 7 != 0 {
|
||||||
|
if currentDate >= startOfMonth && currentDate <= endOfMonth {
|
||||||
|
days.append(currentDate)
|
||||||
|
} else if currentDate < startOfMonth {
|
||||||
|
days.append(nil)
|
||||||
|
} else if days.count % 7 != 0 {
|
||||||
|
days.append(nil)
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
guard let nextDate = calendar.date(byAdding: .day, value: 1, to: currentDate) else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
currentDate = nextDate
|
||||||
|
}
|
||||||
|
|
||||||
|
return days
|
||||||
|
}
|
||||||
|
|
||||||
|
private var selectedDayCount: Int {
|
||||||
|
let components = calendar.dateComponents([.day], from: startDate, to: endDate)
|
||||||
|
return (components.day ?? 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
selectedRangeSummary
|
||||||
|
monthNavigation
|
||||||
|
daysOfWeekHeader
|
||||||
|
calendarGrid
|
||||||
|
dayCountBadge
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Reports.dateRangePicker)
|
||||||
|
.onAppear {
|
||||||
|
displayedMonth = calendar.startOfDay(for: startDate)
|
||||||
|
if endDate > startDate {
|
||||||
|
selectionState = .complete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Selected Range Summary
|
||||||
|
|
||||||
|
private var selectedRangeSummary: some View {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("START")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(textColor.opacity(0.5))
|
||||||
|
Text(startDate.formatted(.dateTime.month(.abbreviated).day().year()))
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
Image(systemName: "arrow.right")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(textColor.opacity(0.5))
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
|
||||||
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
|
Text("END")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(textColor.opacity(0.5))
|
||||||
|
Text(endDate.formatted(.dateTime.month(.abbreviated).day().year()))
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(colorScheme == .dark ? Color(.systemGray6) : .white)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Month Navigation
|
||||||
|
|
||||||
|
private var monthNavigation: some View {
|
||||||
|
HStack {
|
||||||
|
Button {
|
||||||
|
if UIAccessibility.isReduceMotionEnabled {
|
||||||
|
displayedMonth = calendar.date(byAdding: .month, value: -1, to: displayedMonth) ?? displayedMonth
|
||||||
|
} else {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
displayedMonth = calendar.date(byAdding: .month, value: -1, to: displayedMonth) ?? displayedMonth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
.frame(minWidth: 44, minHeight: 44)
|
||||||
|
.background(Color.accentColor.opacity(0.15))
|
||||||
|
.clipShape(Circle())
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Previous month")
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(monthYearString)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(textColor)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
if UIAccessibility.isReduceMotionEnabled {
|
||||||
|
displayedMonth = calendar.date(byAdding: .month, value: 1, to: displayedMonth) ?? displayedMonth
|
||||||
|
} else {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
displayedMonth = calendar.date(byAdding: .month, value: 1, to: displayedMonth) ?? displayedMonth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
.frame(minWidth: 44, minHeight: 44)
|
||||||
|
.background(Color.accentColor.opacity(0.15))
|
||||||
|
.clipShape(Circle())
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Next month")
|
||||||
|
.disabled(isDisplayingCurrentMonth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isDisplayingCurrentMonth: Bool {
|
||||||
|
let now = Date()
|
||||||
|
return calendar.component(.month, from: displayedMonth) == calendar.component(.month, from: now)
|
||||||
|
&& calendar.component(.year, from: displayedMonth) == calendar.component(.year, from: now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Days of Week Header
|
||||||
|
|
||||||
|
private var daysOfWeekHeader: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ForEach(Array(daysOfWeek.enumerated()), id: \.offset) { index, day in
|
||||||
|
Text(day)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(textColor.opacity(0.5))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.accessibilityLabel(daysOfWeekFull[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Calendar Grid
|
||||||
|
|
||||||
|
private var calendarGrid: some View {
|
||||||
|
let columns = Array(repeating: GridItem(.flexible(), spacing: 4), count: 7)
|
||||||
|
|
||||||
|
return LazyVGrid(columns: columns, spacing: 4) {
|
||||||
|
ForEach(Array(daysInMonth.enumerated()), id: \.offset) { _, date in
|
||||||
|
if let date = date {
|
||||||
|
ReportDayCell(
|
||||||
|
date: date,
|
||||||
|
isStart: calendar.isDate(date, inSameDayAs: startDate),
|
||||||
|
isEnd: calendar.isDate(date, inSameDayAs: endDate),
|
||||||
|
isInRange: isDateInRange(date),
|
||||||
|
isToday: calendar.isDateInToday(date),
|
||||||
|
isFuture: isFutureDate(date),
|
||||||
|
textColor: textColor,
|
||||||
|
onTap: { handleDateTap(date) }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Color.clear
|
||||||
|
.frame(height: 40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Day Count Badge
|
||||||
|
|
||||||
|
private var dayCountBadge: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "calendar.badge.clock")
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
Text("\(selectedDayCount) day\(selectedDayCount == 1 ? "" : "s") selected")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(textColor.opacity(0.6))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func isDateInRange(_ date: Date) -> Bool {
|
||||||
|
let start = calendar.startOfDay(for: startDate)
|
||||||
|
let end = calendar.startOfDay(for: endDate)
|
||||||
|
let current = calendar.startOfDay(for: date)
|
||||||
|
return current > start && current < end
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isFutureDate(_ date: Date) -> Bool {
|
||||||
|
calendar.startOfDay(for: date) > calendar.startOfDay(for: Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleDateTap(_ date: Date) {
|
||||||
|
let tappedDate = calendar.startOfDay(for: date)
|
||||||
|
let today = calendar.startOfDay(for: Date())
|
||||||
|
|
||||||
|
// Don't allow selecting dates after today
|
||||||
|
if tappedDate > today { return }
|
||||||
|
|
||||||
|
switch selectionState {
|
||||||
|
case .none, .complete:
|
||||||
|
startDate = date
|
||||||
|
endDate = date
|
||||||
|
selectionState = .startSelected
|
||||||
|
|
||||||
|
case .startSelected:
|
||||||
|
if date >= startDate {
|
||||||
|
endDate = date
|
||||||
|
} else {
|
||||||
|
endDate = startDate
|
||||||
|
startDate = date
|
||||||
|
}
|
||||||
|
selectionState = .complete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Report Day Cell
|
||||||
|
|
||||||
|
private struct ReportDayCell: View {
|
||||||
|
let date: Date
|
||||||
|
let isStart: Bool
|
||||||
|
let isEnd: Bool
|
||||||
|
let isInRange: Bool
|
||||||
|
let isToday: Bool
|
||||||
|
let isFuture: Bool
|
||||||
|
let textColor: Color
|
||||||
|
let onTap: () -> Void
|
||||||
|
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
private let calendar = Calendar.current
|
||||||
|
|
||||||
|
private var dayNumber: String {
|
||||||
|
"\(calendar.component(.day, from: date))"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: onTap) {
|
||||||
|
ZStack {
|
||||||
|
// Range highlight background
|
||||||
|
if isInRange || isStart || isEnd {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.accentColor.opacity(0.15))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.opacity(isStart && !isEnd ? 0 : 1)
|
||||||
|
.offset(x: isStart ? 20 : 0)
|
||||||
|
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.accentColor.opacity(0.15))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.opacity(isEnd && !isStart ? 0 : 1)
|
||||||
|
.offset(x: isEnd ? -20 : 0)
|
||||||
|
}
|
||||||
|
.opacity(isStart && isEnd ? 0 : 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Day circle
|
||||||
|
ZStack {
|
||||||
|
if isStart || isEnd {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.accentColor)
|
||||||
|
} else if isToday {
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.accentColor, lineWidth: 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(dayNumber)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(
|
||||||
|
isFuture ? textColor.opacity(0.25) :
|
||||||
|
(isStart || isEnd) ? .white :
|
||||||
|
isToday ? .accentColor :
|
||||||
|
textColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(isFuture)
|
||||||
|
.frame(height: 40)
|
||||||
|
}
|
||||||
|
}
|
||||||
86
Shared/Views/InsightsView/ReportGeneratingView.swift
Normal file
86
Shared/Views/InsightsView/ReportGeneratingView.swift
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
//
|
||||||
|
// ReportGeneratingView.swift
|
||||||
|
// Reflect
|
||||||
|
//
|
||||||
|
// Overlay shown during AI report generation with progress and cancel.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ReportGeneratingView: View {
|
||||||
|
let progress: Double
|
||||||
|
let message: String
|
||||||
|
let onCancel: () -> Void
|
||||||
|
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
@State private var isPulsing = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
// Semi-transparent background
|
||||||
|
Color.black.opacity(0.4)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
// Sparkles icon
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
.font(.system(size: 44))
|
||||||
|
.foregroundStyle(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [.purple, .blue],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scaleEffect(isPulsing ? 1.1 : 1.0)
|
||||||
|
.opacity(isPulsing ? 0.8 : 1.0)
|
||||||
|
.animation(
|
||||||
|
UIAccessibility.isReduceMotionEnabled ? nil :
|
||||||
|
.easeInOut(duration: 1.2).repeatForever(autoreverses: true),
|
||||||
|
value: isPulsing
|
||||||
|
)
|
||||||
|
|
||||||
|
// Progress bar
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
ProgressView(value: progress)
|
||||||
|
.progressViewStyle(.linear)
|
||||||
|
.tint(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [.purple, .blue],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Reports.progressView)
|
||||||
|
|
||||||
|
Text(message)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel button
|
||||||
|
Button(role: .cancel) {
|
||||||
|
onCancel()
|
||||||
|
} label: {
|
||||||
|
Text("Cancel")
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Reports.cancelButton)
|
||||||
|
}
|
||||||
|
.padding(32)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 20)
|
||||||
|
.fill(colorScheme == .dark ? Color(.systemGray6) : .white)
|
||||||
|
.shadow(radius: 20)
|
||||||
|
)
|
||||||
|
.padding(.horizontal, 40)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
if !UIAccessibility.isReduceMotionEnabled {
|
||||||
|
isPulsing = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
381
Shared/Views/InsightsView/ReportsView.swift
Normal file
381
Shared/Views/InsightsView/ReportsView.swift
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
//
|
||||||
|
// ReportsView.swift
|
||||||
|
// Reflect
|
||||||
|
//
|
||||||
|
// AI-powered mood report generation with date range selection and PDF export.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ReportsView: View {
|
||||||
|
@StateObject private var viewModel = ReportsViewModel()
|
||||||
|
@EnvironmentObject var iapManager: IAPManager
|
||||||
|
|
||||||
|
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
@State private var showSubscriptionStore = false
|
||||||
|
|
||||||
|
private var textColor: Color { theme.currentTheme.labelColor }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
// Report type selector
|
||||||
|
reportTypeSelector
|
||||||
|
|
||||||
|
// Date range picker
|
||||||
|
ReportDateRangePicker(
|
||||||
|
startDate: $viewModel.startDate,
|
||||||
|
endDate: $viewModel.endDate
|
||||||
|
)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
// Entry count / validation
|
||||||
|
entryValidationCard
|
||||||
|
|
||||||
|
// AI unavailable warning
|
||||||
|
if !viewModel.isAIAvailable {
|
||||||
|
aiUnavailableCard
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate button
|
||||||
|
generateButton
|
||||||
|
|
||||||
|
// Report ready card
|
||||||
|
if viewModel.generationState == .completed {
|
||||||
|
reportReadyCard
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error message
|
||||||
|
if case .failed(let message) = viewModel.generationState {
|
||||||
|
errorCard(message: message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical)
|
||||||
|
.padding(.bottom, 100)
|
||||||
|
}
|
||||||
|
.disabled(iapManager.shouldShowPaywall)
|
||||||
|
|
||||||
|
// Generating overlay
|
||||||
|
if viewModel.generationState == .generating {
|
||||||
|
ReportGeneratingView(
|
||||||
|
progress: viewModel.progressValue,
|
||||||
|
message: viewModel.progressMessage,
|
||||||
|
onCancel: { viewModel.cancelGeneration() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paywall overlay
|
||||||
|
if iapManager.shouldShowPaywall {
|
||||||
|
paywallOverlay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $viewModel.showShareSheet) {
|
||||||
|
if let url = viewModel.exportedPDFURL {
|
||||||
|
ExportShareSheet(items: [url])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showSubscriptionStore) {
|
||||||
|
ReflectSubscriptionStoreView(source: "reports_gate")
|
||||||
|
}
|
||||||
|
.confirmationDialog(
|
||||||
|
String(localized: "Privacy Notice"),
|
||||||
|
isPresented: $viewModel.showPrivacyConfirmation,
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button(String(localized: "Share Report")) {
|
||||||
|
viewModel.exportPDF()
|
||||||
|
}
|
||||||
|
Button(String(localized: "Cancel"), role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("This report contains your personal mood data and journal notes. Only share it with people you trust.")
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
AnalyticsManager.shared.trackScreen(.reports)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Report Type Selector
|
||||||
|
|
||||||
|
private var reportTypeSelector: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Report Type")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
ForEach(ReportType.allCases, id: \.self) { type in
|
||||||
|
Button {
|
||||||
|
viewModel.reportType = type
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
Image(systemName: type.icon)
|
||||||
|
.font(.title2)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.background(viewModel.reportType == type ? Color.accentColor.opacity(0.15) : Color(.systemGray5))
|
||||||
|
.foregroundColor(viewModel.reportType == type ? .accentColor : .gray)
|
||||||
|
.clipShape(Circle())
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(type.rawValue)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
|
Text(type.description)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if viewModel.reportType == type {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.fill(colorScheme == .dark ? Color(.systemGray6) : .white)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.stroke(viewModel.reportType == type ? Color.accentColor : Color.clear, lineWidth: 2)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityIdentifier(
|
||||||
|
type == .quickSummary ? AccessibilityID.Reports.quickSummaryButton : AccessibilityID.Reports.detailedReportButton
|
||||||
|
)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Entry Validation Card
|
||||||
|
|
||||||
|
private var entryValidationCard: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: viewModel.validEntryCount >= 3 ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
|
||||||
|
.foregroundColor(viewModel.validEntryCount >= 3 ? .green : .orange)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("\(viewModel.validEntryCount) mood entries in range")
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
|
if viewModel.validEntryCount < 3 {
|
||||||
|
Text("At least 3 entries required to generate a report")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.fill(colorScheme == .dark ? Color(.systemGray6) : .white)
|
||||||
|
)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Reports.minimumEntriesWarning)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AI Unavailable Card
|
||||||
|
|
||||||
|
private var aiUnavailableCard: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "brain.head.profile")
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Apple Intelligence Required")
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
|
Text("AI report generation requires Apple Intelligence to be enabled in Settings.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.fill(Color.orange.opacity(0.1))
|
||||||
|
)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Generate Button
|
||||||
|
|
||||||
|
private var generateButton: some View {
|
||||||
|
Button {
|
||||||
|
viewModel.generateReport()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
Text("Generate Report")
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding()
|
||||||
|
.background(viewModel.canGenerate ? Color.accentColor : Color.gray)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
.disabled(!viewModel.canGenerate)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Reports.generateButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Report Ready Card
|
||||||
|
|
||||||
|
private var reportReadyCard: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundColor(.green)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Report Ready")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
|
Text("\(viewModel.reportType.rawValue) with \(viewModel.validEntryCount) entries")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.showPrivacyConfirmation = true
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "square.and.arrow.up")
|
||||||
|
Text("Export PDF")
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding()
|
||||||
|
.background(Color.accentColor)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Reports.exportButton)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.fill(colorScheme == .dark ? Color(.systemGray6) : .white)
|
||||||
|
)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Error Card
|
||||||
|
|
||||||
|
private func errorCard(message: String) -> some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Generation Failed")
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
|
Text(message)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button("Retry") {
|
||||||
|
viewModel.generateReport()
|
||||||
|
}
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.fill(Color.red.opacity(0.1))
|
||||||
|
)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Paywall Overlay
|
||||||
|
|
||||||
|
private var paywallOverlay: some View {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [.purple.opacity(0.2), .blue.opacity(0.2)],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: 100, height: 100)
|
||||||
|
|
||||||
|
Image(systemName: "doc.text.magnifyingglass")
|
||||||
|
.font(.largeTitle)
|
||||||
|
.foregroundStyle(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [.purple, .blue],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Text("Unlock AI Reports")
|
||||||
|
.font(.title2.weight(.bold))
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Text("Generate clinical-quality mood reports to share with your therapist or track your progress over time.")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(textColor.opacity(0.7))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showSubscriptionStore = true
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
Text("Unlock Reports")
|
||||||
|
}
|
||||||
|
.font(.headline.weight(.bold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
.background(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [.purple, .blue],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.background(theme.currentTheme.bg)
|
||||||
|
}
|
||||||
|
}
|
||||||
535
Shared/Views/InsightsView/ReportsViewModel.swift
Normal file
535
Shared/Views/InsightsView/ReportsViewModel.swift
Normal file
@@ -0,0 +1,535 @@
|
|||||||
|
//
|
||||||
|
// ReportsViewModel.swift
|
||||||
|
// Reflect
|
||||||
|
//
|
||||||
|
// ViewModel for AI mood report generation and PDF export.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import FoundationModels
|
||||||
|
|
||||||
|
// MARK: - Generation State
|
||||||
|
|
||||||
|
enum ReportGenerationState: Equatable {
|
||||||
|
case idle
|
||||||
|
case generating
|
||||||
|
case completed
|
||||||
|
case failed(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ViewModel
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class ReportsViewModel: ObservableObject {
|
||||||
|
|
||||||
|
// MARK: - Published State
|
||||||
|
|
||||||
|
@Published var startDate: Date = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()
|
||||||
|
@Published var endDate: Date = Date()
|
||||||
|
@Published var reportType: ReportType = .quickSummary
|
||||||
|
@Published var generationState: ReportGenerationState = .idle
|
||||||
|
@Published var progressValue: Double = 0.0
|
||||||
|
@Published var progressMessage: String = ""
|
||||||
|
@Published var generatedReport: MoodReport?
|
||||||
|
@Published var exportedPDFURL: URL?
|
||||||
|
@Published var showShareSheet: Bool = false
|
||||||
|
@Published var showPrivacyConfirmation: Bool = false
|
||||||
|
@Published var errorMessage: String?
|
||||||
|
@Published var isAIAvailable: Bool = false
|
||||||
|
|
||||||
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
|
var entriesInRange: [MoodEntryModel] {
|
||||||
|
let allEntries = DataController.shared.getData(
|
||||||
|
startDate: Calendar.current.startOfDay(for: startDate),
|
||||||
|
endDate: Calendar.current.startOfDay(for: Calendar.current.date(byAdding: .day, value: 1, to: endDate) ?? endDate),
|
||||||
|
includedDays: [1, 2, 3, 4, 5, 6, 7]
|
||||||
|
)
|
||||||
|
return allEntries.filter { ![.missing, .placeholder].contains($0.mood) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var validEntryCount: Int { entriesInRange.count }
|
||||||
|
|
||||||
|
var canGenerate: Bool {
|
||||||
|
validEntryCount >= 3 && isAIAvailable
|
||||||
|
}
|
||||||
|
|
||||||
|
var daySpan: Int {
|
||||||
|
let components = Calendar.current.dateComponents([.day], from: startDate, to: endDate)
|
||||||
|
return (components.day ?? 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Dependencies
|
||||||
|
|
||||||
|
private var insightService: Any?
|
||||||
|
private let summarizer = MoodDataSummarizer()
|
||||||
|
private let pdfGenerator = ReportPDFGenerator()
|
||||||
|
private let calendar = Calendar.current
|
||||||
|
private var generationTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
init() {
|
||||||
|
if #available(iOS 26, *) {
|
||||||
|
let service = FoundationModelsInsightService()
|
||||||
|
insightService = service
|
||||||
|
isAIAvailable = service.isAvailable
|
||||||
|
} else {
|
||||||
|
insightService = nil
|
||||||
|
isAIAvailable = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
generationTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Report Generation
|
||||||
|
|
||||||
|
func generateReport() {
|
||||||
|
generationTask?.cancel()
|
||||||
|
generationState = .generating
|
||||||
|
progressValue = 0.0
|
||||||
|
progressMessage = String(localized: "Preparing data...")
|
||||||
|
errorMessage = nil
|
||||||
|
generatedReport = nil
|
||||||
|
|
||||||
|
generationTask = Task {
|
||||||
|
do {
|
||||||
|
let entries = entriesInRange
|
||||||
|
let reportEntries = entries.map { ReportEntry(from: $0) }
|
||||||
|
|
||||||
|
let report: MoodReport
|
||||||
|
switch reportType {
|
||||||
|
case .quickSummary:
|
||||||
|
report = try await generateQuickSummary(entries: entries, reportEntries: reportEntries)
|
||||||
|
case .detailed:
|
||||||
|
report = try await generateDetailedReport(entries: entries, reportEntries: reportEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
|
||||||
|
generatedReport = report
|
||||||
|
generationState = .completed
|
||||||
|
progressValue = 1.0
|
||||||
|
progressMessage = String(localized: "Report ready")
|
||||||
|
|
||||||
|
AnalyticsManager.shared.track(.reportGenerated(
|
||||||
|
type: reportType.rawValue,
|
||||||
|
entryCount: validEntryCount,
|
||||||
|
daySpan: daySpan
|
||||||
|
))
|
||||||
|
} catch {
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
generationState = .failed(error.localizedDescription)
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
AnalyticsManager.shared.track(.reportGenerationFailed(error: error.localizedDescription))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelGeneration() {
|
||||||
|
generationTask?.cancel()
|
||||||
|
generationState = .idle
|
||||||
|
progressValue = 0.0
|
||||||
|
progressMessage = ""
|
||||||
|
AnalyticsManager.shared.track(.reportCancelled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PDF Export
|
||||||
|
|
||||||
|
func exportPDF() {
|
||||||
|
guard let report = generatedReport else { return }
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let url = try await pdfGenerator.generatePDF(from: report)
|
||||||
|
exportedPDFURL = url
|
||||||
|
showShareSheet = true
|
||||||
|
|
||||||
|
AnalyticsManager.shared.track(.reportExported(
|
||||||
|
type: reportType.rawValue,
|
||||||
|
entryCount: validEntryCount
|
||||||
|
))
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
generationState = .failed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Quick Summary Generation
|
||||||
|
|
||||||
|
private func generateQuickSummary(entries: [MoodEntryModel], reportEntries: [ReportEntry]) async throws -> MoodReport {
|
||||||
|
let overview = buildOverview(entries: entries)
|
||||||
|
let weeks = splitIntoWeeks(entries: reportEntries)
|
||||||
|
|
||||||
|
progressValue = 0.3
|
||||||
|
progressMessage = String(localized: "Generating AI summary...")
|
||||||
|
|
||||||
|
var quickSummaryText: String?
|
||||||
|
|
||||||
|
if #available(iOS 26, *) {
|
||||||
|
let summary = summarizer.summarize(entries: entries, periodName: "selected period")
|
||||||
|
let promptData = summarizer.toPromptString(summary)
|
||||||
|
|
||||||
|
let session = LanguageModelSession(instructions: clinicalSystemInstructions)
|
||||||
|
|
||||||
|
let prompt = """
|
||||||
|
Analyze this mood data and generate a clinical summary report:
|
||||||
|
|
||||||
|
\(promptData)
|
||||||
|
|
||||||
|
Generate a factual, third-person clinical summary suitable for sharing with a therapist.
|
||||||
|
"""
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response = try await session.respond(to: prompt, generating: AIQuickSummaryResponse.self)
|
||||||
|
|
||||||
|
guard !Task.isCancelled else { throw CancellationError() }
|
||||||
|
|
||||||
|
var text = response.content.summary
|
||||||
|
if !response.content.keyObservations.isEmpty {
|
||||||
|
text += "\n\nKey Observations:\n" + response.content.keyObservations.map { "- \($0)" }.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
if !response.content.recommendations.isEmpty {
|
||||||
|
text += "\n\nRecommendations:\n" + response.content.recommendations.map { "- \($0)" }.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
quickSummaryText = text
|
||||||
|
} catch is CancellationError {
|
||||||
|
throw CancellationError()
|
||||||
|
} catch {
|
||||||
|
quickSummaryText = "Summary unavailable: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
progressValue = 0.9
|
||||||
|
|
||||||
|
let monthlySummaries = buildMonthlySummaries(entries: reportEntries)
|
||||||
|
let yearlySummaries = buildYearlySummaries(entries: reportEntries)
|
||||||
|
|
||||||
|
return MoodReport(
|
||||||
|
reportType: .quickSummary,
|
||||||
|
generatedAt: Date(),
|
||||||
|
overview: overview,
|
||||||
|
weeks: weeks,
|
||||||
|
monthlySummaries: monthlySummaries,
|
||||||
|
yearlySummaries: yearlySummaries,
|
||||||
|
quickSummary: quickSummaryText
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Detailed Report Generation
|
||||||
|
|
||||||
|
private func generateDetailedReport(entries: [MoodEntryModel], reportEntries: [ReportEntry]) async throws -> MoodReport {
|
||||||
|
let overview = buildOverview(entries: entries)
|
||||||
|
var weeks = splitIntoWeeks(entries: reportEntries)
|
||||||
|
var monthlySummaries = buildMonthlySummaries(entries: reportEntries)
|
||||||
|
var yearlySummaries = buildYearlySummaries(entries: reportEntries)
|
||||||
|
|
||||||
|
let totalSections = weeks.count + monthlySummaries.count + yearlySummaries.count
|
||||||
|
var completedSections = 0
|
||||||
|
|
||||||
|
// Generate weekly AI summaries — batched at 4 concurrent
|
||||||
|
if #available(iOS 26, *) {
|
||||||
|
let batchSize = 4
|
||||||
|
|
||||||
|
for batchStart in stride(from: 0, to: weeks.count, by: batchSize) {
|
||||||
|
guard !Task.isCancelled else { throw CancellationError() }
|
||||||
|
|
||||||
|
let batchEnd = min(batchStart + batchSize, weeks.count)
|
||||||
|
let batchIndices = batchStart..<batchEnd
|
||||||
|
|
||||||
|
await withTaskGroup(of: (Int, String?).self) { group in
|
||||||
|
for index in batchIndices {
|
||||||
|
group.addTask { @MainActor in
|
||||||
|
let week = weeks[index]
|
||||||
|
let summary = await self.generateWeeklySummary(week: week)
|
||||||
|
return (index, summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for await (index, summary) in group {
|
||||||
|
weeks[index].aiSummary = summary
|
||||||
|
completedSections += 1
|
||||||
|
progressValue = Double(completedSections) / Double(totalSections)
|
||||||
|
progressMessage = String(localized: "Generating weekly summary \(completedSections) of \(weeks.count)...")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate monthly AI summaries — concurrent
|
||||||
|
guard !Task.isCancelled else { throw CancellationError() }
|
||||||
|
progressMessage = String(localized: "Generating monthly summaries...")
|
||||||
|
|
||||||
|
await withTaskGroup(of: (Int, String?).self) { group in
|
||||||
|
for (index, monthSummary) in monthlySummaries.enumerated() {
|
||||||
|
group.addTask { @MainActor in
|
||||||
|
let summary = await self.generateMonthlySummary(month: monthSummary, allEntries: reportEntries)
|
||||||
|
return (index, summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for await (index, summary) in group {
|
||||||
|
monthlySummaries[index].aiSummary = summary
|
||||||
|
completedSections += 1
|
||||||
|
progressValue = Double(completedSections) / Double(totalSections)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate yearly AI summaries — concurrent
|
||||||
|
guard !Task.isCancelled else { throw CancellationError() }
|
||||||
|
|
||||||
|
if !yearlySummaries.isEmpty {
|
||||||
|
progressMessage = String(localized: "Generating yearly summaries...")
|
||||||
|
|
||||||
|
await withTaskGroup(of: (Int, String?).self) { group in
|
||||||
|
for (index, yearSummary) in yearlySummaries.enumerated() {
|
||||||
|
group.addTask { @MainActor in
|
||||||
|
let summary = await self.generateYearlySummary(year: yearSummary, allEntries: reportEntries)
|
||||||
|
return (index, summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for await (index, summary) in group {
|
||||||
|
yearlySummaries[index].aiSummary = summary
|
||||||
|
completedSections += 1
|
||||||
|
progressValue = Double(completedSections) / Double(totalSections)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return MoodReport(
|
||||||
|
reportType: .detailed,
|
||||||
|
generatedAt: Date(),
|
||||||
|
overview: overview,
|
||||||
|
weeks: weeks,
|
||||||
|
monthlySummaries: monthlySummaries,
|
||||||
|
yearlySummaries: yearlySummaries,
|
||||||
|
quickSummary: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AI Summary Generators
|
||||||
|
|
||||||
|
@available(iOS 26, *)
|
||||||
|
private func generateWeeklySummary(week: ReportWeek) async -> String? {
|
||||||
|
let session = LanguageModelSession(instructions: clinicalSystemInstructions)
|
||||||
|
|
||||||
|
let moodList = week.entries.sorted(by: { $0.date < $1.date }).map { entry in
|
||||||
|
let day = entry.date.formatted(.dateTime.weekday(.abbreviated))
|
||||||
|
let mood = entry.mood.widgetDisplayName
|
||||||
|
let notes = entry.notes ?? "no notes"
|
||||||
|
let reflectionSummary = entry.reflection?.responses
|
||||||
|
.filter { !$0.answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||||
|
.map { "\($0.question): \(String($0.answer.prefix(150)))" }
|
||||||
|
.joined(separator: " | ") ?? ""
|
||||||
|
let reflectionStr = reflectionSummary.isEmpty ? "" : " [reflection: \(reflectionSummary)]"
|
||||||
|
return "\(day): \(mood) (\(notes))\(reflectionStr)"
|
||||||
|
}.joined(separator: "\n")
|
||||||
|
|
||||||
|
let prompt = """
|
||||||
|
Summarize this week's mood data (Week \(week.weekNumber)):
|
||||||
|
\(moodList)
|
||||||
|
|
||||||
|
Average mood: \(String(format: "%.1f", weekAverage(week)))/5
|
||||||
|
"""
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response = try await session.respond(to: prompt, generating: AIWeeklySummary.self)
|
||||||
|
return response.content.summary
|
||||||
|
} catch {
|
||||||
|
return "Summary unavailable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 26, *)
|
||||||
|
private func generateMonthlySummary(month: ReportMonthSummary, allEntries: [ReportEntry]) async -> String? {
|
||||||
|
let session = LanguageModelSession(instructions: clinicalSystemInstructions)
|
||||||
|
|
||||||
|
let monthEntries = allEntries.filter {
|
||||||
|
calendar.component(.month, from: $0.date) == month.month &&
|
||||||
|
calendar.component(.year, from: $0.date) == month.year
|
||||||
|
}
|
||||||
|
|
||||||
|
let moodDist = Dictionary(grouping: monthEntries, by: { $0.mood.widgetDisplayName })
|
||||||
|
.mapValues { $0.count }
|
||||||
|
.sorted { $0.value > $1.value }
|
||||||
|
.map { "\($0.key): \($0.value)" }
|
||||||
|
.joined(separator: ", ")
|
||||||
|
|
||||||
|
let prompt = """
|
||||||
|
Summarize this month's mood data (\(month.title)):
|
||||||
|
\(month.entryCount) entries, average mood: \(String(format: "%.1f", month.averageMood))/5
|
||||||
|
Distribution: \(moodDist)
|
||||||
|
"""
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response = try await session.respond(to: prompt, generating: AIMonthSummary.self)
|
||||||
|
return response.content.summary
|
||||||
|
} catch {
|
||||||
|
return "Summary unavailable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 26, *)
|
||||||
|
private func generateYearlySummary(year: ReportYearSummary, allEntries: [ReportEntry]) async -> String? {
|
||||||
|
let session = LanguageModelSession(instructions: clinicalSystemInstructions)
|
||||||
|
|
||||||
|
let yearEntries = allEntries.filter { calendar.component(.year, from: $0.date) == year.year }
|
||||||
|
|
||||||
|
let monthlyAvgs = Dictionary(grouping: yearEntries) { calendar.component(.month, from: $0.date) }
|
||||||
|
.sorted { $0.key < $1.key }
|
||||||
|
.map { (month, entries) in
|
||||||
|
let avg = Double(entries.reduce(0) { $0 + Int($1.mood.rawValue) + 1 }) / Double(entries.count)
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "MMM"
|
||||||
|
var comps = DateComponents()
|
||||||
|
comps.month = month
|
||||||
|
let date = calendar.date(from: comps) ?? Date()
|
||||||
|
return "\(formatter.string(from: date)): \(String(format: "%.1f", avg))"
|
||||||
|
}
|
||||||
|
.joined(separator: ", ")
|
||||||
|
|
||||||
|
let prompt = """
|
||||||
|
Summarize this year's mood data (\(year.year)):
|
||||||
|
\(year.entryCount) entries, average mood: \(String(format: "%.1f", year.averageMood))/5
|
||||||
|
Monthly averages: \(monthlyAvgs)
|
||||||
|
"""
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response = try await session.respond(to: prompt, generating: AIYearSummary.self)
|
||||||
|
return response.content.summary
|
||||||
|
} catch {
|
||||||
|
return "Summary unavailable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Building Helpers
|
||||||
|
|
||||||
|
private func buildOverview(entries: [MoodEntryModel]) -> ReportOverviewStats {
|
||||||
|
let validEntries = entries.filter { ![.missing, .placeholder].contains($0.mood) }
|
||||||
|
let total = validEntries.count
|
||||||
|
let avgMood = total > 0 ? Double(validEntries.reduce(0) { $0 + Int($1.moodValue) + 1 }) / Double(total) : 0
|
||||||
|
|
||||||
|
var distribution: [Mood: Int] = [:]
|
||||||
|
for entry in validEntries {
|
||||||
|
distribution[entry.mood, default: 0] += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
let sorted = validEntries.sorted { $0.forDate < $1.forDate }
|
||||||
|
let trend: String
|
||||||
|
if sorted.count >= 4 {
|
||||||
|
let half = sorted.count / 2
|
||||||
|
let firstAvg = Double(sorted.prefix(half).reduce(0) { $0 + Int($1.moodValue) + 1 }) / Double(half)
|
||||||
|
let secondAvg = Double(sorted.suffix(half).reduce(0) { $0 + Int($1.moodValue) + 1 }) / Double(half)
|
||||||
|
let diff = secondAvg - firstAvg
|
||||||
|
trend = diff > 0.5 ? "Improving" : diff < -0.5 ? "Declining" : "Stable"
|
||||||
|
} else {
|
||||||
|
trend = "Stable"
|
||||||
|
}
|
||||||
|
|
||||||
|
let dateFormatter = DateFormatter()
|
||||||
|
dateFormatter.dateStyle = .medium
|
||||||
|
let rangeStr: String
|
||||||
|
if let first = sorted.first, let last = sorted.last {
|
||||||
|
rangeStr = "\(dateFormatter.string(from: first.forDate)) - \(dateFormatter.string(from: last.forDate))"
|
||||||
|
} else {
|
||||||
|
rangeStr = "No data"
|
||||||
|
}
|
||||||
|
|
||||||
|
return ReportOverviewStats(
|
||||||
|
totalEntries: total,
|
||||||
|
averageMood: avgMood,
|
||||||
|
moodDistribution: distribution,
|
||||||
|
trend: trend,
|
||||||
|
dateRange: rangeStr
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func splitIntoWeeks(entries: [ReportEntry]) -> [ReportWeek] {
|
||||||
|
let sorted = entries.sorted { $0.date < $1.date }
|
||||||
|
guard let firstDate = sorted.first?.date else { return [] }
|
||||||
|
|
||||||
|
var weeks: [ReportWeek] = []
|
||||||
|
var weekStart = calendar.startOfDay(for: firstDate)
|
||||||
|
var weekNumber = 1
|
||||||
|
|
||||||
|
while weekStart <= (sorted.last?.date ?? Date()) {
|
||||||
|
let weekEnd = calendar.date(byAdding: .day, value: 6, to: weekStart) ?? weekStart
|
||||||
|
let weekEntries = sorted.filter { entry in
|
||||||
|
let entryDay = calendar.startOfDay(for: entry.date)
|
||||||
|
return entryDay >= weekStart && entryDay <= weekEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
if !weekEntries.isEmpty {
|
||||||
|
weeks.append(ReportWeek(
|
||||||
|
weekNumber: weekNumber,
|
||||||
|
startDate: weekStart,
|
||||||
|
endDate: weekEnd,
|
||||||
|
entries: weekEntries
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
weekStart = calendar.date(byAdding: .day, value: 7, to: weekStart) ?? weekStart
|
||||||
|
weekNumber += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return weeks
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildMonthlySummaries(entries: [ReportEntry]) -> [ReportMonthSummary] {
|
||||||
|
let grouped = Dictionary(grouping: entries) { entry in
|
||||||
|
let month = calendar.component(.month, from: entry.date)
|
||||||
|
let year = calendar.component(.year, from: entry.date)
|
||||||
|
return "\(year)-\(month)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return grouped.map { (key, monthEntries) in
|
||||||
|
let components = key.split(separator: "-")
|
||||||
|
let year = Int(components[0]) ?? 0
|
||||||
|
let month = Int(components[1]) ?? 0
|
||||||
|
let avg = Double(monthEntries.reduce(0) { $0 + Int($1.mood.rawValue) + 1 }) / Double(monthEntries.count)
|
||||||
|
|
||||||
|
return ReportMonthSummary(
|
||||||
|
month: month,
|
||||||
|
year: year,
|
||||||
|
entryCount: monthEntries.count,
|
||||||
|
averageMood: avg
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.sorted { ($0.year, $0.month) < ($1.year, $1.month) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildYearlySummaries(entries: [ReportEntry]) -> [ReportYearSummary] {
|
||||||
|
let grouped = Dictionary(grouping: entries) { calendar.component(.year, from: $0.date) }
|
||||||
|
guard grouped.count > 1 else { return [] } // Only generate if range spans multiple years
|
||||||
|
|
||||||
|
return grouped.map { (year, yearEntries) in
|
||||||
|
let avg = Double(yearEntries.reduce(0) { $0 + Int($1.mood.rawValue) + 1 }) / Double(yearEntries.count)
|
||||||
|
return ReportYearSummary(year: year, entryCount: yearEntries.count, averageMood: avg)
|
||||||
|
}
|
||||||
|
.sorted { $0.year < $1.year }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func weekAverage(_ week: ReportWeek) -> Double {
|
||||||
|
let total = week.entries.reduce(0) { $0 + Int($1.mood.rawValue) + 1 }
|
||||||
|
return week.entries.isEmpty ? 0 : Double(total) / Double(week.entries.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Clinical System Instructions
|
||||||
|
|
||||||
|
private var clinicalSystemInstructions: String {
|
||||||
|
let languageCode = Locale.current.language.languageCode?.identifier ?? "en"
|
||||||
|
return """
|
||||||
|
You are a clinical mood data analyst generating a professional mood report. \
|
||||||
|
Use third-person perspective (e.g., "The individual", "The subject"). \
|
||||||
|
Be factual, neutral, and objective. Do not use casual language, emojis, or personality-driven tone. \
|
||||||
|
Reference specific data points and patterns. \
|
||||||
|
This report may be shared with a therapist or healthcare professional. \
|
||||||
|
Generate all text in the language with code: \(languageCode).
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -158,6 +158,7 @@ struct EntryDetailView: View {
|
|||||||
@State private var showDeleteConfirmation = false
|
@State private var showDeleteConfirmation = false
|
||||||
@State private var showFullScreenPhoto = false
|
@State private var showFullScreenPhoto = false
|
||||||
@State private var selectedPhotoItem: PhotosPickerItem?
|
@State private var selectedPhotoItem: PhotosPickerItem?
|
||||||
|
@State private var showReflectionFlow = false
|
||||||
@State private var selectedMood: Mood?
|
@State private var selectedMood: Mood?
|
||||||
|
|
||||||
private var currentMood: Mood {
|
private var currentMood: Mood {
|
||||||
@@ -184,9 +185,20 @@ struct EntryDetailView: View {
|
|||||||
// Mood section
|
// Mood section
|
||||||
moodSection
|
moodSection
|
||||||
|
|
||||||
|
// Guided reflection section
|
||||||
|
if currentMood != .missing && currentMood != .placeholder {
|
||||||
|
reflectionSection
|
||||||
|
}
|
||||||
|
|
||||||
// Notes section
|
// Notes section
|
||||||
notesSection
|
notesSection
|
||||||
|
|
||||||
|
// Weather section
|
||||||
|
if let weatherJSON = entry.weatherJSON,
|
||||||
|
let weatherData = WeatherData.decode(from: weatherJSON) {
|
||||||
|
weatherSection(weatherData)
|
||||||
|
}
|
||||||
|
|
||||||
// Photo section
|
// Photo section
|
||||||
photoSection
|
photoSection
|
||||||
|
|
||||||
@@ -212,6 +224,9 @@ struct EntryDetailView: View {
|
|||||||
.sheet(isPresented: $showNoteEditor) {
|
.sheet(isPresented: $showNoteEditor) {
|
||||||
NoteEditorView(entry: entry)
|
NoteEditorView(entry: entry)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showReflectionFlow) {
|
||||||
|
GuidedReflectionView(entry: entry)
|
||||||
|
}
|
||||||
.alert("Delete Entry", isPresented: $showDeleteConfirmation) {
|
.alert("Delete Entry", isPresented: $showDeleteConfirmation) {
|
||||||
Button("Delete", role: .destructive) {
|
Button("Delete", role: .destructive) {
|
||||||
onDelete()
|
onDelete()
|
||||||
@@ -411,6 +426,84 @@ struct EntryDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var reflectionSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
Text(String(localized: "guided_reflection_title"))
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
AnalyticsManager.shared.track(.reflectionStarted)
|
||||||
|
showReflectionFlow = true
|
||||||
|
} label: {
|
||||||
|
Text(entry.reflectionJSON != nil
|
||||||
|
? String(localized: "guided_reflection_edit")
|
||||||
|
: String(localized: "guided_reflection_begin"))
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
AnalyticsManager.shared.track(.reflectionStarted)
|
||||||
|
showReflectionFlow = true
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
if let json = entry.reflectionJSON,
|
||||||
|
let reflection = GuidedReflection.decode(from: json),
|
||||||
|
reflection.answeredCount > 0 {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(reflection.responses.first(where: {
|
||||||
|
!$0.answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
})?.answer ?? "")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.lineLimit(3)
|
||||||
|
|
||||||
|
Text(String(localized: "guided_reflection_answered_count \(reflection.answeredCount) \(reflection.totalQuestions)"))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(String(localized: "guided_reflection_empty_prompt"))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 16)
|
||||||
|
.fill(Color(.systemBackground))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func weatherSection(_ weatherData: WeatherData) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Weather")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
|
WeatherCardView(weatherData: weatherData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var photoSection: some View {
|
private var photoSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
HStack {
|
HStack {
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ struct SettingsContentView: View {
|
|||||||
@AppStorage(UserDefaultsStore.Keys.deleteEnable.rawValue, store: GroupUserDefaults.groupDefaults) private var deleteEnabled = true
|
@AppStorage(UserDefaultsStore.Keys.deleteEnable.rawValue, store: GroupUserDefaults.groupDefaults) private var deleteEnabled = true
|
||||||
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
|
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
|
||||||
@AppStorage(UserDefaultsStore.Keys.hapticFeedbackEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var hapticFeedbackEnabled = true
|
@AppStorage(UserDefaultsStore.Keys.hapticFeedbackEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var hapticFeedbackEnabled = true
|
||||||
|
@AppStorage(UserDefaultsStore.Keys.weatherEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var weatherEnabled = true
|
||||||
|
@State private var showWeatherSubscriptionStore = false
|
||||||
|
|
||||||
private var textColor: Color { theme.currentTheme.labelColor }
|
private var textColor: Color { theme.currentTheme.labelColor }
|
||||||
|
|
||||||
@@ -49,6 +51,7 @@ struct SettingsContentView: View {
|
|||||||
featuresSectionHeader
|
featuresSectionHeader
|
||||||
privacyLockToggle
|
privacyLockToggle
|
||||||
healthKitToggle
|
healthKitToggle
|
||||||
|
weatherToggle
|
||||||
exportDataButton
|
exportDataButton
|
||||||
|
|
||||||
// Settings section
|
// Settings section
|
||||||
@@ -959,6 +962,72 @@ struct SettingsContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Weather Toggle
|
||||||
|
|
||||||
|
private var weatherToggle: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "cloud.sun.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
.frame(width: 32)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Weather")
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
|
Text("Show weather details for each day")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Toggle("", isOn: Binding(
|
||||||
|
get: { iapManager.shouldShowPaywall ? false : weatherEnabled },
|
||||||
|
set: { newValue in
|
||||||
|
if iapManager.shouldShowPaywall {
|
||||||
|
showWeatherSubscriptionStore = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
weatherEnabled = newValue
|
||||||
|
|
||||||
|
if newValue {
|
||||||
|
LocationManager.shared.requestAuthorization()
|
||||||
|
}
|
||||||
|
|
||||||
|
AnalyticsManager.shared.track(.weatherToggled(enabled: newValue))
|
||||||
|
}
|
||||||
|
))
|
||||||
|
.labelsHidden()
|
||||||
|
.disabled(iapManager.shouldShowPaywall)
|
||||||
|
.accessibilityLabel(String(localized: "Weather"))
|
||||||
|
.accessibilityHint(String(localized: "Show weather details for each day"))
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
|
||||||
|
if iapManager.shouldShowPaywall {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "crown.fill")
|
||||||
|
.foregroundColor(.yellow)
|
||||||
|
Text("Premium Feature")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityLabel(String(localized: "Premium feature, subscription required"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(theme.currentTheme.secondaryBGColor)
|
||||||
|
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||||
|
.sheet(isPresented: $showWeatherSubscriptionStore) {
|
||||||
|
ReflectSubscriptionStoreView(source: "settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Export Data Button
|
// MARK: - Export Data Button
|
||||||
|
|
||||||
private var exportDataButton: some View {
|
private var exportDataButton: some View {
|
||||||
@@ -1240,6 +1309,8 @@ struct SettingsView: View {
|
|||||||
@AppStorage(UserDefaultsStore.Keys.firstLaunchDate.rawValue, store: GroupUserDefaults.groupDefaults) private var firstLaunchDate = Date()
|
@AppStorage(UserDefaultsStore.Keys.firstLaunchDate.rawValue, store: GroupUserDefaults.groupDefaults) private var firstLaunchDate = Date()
|
||||||
@AppStorage(UserDefaultsStore.Keys.privacyLockEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var privacyLockEnabled = false
|
@AppStorage(UserDefaultsStore.Keys.privacyLockEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var privacyLockEnabled = false
|
||||||
@AppStorage(UserDefaultsStore.Keys.hapticFeedbackEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var hapticFeedbackEnabled = true
|
@AppStorage(UserDefaultsStore.Keys.hapticFeedbackEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var hapticFeedbackEnabled = true
|
||||||
|
@AppStorage(UserDefaultsStore.Keys.weatherEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var weatherEnabled = true
|
||||||
|
@State private var showWeatherSubscriptionStore = false
|
||||||
|
|
||||||
private var textColor: Color { theme.currentTheme.labelColor }
|
private var textColor: Color { theme.currentTheme.labelColor }
|
||||||
|
|
||||||
@@ -1257,6 +1328,7 @@ struct SettingsView: View {
|
|||||||
featuresSectionHeader
|
featuresSectionHeader
|
||||||
privacyLockToggle
|
privacyLockToggle
|
||||||
healthKitToggle
|
healthKitToggle
|
||||||
|
weatherToggle
|
||||||
exportDataButton
|
exportDataButton
|
||||||
|
|
||||||
// Settings section
|
// Settings section
|
||||||
@@ -1560,6 +1632,68 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Weather Toggle
|
||||||
|
|
||||||
|
private var weatherToggle: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "cloud.sun.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
.frame(width: 32)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Weather")
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
|
Text("Show weather details for each day")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Toggle("", isOn: Binding(
|
||||||
|
get: { iapManager.shouldShowPaywall ? false : weatherEnabled },
|
||||||
|
set: { newValue in
|
||||||
|
if iapManager.shouldShowPaywall {
|
||||||
|
showWeatherSubscriptionStore = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
weatherEnabled = newValue
|
||||||
|
|
||||||
|
if newValue {
|
||||||
|
LocationManager.shared.requestAuthorization()
|
||||||
|
}
|
||||||
|
|
||||||
|
AnalyticsManager.shared.track(.weatherToggled(enabled: newValue))
|
||||||
|
}
|
||||||
|
))
|
||||||
|
.labelsHidden()
|
||||||
|
.disabled(iapManager.shouldShowPaywall)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
|
||||||
|
if iapManager.shouldShowPaywall {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "crown.fill")
|
||||||
|
.foregroundColor(.yellow)
|
||||||
|
Text("Premium Feature")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(theme.currentTheme.secondaryBGColor)
|
||||||
|
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||||
|
.sheet(isPresented: $showWeatherSubscriptionStore) {
|
||||||
|
ReflectSubscriptionStoreView(source: "settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Export Data Button
|
// MARK: - Export Data Button
|
||||||
|
|
||||||
private var exportDataButton: some View {
|
private var exportDataButton: some View {
|
||||||
|
|||||||
Reference in New Issue
Block a user