Adds a 5-step negative-mood reflection flow with an evidence-examination step, Socratic templated questions that back-reference prior answers, and a deterministic cognitive-distortion detector that routes the perspective- check prompt to a distortion-specific reframe. Includes CBT plan docs, flowchart, stats research notes, and MCP config. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
952 lines
33 KiB
Swift
952 lines
33 KiB
Swift
//
|
|
// GuidedReflectionView.swift
|
|
// Reflect
|
|
//
|
|
// Data-driven guided reflection flow with stable step state.
|
|
//
|
|
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
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 draft: GuidedReflectionDraft
|
|
@State private var currentStepID: Int
|
|
@State private var focusedStepID: Int?
|
|
@State private var keyboardHeight: CGFloat = 0
|
|
@State private var isSaving = false
|
|
@State private var showDiscardAlert = false
|
|
@State private var showInfoSheet = false
|
|
@State private var showFeedback = false
|
|
@State private var savedReflection: GuidedReflection?
|
|
|
|
private let initialDraft: GuidedReflectionDraft
|
|
|
|
private static let maxCharacters = 500
|
|
private static let minimumEditorHeight: CGFloat = 160
|
|
|
|
private var textColor: Color { theme.currentTheme.labelColor }
|
|
private var accentColor: Color { moodTint.color(forMood: entry.mood) }
|
|
|
|
private var currentStepIndex: Int {
|
|
draft.index(forStepID: currentStepID) ?? 0
|
|
}
|
|
|
|
private var currentStep: GuidedReflectionDraft.Step? {
|
|
draft.step(forStepID: currentStepID)
|
|
}
|
|
|
|
private var totalSteps: Int {
|
|
draft.steps.count
|
|
}
|
|
|
|
private var hasUnsavedChanges: Bool {
|
|
draft != initialDraft
|
|
}
|
|
|
|
private var currentStepHasText: Bool {
|
|
currentStep?.hasAnswer ?? false
|
|
}
|
|
|
|
private var canGoBack: Bool {
|
|
draft.stepID(before: currentStepID) != nil
|
|
}
|
|
|
|
private var isLastStep: Bool {
|
|
draft.stepID(after: currentStepID) == nil
|
|
}
|
|
|
|
init(entry: MoodEntryModel) {
|
|
self.entry = entry
|
|
|
|
let existing = entry.reflectionJSON.flatMap { GuidedReflection.decode(from: $0) }
|
|
?? GuidedReflection.createNew(for: entry.mood)
|
|
let draft = GuidedReflectionDraft(reflection: existing)
|
|
let startingStepID = draft.firstUnansweredStepID ?? draft.steps.first?.id ?? 0
|
|
|
|
self._draft = State(initialValue: draft)
|
|
self._currentStepID = State(initialValue: startingStepID)
|
|
self._focusedStepID = State(initialValue: nil)
|
|
self.initialDraft = draft
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ZStack {
|
|
ScrollViewReader { proxy in
|
|
reflectionSheetContent(with: proxy)
|
|
}
|
|
.blur(radius: showFeedback ? 6 : 0)
|
|
.allowsHitTesting(!showFeedback)
|
|
|
|
if showFeedback, let savedReflection {
|
|
Color.black.opacity(0.3)
|
|
.ignoresSafeArea()
|
|
.onTapGesture { }
|
|
|
|
ReflectionFeedbackView(
|
|
mood: entry.mood,
|
|
reflection: savedReflection,
|
|
onDismiss: { dismiss() }
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func reflectionSheetContent(with proxy: ScrollViewProxy) -> some View {
|
|
sheetLayout
|
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
|
if keyboardHeight == 0 {
|
|
actionBar
|
|
}
|
|
}
|
|
.navigationTitle(String(localized: "guided_reflection_title"))
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.accessibilityIdentifier(AccessibilityID.GuidedReflection.sheet)
|
|
.toolbar { navigationToolbar }
|
|
.alert(
|
|
String(localized: "guided_reflection_unsaved_title"),
|
|
isPresented: $showDiscardAlert
|
|
) {
|
|
Button(String(localized: "guided_reflection_discard"), role: .destructive) {
|
|
dismiss()
|
|
}
|
|
.accessibilityIdentifier(AccessibilityID.GuidedReflection.discardButton)
|
|
Button(String(localized: "Cancel"), role: .cancel) { }
|
|
.accessibilityIdentifier(AccessibilityID.GuidedReflection.keepEditingButton)
|
|
} message: {
|
|
Text(String(localized: "guided_reflection_unsaved_message"))
|
|
}
|
|
.trackScreen(.guidedReflection)
|
|
.sheet(isPresented: $showInfoSheet) {
|
|
GuidedReflectionInfoView()
|
|
}
|
|
.onChange(of: currentStepID) { _, newStepID in
|
|
DispatchQueue.main.async {
|
|
scrollTo(stepID: newStepID, with: proxy)
|
|
}
|
|
}
|
|
.onChange(of: focusedStepID) { _, newFocusedStepID in
|
|
guard let newFocusedStepID else { return }
|
|
DispatchQueue.main.async {
|
|
scrollFocusedEditor(stepID: newFocusedStepID, with: proxy)
|
|
}
|
|
}
|
|
.onChange(of: keyboardHeight) { _, newKeyboardHeight in
|
|
guard newKeyboardHeight > 0, let focusedStepID else { return }
|
|
DispatchQueue.main.async {
|
|
scrollFocusedEditor(stepID: focusedStepID, with: proxy)
|
|
}
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification)) { notification in
|
|
keyboardHeight = keyboardOverlap(from: notification)
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in
|
|
keyboardHeight = 0
|
|
}
|
|
}
|
|
|
|
private var sheetLayout: some View {
|
|
VStack(spacing: 0) {
|
|
entryHeader
|
|
.padding()
|
|
.background(Color(.systemGray6))
|
|
|
|
Divider()
|
|
|
|
reflectionScrollView
|
|
}
|
|
}
|
|
|
|
private var reflectionScrollView: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 24) {
|
|
progressSection
|
|
|
|
// Pre-intensity rating — shown only on the first step, once.
|
|
// Captures the baseline emotional intensity so we can measure shift.
|
|
if currentStepIndex == 0 {
|
|
intensityCard(
|
|
title: String(localized: "guided_reflection_pre_intensity_title"),
|
|
value: preIntensityBinding
|
|
)
|
|
}
|
|
|
|
if let step = currentStep {
|
|
stepCard(step)
|
|
.id(step.id)
|
|
}
|
|
|
|
// Post-intensity rating — shown on the final step, below the question.
|
|
// Measures how much the reflection shifted the feeling.
|
|
if isLastStep {
|
|
intensityCard(
|
|
title: String(localized: "guided_reflection_post_intensity_title"),
|
|
value: postIntensityBinding
|
|
)
|
|
}
|
|
}
|
|
.padding(.horizontal)
|
|
.padding(.top, 20)
|
|
.padding(.bottom, contentBottomPadding)
|
|
}
|
|
.scrollDismissesKeyboard(.interactively)
|
|
.onScrollPhaseChange(handleScrollPhaseChange)
|
|
}
|
|
|
|
// MARK: - Intensity Rating UI
|
|
|
|
private var preIntensityBinding: Binding<Int> {
|
|
Binding(
|
|
get: { draft.preIntensity ?? 5 },
|
|
set: { draft.preIntensity = $0 }
|
|
)
|
|
}
|
|
|
|
private var postIntensityBinding: Binding<Int> {
|
|
Binding(
|
|
get: { draft.postIntensity ?? 5 },
|
|
set: { draft.postIntensity = $0 }
|
|
)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func intensityCard(title: String, value: Binding<Int>) -> some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text(title)
|
|
.font(.subheadline)
|
|
.fontWeight(.medium)
|
|
.foregroundColor(textColor)
|
|
|
|
HStack {
|
|
Text(String(localized: "guided_reflection_intensity_low"))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
Text("\(value.wrappedValue) / 10")
|
|
.font(.caption)
|
|
.fontWeight(.semibold)
|
|
.foregroundStyle(accentColor)
|
|
Spacer()
|
|
Text(String(localized: "guided_reflection_intensity_high"))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Slider(
|
|
value: Binding(
|
|
get: { Double(value.wrappedValue) },
|
|
set: { value.wrappedValue = Int($0.rounded()) }
|
|
),
|
|
in: 0...10,
|
|
step: 1
|
|
)
|
|
.tint(accentColor)
|
|
}
|
|
.padding(16)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 20)
|
|
.fill(Color(.secondarySystemBackground))
|
|
)
|
|
}
|
|
|
|
@ToolbarContentBuilder
|
|
private var navigationToolbar: some ToolbarContent {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button(String(localized: "Cancel")) {
|
|
handleCancel()
|
|
}
|
|
.accessibilityIdentifier(AccessibilityID.GuidedReflection.cancelButton)
|
|
}
|
|
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Button {
|
|
showInfoSheet = true
|
|
} label: {
|
|
Image(systemName: "info.circle")
|
|
}
|
|
.accessibilityLabel(String(localized: "guided_reflection_about_title"))
|
|
.accessibilityIdentifier(AccessibilityID.GuidedReflection.infoButton)
|
|
}
|
|
}
|
|
|
|
private var entryHeader: some View {
|
|
HStack(spacing: 12) {
|
|
Circle()
|
|
.fill(accentColor.opacity(0.2))
|
|
.frame(width: 50, height: 50)
|
|
.overlay(
|
|
imagePack.icon(forMood: entry.mood)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 28, height: 28)
|
|
.foregroundColor(accentColor)
|
|
)
|
|
|
|
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(accentColor)
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
}
|
|
|
|
private var progressSection: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack(alignment: .firstTextBaseline) {
|
|
Text("\(currentStepIndex + 1) / \(max(totalSteps, 1))")
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
|
|
Spacer()
|
|
|
|
Text(draft.moodCategory.techniqueName)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.trailing)
|
|
}
|
|
|
|
HStack(spacing: 8) {
|
|
ForEach(draft.steps) { step in
|
|
Capsule()
|
|
.fill(progressColor(for: step))
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 10)
|
|
}
|
|
}
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel(String(localized: "\(draft.steps.filter(\.hasAnswer).count) of \(draft.steps.count) steps completed"))
|
|
}
|
|
.accessibilityIdentifier(AccessibilityID.GuidedReflection.progressDots)
|
|
}
|
|
|
|
private func stepCard(_ step: GuidedReflectionDraft.Step) -> some View {
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
if let label = step.label {
|
|
Text(label.uppercased())
|
|
.font(.caption)
|
|
.fontWeight(.semibold)
|
|
.foregroundStyle(.secondary)
|
|
.tracking(1.5)
|
|
}
|
|
|
|
// Resolve the template against current answers so Socratic back-references
|
|
// (e.g., "Looking at '<your thought>' again...") reflect edits in real time.
|
|
Text(draft.resolvedQuestion(for: step))
|
|
.font(.title3)
|
|
.fontWeight(.medium)
|
|
.foregroundColor(textColor)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.accessibilityIdentifier(AccessibilityID.GuidedReflection.questionLabel(step: currentStepIndex))
|
|
|
|
editor(for: step)
|
|
|
|
// Specificity probe — gentle nudge if the Q1 (situation) answer is too vague.
|
|
// CBT works better on concrete events than generalized feelings.
|
|
if step.id == 0 && needsSpecificityProbe(for: step.answer) {
|
|
specificityProbe
|
|
}
|
|
|
|
if let chips = step.chips {
|
|
ChipSelectionView(
|
|
chips: chips,
|
|
accentColor: accentColor,
|
|
textAnswer: answerBinding(for: step.id),
|
|
onInsert: { chip in
|
|
handleChipInsertion(chip, into: step.id)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
.padding(20)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 24)
|
|
.fill(Color(.secondarySystemBackground))
|
|
)
|
|
}
|
|
|
|
// MARK: - Specificity Probe
|
|
|
|
/// Vague phrases that should trigger the specificity nudge even if the text is
|
|
/// technically long enough. Matched case-insensitively against a trimmed answer.
|
|
private static let vaguePhrases: Set<String> = [
|
|
"idk", "i don't know", "i dont know",
|
|
"nothing", "everything", "nothing really",
|
|
"same as always", "same old", "dunno", "no idea"
|
|
]
|
|
|
|
private func needsSpecificityProbe(for answer: String) -> Bool {
|
|
let trimmed = answer.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return false } // don't nag before they've started
|
|
if trimmed.count < 25 { return true }
|
|
let lower = trimmed.lowercased()
|
|
return Self.vaguePhrases.contains(where: { lower == $0 || lower.hasPrefix($0 + " ") })
|
|
}
|
|
|
|
private var specificityProbe: some View {
|
|
HStack(alignment: .top, spacing: 10) {
|
|
Image(systemName: "lightbulb.fill")
|
|
.foregroundStyle(accentColor)
|
|
.font(.footnote)
|
|
Text(String(localized: "guided_reflection_specificity_probe"))
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.padding(12)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(accentColor.opacity(0.08))
|
|
)
|
|
}
|
|
|
|
private func editor(for step: GuidedReflectionDraft.Step) -> some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
AutoSizingReflectionTextEditor(
|
|
text: answerBinding(for: step.id),
|
|
isFocused: focusBinding(for: step.id),
|
|
maxCharacters: Self.maxCharacters,
|
|
onDone: { focusedStepID = nil }
|
|
)
|
|
.id(step.id)
|
|
.frame(height: Self.minimumEditorHeight, alignment: .top)
|
|
.padding(12)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 16)
|
|
.fill(Color(.systemBackground))
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 16)
|
|
.stroke(Color(.systemGray4), lineWidth: 1)
|
|
)
|
|
.accessibilityIdentifier(AccessibilityID.GuidedReflection.textEditor)
|
|
|
|
HStack {
|
|
Spacer()
|
|
Text("\(step.characterCount)/\(Self.maxCharacters)")
|
|
.font(.caption)
|
|
.foregroundStyle(step.characterCount >= Self.maxCharacters ? .red : .secondary)
|
|
}
|
|
|
|
Color.clear
|
|
.frame(height: 1)
|
|
.id(editorAnchorID(for: step.id))
|
|
}
|
|
}
|
|
|
|
private var actionBar: some View {
|
|
VStack(spacing: 0) {
|
|
Divider()
|
|
|
|
HStack(spacing: 12) {
|
|
if canGoBack {
|
|
Button(String(localized: "guided_reflection_back")) {
|
|
navigateBack()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.accessibilityIdentifier(AccessibilityID.GuidedReflection.backButton)
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
Button(isLastStep ? String(localized: "guided_reflection_save") : String(localized: "guided_reflection_next")) {
|
|
handlePrimaryAction()
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(isLastStep ? .green : accentColor)
|
|
.disabled(primaryActionDisabled)
|
|
.accessibilityIdentifier(isLastStep ? AccessibilityID.GuidedReflection.saveButton : AccessibilityID.GuidedReflection.nextButton)
|
|
}
|
|
.padding(.horizontal)
|
|
.padding(.top, 12)
|
|
.padding(.bottom, 12)
|
|
}
|
|
.background(.regularMaterial)
|
|
}
|
|
|
|
private var primaryActionDisabled: Bool {
|
|
if isLastStep {
|
|
return !draft.isComplete || isSaving
|
|
}
|
|
return !currentStepHasText
|
|
}
|
|
|
|
private var contentBottomPadding: CGFloat {
|
|
28 + (keyboardHeight > 0 ? keyboardHeight + 48 : 0)
|
|
}
|
|
|
|
private func answerBinding(for stepID: Int) -> Binding<String> {
|
|
Binding(
|
|
get: {
|
|
draft.answer(forStepID: stepID) ?? ""
|
|
},
|
|
set: { newValue in
|
|
let truncated = String(newValue.prefix(Self.maxCharacters))
|
|
draft.updateAnswer(truncated, forStepID: stepID)
|
|
}
|
|
)
|
|
}
|
|
|
|
private func focusBinding(for stepID: Int) -> Binding<Bool> {
|
|
Binding(
|
|
get: {
|
|
focusedStepID == stepID
|
|
},
|
|
set: { isFocused in
|
|
if isFocused {
|
|
focusedStepID = stepID
|
|
} else if focusedStepID == stepID {
|
|
focusedStepID = nil
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
private func progressColor(for step: GuidedReflectionDraft.Step) -> Color {
|
|
if step.id == currentStepID {
|
|
return accentColor
|
|
}
|
|
if step.hasAnswer {
|
|
return accentColor.opacity(0.35)
|
|
}
|
|
return Color(.systemGray5)
|
|
}
|
|
|
|
private func handleCancel() {
|
|
focusedStepID = nil
|
|
if hasUnsavedChanges {
|
|
showDiscardAlert = true
|
|
} else {
|
|
dismiss()
|
|
}
|
|
}
|
|
|
|
private func navigateForward() {
|
|
guard let nextStepID = draft.stepID(after: currentStepID) else { return }
|
|
focusedStepID = nil
|
|
|
|
// When leaving Q2 on the negative path, classify the automatic thought and
|
|
// swap Q3's template to the tailored reframe prompt. Idempotent and safe
|
|
// to run on every forward navigation.
|
|
if draft.moodCategory == .negative && currentStepID == 1 {
|
|
draft.recomputeDistortion()
|
|
}
|
|
|
|
updateCurrentStep(to: nextStepID)
|
|
}
|
|
|
|
private func navigateBack() {
|
|
guard let previousStepID = draft.stepID(before: currentStepID) else { return }
|
|
focusedStepID = nil
|
|
updateCurrentStep(to: previousStepID)
|
|
}
|
|
|
|
private func updateCurrentStep(to stepID: Int) {
|
|
if UIAccessibility.isReduceMotionEnabled {
|
|
currentStepID = stepID
|
|
} else {
|
|
withAnimation(.easeInOut(duration: 0.25)) {
|
|
currentStepID = stepID
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleChipInsertion(_ chip: String, into stepID: Int) {
|
|
focusedStepID = nil
|
|
draft.registerChip(chip, forStepID: stepID)
|
|
}
|
|
|
|
private func handleScrollPhaseChange(oldPhase: ScrollPhase, newPhase: ScrollPhase, context: ScrollPhaseChangeContext) {
|
|
guard newPhase == .tracking || newPhase == .interacting else { return }
|
|
focusedStepID = nil
|
|
}
|
|
|
|
private func handlePrimaryAction() {
|
|
if isLastStep {
|
|
saveReflection()
|
|
} else {
|
|
navigateForward()
|
|
}
|
|
}
|
|
|
|
private func saveReflection() {
|
|
guard !isSaving else { return }
|
|
isSaving = true
|
|
focusedStepID = nil
|
|
|
|
var reflection = draft.makeReflection()
|
|
if reflection.isComplete {
|
|
reflection.completedAt = Date()
|
|
}
|
|
|
|
let success = DataController.shared.updateReflection(
|
|
forDate: entry.forDate,
|
|
reflectionJSON: reflection.encode()
|
|
)
|
|
|
|
if success {
|
|
// Fire-and-forget tag extraction
|
|
if #available(iOS 26, *), !IAPManager.shared.shouldShowPaywall {
|
|
Task {
|
|
await FoundationModelsTagService.shared.extractAndSaveTags(for: entry)
|
|
}
|
|
}
|
|
|
|
// Show AI feedback if reflection is complete and AI is potentially available
|
|
if reflection.isComplete {
|
|
savedReflection = reflection
|
|
withAnimation(.easeInOut(duration: 0.3)) {
|
|
showFeedback = true
|
|
}
|
|
} else {
|
|
dismiss()
|
|
}
|
|
} else {
|
|
isSaving = false
|
|
}
|
|
}
|
|
|
|
private func scrollTo(stepID: Int, with proxy: ScrollViewProxy) {
|
|
if UIAccessibility.isReduceMotionEnabled {
|
|
proxy.scrollTo(stepID, anchor: .top)
|
|
} else {
|
|
withAnimation(.easeInOut(duration: 0.25)) {
|
|
proxy.scrollTo(stepID, anchor: .top)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func scrollFocusedEditor(stepID: Int, with proxy: ScrollViewProxy) {
|
|
let anchorID = editorAnchorID(for: stepID)
|
|
if UIAccessibility.isReduceMotionEnabled {
|
|
proxy.scrollTo(anchorID, anchor: .bottom)
|
|
} else {
|
|
withAnimation(.easeInOut(duration: 0.25)) {
|
|
proxy.scrollTo(anchorID, anchor: .bottom)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func editorAnchorID(for stepID: Int) -> String {
|
|
"guided_reflection_editor_anchor_\(stepID)"
|
|
}
|
|
|
|
private func keyboardOverlap(from notification: Notification) -> CGFloat {
|
|
guard
|
|
let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect
|
|
else {
|
|
return 0
|
|
}
|
|
|
|
let screenHeight = UIScreen.main.bounds.height
|
|
return max(0, screenHeight - frame.minY)
|
|
}
|
|
}
|
|
|
|
private struct GuidedReflectionDraft: Equatable {
|
|
struct Step: Identifiable, Equatable {
|
|
let id: Int
|
|
/// The template this step renders from. Contains the raw localized text and
|
|
/// optional placeholder ref. The user-visible question is computed by calling
|
|
/// `GuidedReflectionDraft.resolvedQuestion(for:)` — which injects prior answers.
|
|
var template: QuestionTemplate
|
|
var label: String?
|
|
let chips: QuestionChips?
|
|
var answer: String
|
|
var selectedChips: [String]
|
|
|
|
var hasAnswer: Bool {
|
|
!answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
}
|
|
|
|
var characterCount: Int {
|
|
answer.count
|
|
}
|
|
|
|
static func == (lhs: Step, rhs: Step) -> Bool {
|
|
lhs.id == rhs.id &&
|
|
lhs.template == rhs.template &&
|
|
lhs.label == rhs.label &&
|
|
lhs.answer == rhs.answer &&
|
|
lhs.selectedChips == rhs.selectedChips
|
|
}
|
|
}
|
|
|
|
let moodCategory: MoodCategory
|
|
var steps: [Step]
|
|
var completedAt: Date?
|
|
var preIntensity: Int?
|
|
var postIntensity: Int?
|
|
var detectedDistortion: CognitiveDistortion?
|
|
|
|
init(reflection: GuidedReflection) {
|
|
moodCategory = reflection.moodCategory
|
|
completedAt = reflection.completedAt
|
|
preIntensity = reflection.preIntensity
|
|
postIntensity = reflection.postIntensity
|
|
detectedDistortion = reflection.detectedDistortion
|
|
|
|
let templates = GuidedReflection.questionTemplates(for: reflection.moodCategory)
|
|
let labels = reflection.moodCategory.stepLabels
|
|
|
|
steps = templates.enumerated().map { index, template in
|
|
// Preserve existing answers if reflection is being resumed.
|
|
let existingResponse = reflection.responses.first(where: { $0.id == index })
|
|
?? (reflection.responses.indices.contains(index) ? reflection.responses[index] : nil)
|
|
|
|
return Step(
|
|
id: index,
|
|
template: template,
|
|
label: labels.indices.contains(index) ? labels[index] : nil,
|
|
chips: QuestionChips.chips(for: reflection.moodCategory, questionIndex: index),
|
|
answer: existingResponse?.answer ?? "",
|
|
selectedChips: existingResponse?.selectedChips ?? []
|
|
)
|
|
}
|
|
|
|
// Re-apply any previously-detected distortion so Q3 restores its tailored template.
|
|
if let distortion = detectedDistortion, moodCategory == .negative {
|
|
applyDistortion(distortion)
|
|
}
|
|
}
|
|
|
|
/// Produces (index, answer) tuples suitable for `QuestionTemplate.resolved(with:)`.
|
|
private var answerTuples: [(index: Int, text: String)] {
|
|
steps.map { ($0.id, $0.answer) }
|
|
}
|
|
|
|
/// Resolves the user-visible question text for a step, injecting the latest
|
|
/// value of any referenced prior answer. Called at render time by the view.
|
|
func resolvedQuestion(for step: Step) -> String {
|
|
step.template.resolved(with: answerTuples)
|
|
}
|
|
|
|
func resolvedQuestion(forStepID stepID: Int) -> String {
|
|
guard let step = step(forStepID: stepID) else { return "" }
|
|
return resolvedQuestion(for: step)
|
|
}
|
|
|
|
/// Mutating: detect the cognitive distortion in the current Q2 answer (negative path only)
|
|
/// and swap Q3's template to the tailored prompt. Safe to call repeatedly — if Q2 is empty
|
|
/// or detection yields `.unknown` this resets to the fallback template.
|
|
mutating func recomputeDistortion() {
|
|
guard moodCategory == .negative,
|
|
let q2 = steps.first(where: { $0.id == 1 }),
|
|
!q2.answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
else {
|
|
detectedDistortion = nil
|
|
applyDistortion(.unknown) // reset Q3 label to generic
|
|
return
|
|
}
|
|
|
|
let distortion = CognitiveDistortionDetector.detect(in: q2.answer)
|
|
detectedDistortion = distortion == .unknown ? nil : distortion
|
|
applyDistortion(distortion)
|
|
}
|
|
|
|
/// Overwrites Q3's template + label based on the detected distortion.
|
|
private mutating func applyDistortion(_ distortion: CognitiveDistortion) {
|
|
guard let q3Index = steps.firstIndex(where: { $0.id == 2 }) else { return }
|
|
steps[q3Index].template = distortion.perspectiveCheckTemplate
|
|
if distortion != .unknown {
|
|
steps[q3Index].label = distortion.stepLabel
|
|
} else {
|
|
// Reset to the default "Perspective Check" label from MoodCategory.stepLabels.
|
|
let defaults = moodCategory.stepLabels
|
|
steps[q3Index].label = defaults.indices.contains(2) ? defaults[2] : nil
|
|
}
|
|
}
|
|
|
|
var firstUnansweredStepID: Int? {
|
|
steps.first(where: { !$0.hasAnswer })?.id
|
|
}
|
|
|
|
var isComplete: Bool {
|
|
steps.count == moodCategory.questionCount && steps.allSatisfy(\.hasAnswer)
|
|
}
|
|
|
|
func step(forStepID stepID: Int) -> Step? {
|
|
steps.first(where: { $0.id == stepID })
|
|
}
|
|
|
|
func index(forStepID stepID: Int) -> Int? {
|
|
steps.firstIndex(where: { $0.id == stepID })
|
|
}
|
|
|
|
func answer(forStepID stepID: Int) -> String? {
|
|
step(forStepID: stepID)?.answer
|
|
}
|
|
|
|
func stepID(before stepID: Int) -> Int? {
|
|
guard let index = index(forStepID: stepID), index > 0 else { return nil }
|
|
return steps[index - 1].id
|
|
}
|
|
|
|
func stepID(after stepID: Int) -> Int? {
|
|
guard let index = index(forStepID: stepID), index < steps.count - 1 else { return nil }
|
|
return steps[index + 1].id
|
|
}
|
|
|
|
mutating func updateAnswer(_ answer: String, forStepID stepID: Int) {
|
|
guard let index = index(forStepID: stepID) else { return }
|
|
steps[index].answer = answer
|
|
}
|
|
|
|
mutating func registerChip(_ chip: String, forStepID stepID: Int) {
|
|
guard let index = index(forStepID: stepID) else { return }
|
|
if !steps[index].selectedChips.contains(chip) {
|
|
steps[index].selectedChips.append(chip)
|
|
}
|
|
}
|
|
|
|
func makeReflection() -> GuidedReflection {
|
|
GuidedReflection(
|
|
moodCategory: moodCategory,
|
|
responses: steps.map { step in
|
|
// Persist the user-visible resolved question text — not the raw template —
|
|
// so downstream consumers (AI feedback, history view) see what the user saw.
|
|
GuidedReflection.Response(
|
|
id: step.id,
|
|
question: resolvedQuestion(for: step),
|
|
answer: step.answer,
|
|
selectedChips: step.selectedChips
|
|
)
|
|
},
|
|
completedAt: completedAt,
|
|
preIntensity: preIntensity,
|
|
postIntensity: postIntensity,
|
|
detectedDistortion: detectedDistortion
|
|
)
|
|
}
|
|
}
|
|
|
|
private struct AutoSizingReflectionTextEditor: UIViewRepresentable {
|
|
@Binding var text: String
|
|
@Binding var isFocused: Bool
|
|
let maxCharacters: Int
|
|
let onDone: () -> Void
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator(parent: self)
|
|
}
|
|
|
|
func makeUIView(context: Context) -> UITextView {
|
|
let textView = UITextView()
|
|
textView.delegate = context.coordinator
|
|
textView.isScrollEnabled = true
|
|
textView.alwaysBounceVertical = true
|
|
textView.font = .preferredFont(forTextStyle: .body)
|
|
textView.backgroundColor = .clear
|
|
textView.textColor = .label
|
|
textView.textContainerInset = .zero
|
|
textView.textContainer.lineFragmentPadding = 0
|
|
textView.keyboardDismissMode = .interactive
|
|
context.coordinator.configureAccessoryView(for: textView)
|
|
return textView
|
|
}
|
|
|
|
func updateUIView(_ textView: UITextView, context: Context) {
|
|
context.coordinator.parent = self
|
|
|
|
if textView.text != text {
|
|
textView.text = text
|
|
DispatchQueue.main.async {
|
|
scrollCaretToVisible(in: textView)
|
|
}
|
|
}
|
|
|
|
if isFocused && !textView.isFirstResponder {
|
|
DispatchQueue.main.async {
|
|
textView.becomeFirstResponder()
|
|
scrollCaretToVisible(in: textView)
|
|
}
|
|
} else if !isFocused && textView.isFirstResponder {
|
|
DispatchQueue.main.async {
|
|
textView.resignFirstResponder()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func scrollCaretToVisible(in textView: UITextView) {
|
|
guard let selectedTextRange = textView.selectedTextRange else { return }
|
|
textView.layoutIfNeeded()
|
|
let caretRect = textView.caretRect(for: selectedTextRange.end)
|
|
let paddedRect = caretRect.insetBy(dx: 0, dy: -16)
|
|
textView.scrollRectToVisible(paddedRect, animated: false)
|
|
}
|
|
|
|
final class Coordinator: NSObject, UITextViewDelegate {
|
|
var parent: AutoSizingReflectionTextEditor
|
|
weak var textView: UITextView?
|
|
private let accessoryToolbar = UIToolbar()
|
|
private let spacerItem = UIBarButtonItem(systemItem: .flexibleSpace)
|
|
private lazy var dismissButtonItem = UIBarButtonItem(
|
|
image: UIImage(systemName: "keyboard.chevron.compact.down"),
|
|
style: .plain,
|
|
target: self,
|
|
action: #selector(handleDoneTapped)
|
|
)
|
|
|
|
init(parent: AutoSizingReflectionTextEditor) {
|
|
self.parent = parent
|
|
}
|
|
|
|
func configureAccessoryView(for textView: UITextView) {
|
|
self.textView = textView
|
|
accessoryToolbar.sizeToFit()
|
|
accessoryToolbar.setItems([spacerItem, dismissButtonItem], animated: false)
|
|
textView.inputAccessoryView = accessoryToolbar
|
|
}
|
|
|
|
func textViewDidBeginEditing(_ textView: UITextView) {
|
|
if !parent.isFocused {
|
|
parent.isFocused = true
|
|
}
|
|
parent.scrollCaretToVisible(in: textView)
|
|
}
|
|
|
|
func textViewDidEndEditing(_ textView: UITextView) {
|
|
if parent.isFocused {
|
|
parent.isFocused = false
|
|
}
|
|
}
|
|
|
|
func textViewDidChange(_ textView: UITextView) {
|
|
let truncated = String(textView.text.prefix(parent.maxCharacters))
|
|
if textView.text != truncated {
|
|
textView.text = truncated
|
|
}
|
|
|
|
if parent.text != truncated {
|
|
parent.text = truncated
|
|
}
|
|
|
|
parent.scrollCaretToVisible(in: textView)
|
|
}
|
|
|
|
func textViewDidChangeSelection(_ textView: UITextView) {
|
|
guard textView.isFirstResponder else { return }
|
|
parent.scrollCaretToVisible(in: textView)
|
|
}
|
|
|
|
@objc private func handleDoneTapped() {
|
|
parent.onDone()
|
|
}
|
|
}
|
|
}
|