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:
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user