Replace generic journaling prompts with evidence-based therapeutic techniques: CBT thought record for negative moods, ACT cognitive defusion for neutral, and behavioral activation for positive. Each question now shows a clinical step label (e.g. SITUATION, REFRAME). Added info button linking to a new sheet explaining the techniques with citations to Beck, Harris, and Martell/Dimidjian. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
321 lines
11 KiB
Swift
321 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)")
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|