Files
Reflect/Shared/Views/GuidedReflectionView.swift
Trey T 8ae8d23f95 Add mood-specific selectable chip answers to guided reflection flow
Reduces friction in the guided reflection by offering predefined tappable
chip answers tailored to each mood category's therapeutic framework:
- Positive (Behavioral Activation): savoring emotions + reinforcing actions
- Neutral (ACT Cognitive Defusion): ambivalent feelings + defusion reframes + values
- Negative (CBT Thought Record): automatic negative thoughts + compassionate reframes + grounding actions

Chips appear between the question and text editor. Tapping toggles selection
and auto-fills the text field. "More" expander reveals additional options.
Free text always remains available alongside chips.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 13:37:55 -05:00

330 lines
11 KiB
Swift

//
// 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
@State private var showInfoSheet = 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)
}
ToolbarItem(placement: .primaryAction) {
Button {
showInfoSheet = true
} label: {
Image(systemName: "info.circle")
}
.accessibilityLabel(String(localized: "guided_reflection_about_title"))
}
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)
.sheet(isPresented: $showInfoSheet) {
GuidedReflectionInfoView()
}
}
}
// 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]
let stepLabels = reflection.moodCategory.stepLabels
// CBT step label
if currentStep < stepLabels.count {
Text(stepLabels[currentStep].uppercased())
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
.tracking(1.5)
}
Text(response.question)
.font(.title3)
.fontWeight(.medium)
.foregroundColor(textColor)
.fixedSize(horizontal: false, vertical: true)
.accessibilityIdentifier(AccessibilityID.GuidedReflection.questionLabel(step: currentStep))
.id("question_\(currentStep)")
if let chips = QuestionChips.chips(for: reflection.moodCategory, questionIndex: currentStep) {
ChipSelectionView(
chips: chips,
accentColor: moodTint.color(forMood: entry.mood),
selectedChips: $reflection.responses[currentStep].selectedChips,
textAnswer: $reflection.responses[currentStep].answer
)
}
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
}
}
}