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>
This commit is contained in:
151
Shared/Views/ChipSelectionView.swift
Normal file
151
Shared/Views/ChipSelectionView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user