Merge pull request #152 from akatreyt/discord/guided-reflection-chips

Add mood-specific selectable chips to guided reflection
This commit is contained in:
akatreyt
2026-03-21 22:52:17 -05:00
committed by GitHub
14 changed files with 4321 additions and 1 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -70,6 +70,11 @@ enum AccessibilityID {
static func questionLabel(step: Int) -> String { static func questionLabel(step: Int) -> String {
"guided_reflection_question_\(step)" "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 // MARK: - Settings

View File

@@ -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 // MARK: - Guided Reflection
struct GuidedReflection: Codable, Equatable { struct GuidedReflection: Codable, Equatable {
@@ -74,6 +254,26 @@ struct GuidedReflection: Codable, Equatable {
var id: Int // question index (0-based) var id: Int // question index (0-based)
let question: String let question: String
var answer: 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 let moodCategory: MoodCategory

View File

@@ -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)
}
}

View File

@@ -185,6 +185,15 @@ struct GuidedReflectionView: View {
.accessibilityIdentifier(AccessibilityID.GuidedReflection.questionLabel(step: currentStep)) .accessibilityIdentifier(AccessibilityID.GuidedReflection.questionLabel(step: currentStep))
.id("question_\(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) TextEditor(text: $reflection.responses[currentStep].answer)
.focused($isTextFieldFocused) .focused($isTextFieldFocused)
.frame(minHeight: 120, maxHeight: 200) .frame(minHeight: 120, maxHeight: 200)

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB