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

@@ -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")