Add guided reflection flow with mood-adaptive CBT/ACT questions

Walks users through 3-4 guided questions based on mood category:
positive (great/good) gets gratitude-oriented questions, neutral
(average) gets exploratory questions, and negative (bad/horrible)
gets empathetic questions. Stored as JSON in MoodEntryModel,
integrated into PDF reports, AI summaries, and CSV export.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-11 10:51:36 -05:00
parent 19b4c8b05b
commit 5bd8f8076a
13 changed files with 15340 additions and 13617 deletions

View File

@@ -18,6 +18,8 @@
<array>
<string>CloudKit</string>
</array>
<key>com.apple.developer.weatherkit</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.88oakapps.reflect</string>

File diff suppressed because it is too large Load Diff

View File

@@ -58,6 +58,20 @@ enum AccessibilityID {
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
enum Settings {
static let header = "settings_header"

View File

@@ -350,6 +350,7 @@ extension AnalyticsManager {
case paywall = "paywall"
case entryDetail = "entry_detail"
case noteEditor = "note_editor"
case guidedReflection = "guided_reflection"
case lockScreen = "lock_screen"
case sharing = "sharing"
case themePicker = "theme_picker"
@@ -382,6 +383,8 @@ extension AnalyticsManager {
case noteUpdated(characterCount: Int)
case photoAdded
case photoDeleted
case reflectionStarted
case reflectionCompleted(answeredCount: Int)
case missingEntriesFilled(count: Int)
case entryDeleted(mood: Int)
case allDataCleared
@@ -491,6 +494,10 @@ extension AnalyticsManager {
return ("photo_added", nil)
case .photoDeleted:
return ("photo_deleted", nil)
case .reflectionStarted:
return ("reflection_started", nil)
case .reflectionCompleted(let count):
return ("reflection_completed", ["answered_count": count])
case .missingEntriesFilled(let count):
return ("missing_entries_filled", ["count": count])
case .entryDeleted(let mood):

View File

@@ -36,12 +36,14 @@ struct ReportEntry {
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) }
}
}

View 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)
}
}

View File

@@ -45,6 +45,9 @@ final class MoodEntryModel {
// Weather
var weatherJSON: String?
// Guided Reflection
var reflectionJSON: String?
// Computed properties
var mood: Mood {
Mood(rawValue: moodValue) ?? .missing
@@ -62,7 +65,8 @@ final class MoodEntryModel {
canDelete: Bool = true,
notes: String? = nil,
photoID: UUID? = nil,
weatherJSON: String? = nil
weatherJSON: String? = nil,
reflectionJSON: String? = nil
) {
self.forDate = forDate
self.moodValue = mood.rawValue
@@ -74,6 +78,7 @@ final class MoodEntryModel {
self.notes = notes
self.photoID = photoID
self.weatherJSON = weatherJSON
self.reflectionJSON = reflectionJSON
}
// Convenience initializer for raw values
@@ -87,7 +92,8 @@ final class MoodEntryModel {
canDelete: Bool = true,
notes: String? = nil,
photoID: UUID? = nil,
weatherJSON: String? = nil
weatherJSON: String? = nil,
reflectionJSON: String? = nil
) {
self.forDate = forDate
self.moodValue = moodValue
@@ -99,5 +105,6 @@ final class MoodEntryModel {
self.notes = notes
self.photoID = photoID
self.weatherJSON = weatherJSON
self.reflectionJSON = reflectionJSON
}
}

View File

@@ -51,6 +51,18 @@ extension DataController {
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
@discardableResult

View File

@@ -54,7 +54,7 @@ class ExportService {
// MARK: - CSV Export
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 }
@@ -63,11 +63,14 @@ class ExportService {
let mood = entry.mood.widgetDisplayName
let moodValue = entry.moodValue + 1 // 1-5 scale
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 entryType = EntryType(rawValue: entry.entryType)?.description ?? "Unknown"
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

View File

@@ -213,6 +213,18 @@ final class ReportPDFGenerator {
<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 """
@@ -261,6 +273,13 @@ final class ReportPDFGenerator {
}
}
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: "&amp;")
@@ -396,6 +415,10 @@ final class ReportPDFGenerator {
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 {

View 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
}
}
}

View File

@@ -322,7 +322,12 @@ class ReportsViewModel: ObservableObject {
let day = entry.date.formatted(.dateTime.weekday(.abbreviated))
let mood = entry.mood.widgetDisplayName
let notes = entry.notes ?? "no notes"
return "\(day): \(mood) (\(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 = """

View File

@@ -158,6 +158,7 @@ struct EntryDetailView: View {
@State private var showDeleteConfirmation = false
@State private var showFullScreenPhoto = false
@State private var selectedPhotoItem: PhotosPickerItem?
@State private var showReflectionFlow = false
@State private var selectedMood: Mood?
private var currentMood: Mood {
@@ -184,6 +185,11 @@ struct EntryDetailView: View {
// Mood section
moodSection
// Guided reflection section
if currentMood != .missing && currentMood != .placeholder {
reflectionSection
}
// Notes section
notesSection
@@ -218,6 +224,9 @@ struct EntryDetailView: View {
.sheet(isPresented: $showNoteEditor) {
NoteEditorView(entry: entry)
}
.sheet(isPresented: $showReflectionFlow) {
GuidedReflectionView(entry: entry)
}
.alert("Delete Entry", isPresented: $showDeleteConfirmation) {
Button("Delete", role: .destructive) {
onDelete()
@@ -417,6 +426,74 @@ 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")