Files
Reflect/Shared/Views/ChipSelectionView.swift
Trey T 8ae8d23f95 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) <noreply@anthropic.com>
2026-03-21 13:37:55 -05:00

152 lines
5.0 KiB
Swift

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