Remove Back/Next/Done buttons from keyboard toolbar to eliminate confusion with the bottom action bar. Toolbar now shows only a keyboard.chevron.compact.down dismiss icon. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
139 lines
4.4 KiB
Swift
139 lines
4.4 KiB
Swift
//
|
|
// ChipSelectionView.swift
|
|
// Reflect
|
|
//
|
|
// Reusable chip grid for guided reflection — tapping a chip appends its text.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct ChipSelectionView: View {
|
|
|
|
let chips: QuestionChips
|
|
let accentColor: Color
|
|
@Binding var textAnswer: String
|
|
var onInsert: ((String) -> Void)? = nil
|
|
@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 {
|
|
Button {
|
|
appendChip(label)
|
|
} label: {
|
|
Text(label)
|
|
.font(.subheadline)
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 8)
|
|
.background(
|
|
Capsule()
|
|
.fill(Color(.systemGray6))
|
|
)
|
|
.overlay(
|
|
Capsule()
|
|
.stroke(Color(.systemGray4), lineWidth: 1)
|
|
)
|
|
.foregroundColor(.primary)
|
|
}
|
|
.accessibilityIdentifier(AccessibilityID.GuidedReflection.chip(label: label))
|
|
}
|
|
|
|
// MARK: - Append Chip Text
|
|
|
|
private func appendChip(_ label: String) {
|
|
let trimmed = textAnswer.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if trimmed.isEmpty {
|
|
textAnswer = label
|
|
} else {
|
|
textAnswer = trimmed + "\n" + label
|
|
}
|
|
onInsert?(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)
|
|
}
|
|
}
|