From 8ae8d23f958bce54be12080ea3ef942c15612fe9 Mon Sep 17 00:00:00 2001 From: Trey T Date: Sat, 21 Mar 2026 13:37:55 -0500 Subject: [PATCH] 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) --- Reflect/Localizable.xcstrings | 258 ++++++++++++++++++++++++ Shared/AccessibilityIdentifiers.swift | 5 + Shared/Models/GuidedReflection.swift | 200 ++++++++++++++++++ Shared/Views/ChipSelectionView.swift | 151 ++++++++++++++ Shared/Views/GuidedReflectionView.swift | 9 + 5 files changed, 623 insertions(+) create mode 100644 Shared/Views/ChipSelectionView.swift diff --git a/Reflect/Localizable.xcstrings b/Reflect/Localizable.xcstrings index bd90a7f..714fdde 100644 --- a/Reflect/Localizable.xcstrings +++ b/Reflect/Localizable.xcstrings @@ -10611,6 +10611,264 @@ } } }, + "guided_chip_pos_joy" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Joy" } } } + }, + "guided_chip_pos_gratitude" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Gratitude" } } } + }, + "guided_chip_pos_pride" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Pride" } } } + }, + "guided_chip_pos_contentment" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Contentment" } } } + }, + "guided_chip_pos_love" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Love" } } } + }, + "guided_chip_pos_excitement" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Excitement" } } } + }, + "guided_chip_pos_inspiration" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Inspiration" } } } + }, + "guided_chip_pos_amusement" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Amusement" } } } + }, + "guided_chip_pos_serenity" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Serenity" } } } + }, + "guided_chip_pos_relief" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Relief" } } } + }, + "guided_chip_pos_connection" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Connection" } } } + }, + "guided_chip_pos_hope" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Hope" } } } + }, + "guided_chip_pos_act_more_of_this" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Do more of what worked today" } } } + }, + "guided_chip_pos_act_time_with_people" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Spend time with people I enjoy" } } } + }, + "guided_chip_pos_act_get_outside" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Get outside" } } } + }, + "guided_chip_pos_act_stay_active" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Stay active" } } } + }, + "guided_chip_pos_act_keep_routine" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Keep a routine" } } } + }, + "guided_chip_pos_act_practice_gratitude" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Practice gratitude" } } } + }, + "guided_chip_pos_act_celebrate_wins" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Celebrate small wins" } } } + }, + "guided_chip_pos_act_hobbies" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Make time for hobbies" } } } + }, + "guided_chip_pos_act_help_someone" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Help someone" } } } + }, + "guided_chip_pos_act_say_yes" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Say yes to plans" } } } + }, + "guided_chip_pos_act_rest" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Rest without guilt" } } } + }, + "guided_chip_pos_act_limit_doomscroll" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Limit doomscrolling" } } } + }, + "guided_chip_neu_boredom" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Boredom" } } } + }, + "guided_chip_neu_restlessness" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Restlessness" } } } + }, + "guided_chip_neu_uncertainty" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Uncertainty" } } } + }, + "guided_chip_neu_numbness" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Numbness" } } } + }, + "guided_chip_neu_indifference" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Indifference" } } } + }, + "guided_chip_neu_distraction" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Distraction" } } } + }, + "guided_chip_neu_flatness" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Flatness" } } } + }, + "guided_chip_neu_disconnection" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Disconnection" } } } + }, + "guided_chip_neu_ambivalence" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Ambivalence" } } } + }, + "guided_chip_neu_weariness" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Weariness" } } } + }, + "guided_chip_neu_apathy" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Apathy" } } } + }, + "guided_chip_neu_autopilot" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Autopilot" } } } + }, + "guided_chip_neu_def_just_a_thought" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "It's just a thought, not a fact" } } } + }, + "guided_chip_neu_def_storytelling" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "My mind is storytelling" } } } + }, + "guided_chip_neu_def_thought_passed" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "I've had this thought before and it passed" } } } + }, + "guided_chip_neu_def_more_weight" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "I'm giving this more weight than it deserves" } } } + }, + "guided_chip_neu_def_not_helpful" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "This thought isn't helpful right now" } } } + }, + "guided_chip_neu_def_notice_not_act" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "I can notice this without acting on it" } } } + }, + "guided_chip_neu_def_dont_solve_now" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "I don't need to solve this right now" } } } + }, + "guided_chip_neu_def_doesnt_define" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "It could be true, but it doesn't define me" } } } + }, + "guided_chip_neu_val_be_present" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Be more present" } } } + }, + "guided_chip_neu_val_connect" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Connect with someone" } } } + }, + "guided_chip_neu_val_body" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Take care of my body" } } } + }, + "guided_chip_neu_val_meaningful_work" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Do meaningful work" } } } + }, + "guided_chip_neu_val_kind_to_self" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Be kind to myself" } } } + }, + "guided_chip_neu_val_small_goal" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Set a small goal" } } } + }, + "guided_chip_neu_val_less_autopilot" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Spend less time on autopilot" } } } + }, + "guided_chip_neu_val_one_thing_better" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Make one thing better" } } } + }, + "guided_chip_neu_val_ask_what_i_need" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Ask for what I need" } } } + }, + "guided_chip_neu_val_let_go" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Let go of yesterday" } } } + }, + "guided_chip_neg_not_good_enough" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "I'm not good enough" } } } + }, + "guided_chip_neg_nothing_goes_right" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Nothing ever goes right" } } } + }, + "guided_chip_neg_all_my_fault" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "It's all my fault" } } } + }, + "guided_chip_neg_cant_handle" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "I can't handle this" } } } + }, + "guided_chip_neg_no_one_understands" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "No one understands" } } } + }, + "guided_chip_neg_should_have" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "I should have done better" } } } + }, + "guided_chip_neg_never_change" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Things will never change" } } } + }, + "guided_chip_neg_falling_behind" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "I'm falling behind" } } } + }, + "guided_chip_neg_dont_matter" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "I don't matter" } } } + }, + "guided_chip_neg_something_wrong" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Something is wrong with me" } } } + }, + "guided_chip_neg_letting_down" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "I'm letting people down" } } } + }, + "guided_chip_neg_cant_do_right" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "I can't do anything right" } } } + }, + "guided_chip_neg_too_hard" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "You're being too hard on yourself" } } } + }, + "guided_chip_neg_one_bad_day" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "One bad day doesn't define you" } } } + }, + "guided_chip_neg_better_than_think" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "You're doing better than you think" } } } + }, + "guided_chip_neg_ok_to_struggle" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "It's okay to struggle sometimes" } } } + }, + "guided_chip_neg_feeling_will_pass" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "This feeling will pass" } } } + }, + "guided_chip_neg_dont_need_figured" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "You don't have to have it all figured out" } } } + }, + "guided_chip_neg_not_whole_story" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "What happened isn't your whole story" } } } + }, + "guided_chip_neg_give_grace" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Give yourself the grace you'd give others" } } } + }, + "guided_chip_neg_act_talk_someone" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Talk to someone I trust" } } } + }, + "guided_chip_neg_act_write_it_out" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Write it out" } } } + }, + "guided_chip_neg_act_take_walk" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Take a walk" } } } + }, + "guided_chip_neg_act_step_away" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Step away from the situation" } } } + }, + "guided_chip_neg_act_get_rest" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Get some rest" } } } + }, + "guided_chip_neg_act_one_small_thing" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Do one small thing I can control" } } } + }, + "guided_chip_neg_act_worst_case" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Challenge the worst-case scenario" } } } + }, + "guided_chip_neg_act_got_through" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Remember a time I got through something hard" } } } + }, + "guided_chip_neg_act_facts_feelings" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Separate facts from feelings" } } } + }, + "guided_chip_neg_act_matter_in_week" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Will this matter in a week?" } } } + }, + "guided_chip_show_more" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "More" } } } + }, + "guided_chip_show_less" : { + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Less" } } } + }, "H: %@" : { "comment" : "A label displaying the high temperature. The argument is the high temperature as a string.", "isCommentAutoGenerated" : true, diff --git a/Shared/AccessibilityIdentifiers.swift b/Shared/AccessibilityIdentifiers.swift index b147351..7713ee3 100644 --- a/Shared/AccessibilityIdentifiers.swift +++ b/Shared/AccessibilityIdentifiers.swift @@ -70,6 +70,11 @@ enum AccessibilityID { static func questionLabel(step: Int) -> String { "guided_reflection_question_\(step)" } + static let chipGrid = "guided_reflection_chip_grid" + static let chipMoreButton = "guided_reflection_chip_more" + static func chip(label: String) -> String { + "guided_reflection_chip_\(label)" + } } // MARK: - Settings diff --git a/Shared/Models/GuidedReflection.swift b/Shared/Models/GuidedReflection.swift index f21452d..7d1c077 100644 --- a/Shared/Models/GuidedReflection.swift +++ b/Shared/Models/GuidedReflection.swift @@ -66,6 +66,186 @@ enum MoodCategory: String, Codable { } } +// 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?" — positive feelings to savor + case (.positive, 1): + return QuestionChips( + topRow: [ + String(localized: "guided_chip_pos_joy"), + String(localized: "guided_chip_pos_gratitude"), + String(localized: "guided_chip_pos_pride"), + String(localized: "guided_chip_pos_contentment"), + String(localized: "guided_chip_pos_love"), + String(localized: "guided_chip_pos_excitement"), + ], + expanded: [ + String(localized: "guided_chip_pos_inspiration"), + String(localized: "guided_chip_pos_amusement"), + String(localized: "guided_chip_pos_serenity"), + String(localized: "guided_chip_pos_relief"), + String(localized: "guided_chip_pos_connection"), + String(localized: "guided_chip_pos_hope"), + ] + ) + + // 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: [] + ) + + // Q4: "More balanced way to see it?" — grounding actions + cognitive shifts + case (.negative, 3): + return QuestionChips( + topRow: [ + 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"), + ], + expanded: [ + String(localized: "guided_chip_neg_act_worst_case"), + String(localized: "guided_chip_neg_act_got_through"), + String(localized: "guided_chip_neg_act_facts_feelings"), + String(localized: "guided_chip_neg_act_matter_in_week"), + ] + ) + + default: + return nil + } + } +} + // MARK: - Guided Reflection struct GuidedReflection: Codable, Equatable { @@ -74,6 +254,26 @@ struct GuidedReflection: Codable, Equatable { 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 diff --git a/Shared/Views/ChipSelectionView.swift b/Shared/Views/ChipSelectionView.swift new file mode 100644 index 0000000..50478c6 --- /dev/null +++ b/Shared/Views/ChipSelectionView.swift @@ -0,0 +1,151 @@ +// +// ChipSelectionView.swift +// Reflect +// +// Reusable chip selection grid for guided reflection quick-pick answers. +// + +import SwiftUI + +struct ChipSelectionView: View { + + let chips: QuestionChips + let accentColor: Color + @Binding var selectedChips: [String] + @Binding var textAnswer: String + @State private var showMore = false + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + chipFlow(chips: chips.topRow) + + if chips.hasExpanded { + if showMore { + chipFlow(chips: chips.expanded) + .transition(.opacity.combined(with: .move(edge: .top))) + } + + Button { + withAnimation(.easeInOut(duration: 0.25)) { + showMore.toggle() + } + } label: { + HStack(spacing: 4) { + Text(showMore ? String(localized: "guided_chip_show_less") : String(localized: "guided_chip_show_more")) + .font(.caption) + .fontWeight(.medium) + Image(systemName: showMore ? "chevron.up" : "chevron.down") + .font(.caption2) + } + .foregroundStyle(accentColor) + } + .accessibilityIdentifier(AccessibilityID.GuidedReflection.chipMoreButton) + } + } + .accessibilityIdentifier(AccessibilityID.GuidedReflection.chipGrid) + } + + // MARK: - Chip Flow Layout + + private func chipFlow(chips: [String]) -> some View { + FlowLayout(spacing: 8) { + ForEach(chips, id: \.self) { chip in + chipButton(chip) + } + } + } + + private func chipButton(_ label: String) -> some View { + let isSelected = selectedChips.contains(label) + return Button { + toggleChip(label) + } label: { + Text(label) + .font(.subheadline) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background( + Capsule() + .fill(isSelected ? accentColor.opacity(0.2) : Color(.systemGray6)) + ) + .overlay( + Capsule() + .stroke(isSelected ? accentColor : Color(.systemGray4), lineWidth: 1) + ) + .foregroundColor(isSelected ? accentColor : .primary) + } + .accessibilityIdentifier(AccessibilityID.GuidedReflection.chip(label: label)) + } + + // MARK: - Chip Toggle + + private func toggleChip(_ label: String) { + if let index = selectedChips.firstIndex(of: label) { + selectedChips.remove(at: index) + // Remove from text answer + let separator = ", " + var parts = textAnswer + .components(separatedBy: separator) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + parts.removeAll { $0 == label } + textAnswer = parts.joined(separator: separator) + } else { + selectedChips.append(label) + // Append to text answer + if textAnswer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + textAnswer = label + } else { + textAnswer += ", \(label)" + } + } + } +} + +// MARK: - Flow Layout + +/// A simple wrapping horizontal layout for chips. +struct FlowLayout: Layout { + var spacing: CGFloat = 8 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let result = arrange(proposal: proposal, subviews: subviews) + return result.size + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let result = arrange(proposal: proposal, subviews: subviews) + for (index, subview) in subviews.enumerated() { + let point = result.origins[index] + subview.place(at: CGPoint(x: bounds.minX + point.x, y: bounds.minY + point.y), proposal: .unspecified) + } + } + + private struct ArrangeResult { + var size: CGSize + var origins: [CGPoint] + } + + private func arrange(proposal: ProposedViewSize, subviews: Subviews) -> ArrangeResult { + let maxWidth = proposal.width ?? .infinity + var origins: [CGPoint] = [] + var x: CGFloat = 0 + var y: CGFloat = 0 + var rowHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if x + size.width > maxWidth && x > 0 { + x = 0 + y += rowHeight + spacing + rowHeight = 0 + } + origins.append(CGPoint(x: x, y: y)) + rowHeight = max(rowHeight, size.height) + x += size.width + spacing + } + + let totalHeight = y + rowHeight + return ArrangeResult(size: CGSize(width: maxWidth, height: totalHeight), origins: origins) + } +} diff --git a/Shared/Views/GuidedReflectionView.swift b/Shared/Views/GuidedReflectionView.swift index 5ad89ba..4b47f69 100644 --- a/Shared/Views/GuidedReflectionView.swift +++ b/Shared/Views/GuidedReflectionView.swift @@ -185,6 +185,15 @@ struct GuidedReflectionView: View { .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)