// // GuidedReflection.swift // Reflect // // Codable model for guided reflection responses, stored as JSON in MoodEntryModel. // import Foundation // MARK: - Mood Category enum MoodCategory: String, Codable { case positive // great, good → 3 questions (Behavioral Activation) case neutral // average → 4 questions (ACT Cognitive Defusion) case negative // bad, horrible → 5 questions (CBT Thought Record with evidence step) init(from mood: Mood) { switch mood { case .great, .good: self = .positive case .average: self = .neutral case .horrible, .bad: self = .negative default: self = .neutral } } var questionCount: Int { switch self { case .positive: return 3 case .neutral: return 4 case .negative: return 5 } } /// The therapeutic technique name for display purposes. var techniqueName: String { switch self { case .positive: return "Behavioral Activation" case .neutral: return "Acceptance & Commitment Therapy" case .negative: return "Cognitive Behavioral Therapy" } } /// Short CBT step labels shown above each question. var stepLabels: [String] { switch self { case .negative: return [ String(localized: "Situation"), String(localized: "Automatic Thought"), String(localized: "Perspective Check"), String(localized: "Evidence"), String(localized: "Reframe"), ] case .neutral: return [ String(localized: "Awareness"), String(localized: "Thought"), String(localized: "Defusion"), String(localized: "Values"), ] case .positive: return [ String(localized: "Activity"), String(localized: "Awareness"), String(localized: "Planning"), ] } } } // MARK: - Cognitive Distortion /// Detected cognitive distortion type in a user's automatic thought. /// Used to route the perspective-check question to a distortion-specific reframe. enum CognitiveDistortion: String, Codable { case overgeneralization case shouldStatement case labeling case personalization case catastrophizing case mindReading case unknown } // MARK: - Question Template /// A guided reflection question. May contain `%@` placeholders resolved at render time /// by substituting the answer from a prior question (Socratic back-reference). struct QuestionTemplate: Equatable { /// Localized template text — may contain a single `%@` format specifier. let text: String /// Zero-based index of the question whose answer to inject in place of `%@`. /// Nil if this template is static (no placeholder). let placeholderRef: Int? /// Resolve the template against the provided ordered list of answers. /// - Parameter answers: Array of (index, answer) pairs where `index` matches `placeholderRef`. func resolved(with answers: [(index: Int, text: String)]) -> String { guard let ref = placeholderRef else { return text } let referenced = answers .first(where: { $0.index == ref })? .text .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !referenced.isEmpty, text.contains("%@") else { // Fallback — strip the placeholder marker so we never show a literal "%@". return text.replacingOccurrences(of: "%@", with: "").trimmingCharacters(in: .whitespaces) } let injected = GuidedReflection.truncatedForInjection(referenced) return String(format: text, injected) } } // MARK: - Question Chips struct QuestionChips { let topRow: [String] let expanded: [String] var hasExpanded: Bool { !expanded.isEmpty } static func chips(for category: MoodCategory, questionIndex: Int) -> QuestionChips? { switch (category, questionIndex) { // MARK: Positive (Great/Good) — Behavioral Activation // Q1: "What did you do today…?" — no chips (situational) // Q2: "What thought or moment stands out?" — memorable moments to savor case (.positive, 1): return QuestionChips( topRow: [ String(localized: "guided_chip_pos_moment_conversation"), String(localized: "guided_chip_pos_moment_accomplished"), String(localized: "guided_chip_pos_moment_calm"), String(localized: "guided_chip_pos_moment_laugh"), String(localized: "guided_chip_pos_moment_grateful_person"), String(localized: "guided_chip_pos_moment_small_win"), ], expanded: [ String(localized: "guided_chip_pos_moment_beauty"), String(localized: "guided_chip_pos_moment_connected"), String(localized: "guided_chip_pos_moment_progress"), String(localized: "guided_chip_pos_moment_like_myself"), String(localized: "guided_chip_pos_moment_kindness"), String(localized: "guided_chip_pos_moment_time_well_spent"), ] ) // Q3: "How could you create more days like this?" — reinforcing actions case (.positive, 2): return QuestionChips( topRow: [ String(localized: "guided_chip_pos_act_more_of_this"), String(localized: "guided_chip_pos_act_time_with_people"), String(localized: "guided_chip_pos_act_get_outside"), String(localized: "guided_chip_pos_act_stay_active"), String(localized: "guided_chip_pos_act_keep_routine"), String(localized: "guided_chip_pos_act_practice_gratitude"), ], expanded: [ String(localized: "guided_chip_pos_act_celebrate_wins"), String(localized: "guided_chip_pos_act_hobbies"), String(localized: "guided_chip_pos_act_help_someone"), String(localized: "guided_chip_pos_act_say_yes"), String(localized: "guided_chip_pos_act_rest"), String(localized: "guided_chip_pos_act_limit_doomscroll"), ] ) // MARK: Neutral (Average) — ACT Cognitive Defusion // Q1: "What feeling has been sitting with you?" — ambivalent feelings case (.neutral, 0): return QuestionChips( topRow: [ String(localized: "guided_chip_neu_boredom"), String(localized: "guided_chip_neu_restlessness"), String(localized: "guided_chip_neu_uncertainty"), String(localized: "guided_chip_neu_numbness"), String(localized: "guided_chip_neu_indifference"), String(localized: "guided_chip_neu_distraction"), ], expanded: [ String(localized: "guided_chip_neu_flatness"), String(localized: "guided_chip_neu_disconnection"), String(localized: "guided_chip_neu_ambivalence"), String(localized: "guided_chip_neu_weariness"), String(localized: "guided_chip_neu_apathy"), String(localized: "guided_chip_neu_autopilot"), ] ) // Q2: "What thought is connected to that feeling?" — no chips (specific thought) // Q3: "Is that thought true, or something your mind is telling you?" — defusion reframes case (.neutral, 2): return QuestionChips( topRow: [ String(localized: "guided_chip_neu_def_just_a_thought"), String(localized: "guided_chip_neu_def_storytelling"), String(localized: "guided_chip_neu_def_thought_passed"), String(localized: "guided_chip_neu_def_more_weight"), String(localized: "guided_chip_neu_def_not_helpful"), String(localized: "guided_chip_neu_def_notice_not_act"), String(localized: "guided_chip_neu_def_dont_solve_now"), String(localized: "guided_chip_neu_def_doesnt_define"), ], expanded: [] ) // Q4: "What matters to you about tomorrow?" — values-aligned intentions case (.neutral, 3): return QuestionChips( topRow: [ String(localized: "guided_chip_neu_val_be_present"), String(localized: "guided_chip_neu_val_connect"), String(localized: "guided_chip_neu_val_body"), String(localized: "guided_chip_neu_val_meaningful_work"), String(localized: "guided_chip_neu_val_kind_to_self"), String(localized: "guided_chip_neu_val_small_goal"), ], expanded: [ String(localized: "guided_chip_neu_val_less_autopilot"), String(localized: "guided_chip_neu_val_one_thing_better"), String(localized: "guided_chip_neu_val_ask_what_i_need"), String(localized: "guided_chip_neu_val_let_go"), ] ) // MARK: Negative (Bad/Horrible) — CBT Thought Record // Q1: "What happened today…?" — no chips (situational) // Q2: "What thought kept coming back?" — common automatic negative thoughts case (.negative, 1): return QuestionChips( topRow: [ String(localized: "guided_chip_neg_not_good_enough"), String(localized: "guided_chip_neg_nothing_goes_right"), String(localized: "guided_chip_neg_all_my_fault"), String(localized: "guided_chip_neg_cant_handle"), String(localized: "guided_chip_neg_no_one_understands"), String(localized: "guided_chip_neg_should_have"), ], expanded: [ String(localized: "guided_chip_neg_never_change"), String(localized: "guided_chip_neg_falling_behind"), String(localized: "guided_chip_neg_dont_matter"), String(localized: "guided_chip_neg_something_wrong"), String(localized: "guided_chip_neg_letting_down"), String(localized: "guided_chip_neg_cant_do_right"), ] ) // Q3: "What would you tell a friend?" — compassionate reframes case (.negative, 2): return QuestionChips( topRow: [ String(localized: "guided_chip_neg_too_hard"), String(localized: "guided_chip_neg_one_bad_day"), String(localized: "guided_chip_neg_better_than_think"), String(localized: "guided_chip_neg_ok_to_struggle"), String(localized: "guided_chip_neg_feeling_will_pass"), String(localized: "guided_chip_neg_dont_need_figured"), String(localized: "guided_chip_neg_not_whole_story"), String(localized: "guided_chip_neg_give_grace"), ], expanded: [] ) // Q3 NEW: Evidence — no chips (user explores both sides in free text) // Q4 NEW → Q5: "More balanced way to see it?" — cognitive reframes first, grounding actions expanded case (.negative, 4): return QuestionChips( topRow: [ String(localized: "guided_chip_neg_act_worst_case"), String(localized: "guided_chip_neg_act_facts_feelings"), String(localized: "guided_chip_neg_act_matter_in_week"), String(localized: "guided_chip_neg_act_got_through"), String(localized: "guided_chip_neg_ref_one_chapter"), String(localized: "guided_chip_neg_ref_doing_my_best"), ], expanded: [ String(localized: "guided_chip_neg_act_talk_someone"), String(localized: "guided_chip_neg_act_write_it_out"), String(localized: "guided_chip_neg_act_take_walk"), String(localized: "guided_chip_neg_act_step_away"), String(localized: "guided_chip_neg_act_get_rest"), String(localized: "guided_chip_neg_act_one_small_thing"), ] ) default: return nil } } } // MARK: - Guided Reflection struct GuidedReflection: Codable, Equatable { struct Response: Codable, Equatable, Identifiable { var id: Int // question index (0-based) let question: String var answer: String var selectedChips: [String] = [] enum CodingKeys: String, CodingKey { case id, question, answer, selectedChips } init(id: Int, question: String, answer: String, selectedChips: [String] = []) { self.id = id self.question = question self.answer = answer self.selectedChips = selectedChips } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(Int.self, forKey: .id) question = try container.decode(String.self, forKey: .question) answer = try container.decode(String.self, forKey: .answer) selectedChips = try container.decodeIfPresent([String].self, forKey: .selectedChips) ?? [] } } let moodCategory: MoodCategory var responses: [Response] var completedAt: Date? // MARK: - New Fields (optional for back-compat with older saved reflections) /// Emotional intensity rating before the reflection (0-10 scale). var preIntensity: Int? /// Emotional intensity rating after the reflection (0-10 scale). Measures change. var postIntensity: Int? /// Cognitive distortion detected in the automatic-thought response (negative path only). var detectedDistortion: CognitiveDistortion? // MARK: - Codable (tolerant of old JSON without new fields) enum CodingKeys: String, CodingKey { case moodCategory, responses, completedAt, preIntensity, postIntensity, detectedDistortion } init( moodCategory: MoodCategory, responses: [Response], completedAt: Date?, preIntensity: Int? = nil, postIntensity: Int? = nil, detectedDistortion: CognitiveDistortion? = nil ) { self.moodCategory = moodCategory self.responses = responses self.completedAt = completedAt self.preIntensity = preIntensity self.postIntensity = postIntensity self.detectedDistortion = detectedDistortion } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) moodCategory = try container.decode(MoodCategory.self, forKey: .moodCategory) responses = try container.decode([Response].self, forKey: .responses) completedAt = try container.decodeIfPresent(Date.self, forKey: .completedAt) preIntensity = try container.decodeIfPresent(Int.self, forKey: .preIntensity) postIntensity = try container.decodeIfPresent(Int.self, forKey: .postIntensity) detectedDistortion = try container.decodeIfPresent(CognitiveDistortion.self, forKey: .detectedDistortion) } // MARK: - Computed Properties /// A reflection is complete when every required question has a non-empty answer. /// Intensity ratings are optional and do not gate completion. /// /// Back-compat: old negative reflections saved with 4 responses are still considered /// complete — we detect the old shape and treat it as valid rather than forcing a re-prompt. var isComplete: Bool { let expectedCount = moodCategory.questionCount let legacyNegativeCount = 4 // pre-evidence-step shape let nonEmpty = responses.filter { !$0.answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count if responses.count == expectedCount { return nonEmpty == expectedCount } // Legacy negative reflection (pre-evidence-step) — still valid. if moodCategory == .negative && responses.count == legacyNegativeCount { return nonEmpty == legacyNegativeCount } return false } var answeredCount: Int { responses.filter { !$0.answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count } var totalQuestions: Int { moodCategory.questionCount } // MARK: - Factory static func createNew(for mood: Mood) -> GuidedReflection { let category = MoodCategory(from: mood) let templates = questionTemplates(for: category) let responses = templates.enumerated().map { index, template in // Store the raw template text on creation — the view layer will resolve // and overwrite this with the user-visible text before saving. Response(id: index, question: template.text, answer: "") } return GuidedReflection( moodCategory: category, responses: responses, completedAt: nil ) } // MARK: - Question Templates /// Returns the ordered template list for a mood category. Templates may contain /// `%@` placeholders that the view layer fills in with prior answers at render time /// (Socratic back-reference — each question builds on the previous one). static func questionTemplates(for category: MoodCategory) -> [QuestionTemplate] { switch category { case .positive: // Behavioral Activation: situation → savor → plan return [ QuestionTemplate( text: String(localized: "guided_reflection_positive_q1"), placeholderRef: nil ), QuestionTemplate( text: String(localized: "guided_reflection_positive_q2"), placeholderRef: nil ), // Q3 references Q2's "moment that stood out" so the plan is specific. QuestionTemplate( text: String(localized: "guided_reflection_positive_q3_templated"), placeholderRef: 1 ), ] case .neutral: // ACT: awareness → thought → defusion → values return [ QuestionTemplate( text: String(localized: "guided_reflection_neutral_q1"), placeholderRef: nil ), // Q2 references the feeling from Q1. QuestionTemplate( text: String(localized: "guided_reflection_neutral_q2_templated"), placeholderRef: 0 ), // Q3 references the thought from Q2 (the thing to defuse from). QuestionTemplate( text: String(localized: "guided_reflection_neutral_q3_templated"), placeholderRef: 1 ), QuestionTemplate( text: String(localized: "guided_reflection_neutral_q4"), placeholderRef: nil ), ] case .negative: // CBT Thought Record: situation → thought → perspective → evidence → reframe return [ QuestionTemplate( text: String(localized: "guided_reflection_negative_q1"), placeholderRef: nil ), // Q2 references the situation from Q1. QuestionTemplate( text: String(localized: "guided_reflection_negative_q2_templated"), placeholderRef: 0 ), // Q3 is distortion-specific — the view layer picks the right template // based on the detected distortion in Q2. This default is the fallback. QuestionTemplate( text: String(localized: "guided_reflection_negative_q3_templated"), placeholderRef: 1 ), // Q4 is the new evidence-examination step (core of CBT Thought Record). QuestionTemplate( text: String(localized: "guided_reflection_negative_q_evidence_templated"), placeholderRef: 1 ), // Q5 is the balanced reframe, still referencing the original thought. QuestionTemplate( text: String(localized: "guided_reflection_negative_q4_templated"), placeholderRef: 1 ), ] } } /// Legacy accessor — returns templates resolved as static strings (no injection). /// Kept for any callers that want plain text without a response context. static func questions(for category: MoodCategory) -> [String] { questionTemplates(for: category).map { $0.text } } // MARK: - Answer Injection Helper /// Truncates a prior answer for injection into a follow-up question template. /// Prefers breaking at a sentence boundary or word boundary within `maxLength`. static func truncatedForInjection(_ text: String, maxLength: Int = 60) -> String { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard trimmed.count > maxLength else { return trimmed } let prefix = String(trimmed.prefix(maxLength)) // Prefer a sentence boundary within the window. let sentenceEnders: [Character] = [".", "!", "?"] if let lastSentenceEnd = prefix.lastIndex(where: { sentenceEnders.contains($0) }) { let candidate = String(prefix[..= 15 { // Avoid chopping too short. return candidate + "…" } } // Fallback: last word boundary. if let lastSpace = prefix.lastIndex(of: " ") { return String(prefix[.. String? { guard let data = try? JSONEncoder().encode(self) else { return nil } return String(data: data, encoding: .utf8) } static func decode(from json: String) -> GuidedReflection? { guard let data = json.data(using: .utf8) else { return nil } return try? JSONDecoder().decode(GuidedReflection.self, from: data) } }