Files
Reflect/Shared/Views/GuidedReflectionView.swift
Trey T e7648ddd8a Add missing accessibility identifiers to all interactive UI elements
Audit found ~50+ interactive elements (buttons, toggles, pickers, alerts,
links) missing accessibility identifiers across 13 view files. Added
centralized ID definitions and applied them to every entry detail button,
guided reflection control, settings toggle, paywall unlock button,
subscription/IAP button, lock screen control, and photo action dialog.
2026-03-26 07:59:52 -05:00

724 lines
24 KiB
Swift

//
// GuidedReflectionView.swift
// Reflect
//
// Data-driven guided reflection flow with stable step state.
//
import SwiftUI
import UIKit
struct GuidedReflectionView: View {
@Environment(\.dismiss) private var dismiss
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
let entry: MoodEntryModel
@State private var draft: GuidedReflectionDraft
@State private var currentStepID: Int
@State private var focusedStepID: Int?
@State private var keyboardHeight: CGFloat = 0
@State private var isSaving = false
@State private var showDiscardAlert = false
@State private var showInfoSheet = false
private let initialDraft: GuidedReflectionDraft
private static let maxCharacters = 500
private static let minimumEditorHeight: CGFloat = 160
private var textColor: Color { theme.currentTheme.labelColor }
private var accentColor: Color { moodTint.color(forMood: entry.mood) }
private var currentStepIndex: Int {
draft.index(forStepID: currentStepID) ?? 0
}
private var currentStep: GuidedReflectionDraft.Step? {
draft.step(forStepID: currentStepID)
}
private var totalSteps: Int {
draft.steps.count
}
private var hasUnsavedChanges: Bool {
draft != initialDraft
}
private var currentStepHasText: Bool {
currentStep?.hasAnswer ?? false
}
private var canGoBack: Bool {
draft.stepID(before: currentStepID) != nil
}
private var isLastStep: Bool {
draft.stepID(after: currentStepID) == nil
}
init(entry: MoodEntryModel) {
self.entry = entry
let existing = entry.reflectionJSON.flatMap { GuidedReflection.decode(from: $0) }
?? GuidedReflection.createNew(for: entry.mood)
let draft = GuidedReflectionDraft(reflection: existing)
let startingStepID = draft.firstUnansweredStepID ?? draft.steps.first?.id ?? 0
self._draft = State(initialValue: draft)
self._currentStepID = State(initialValue: startingStepID)
self._focusedStepID = State(initialValue: nil)
self.initialDraft = draft
}
var body: some View {
NavigationStack {
ScrollViewReader { proxy in
reflectionSheetContent(with: proxy)
}
}
}
private func reflectionSheetContent(with proxy: ScrollViewProxy) -> some View {
sheetLayout
.safeAreaInset(edge: .bottom, spacing: 0) {
if keyboardHeight == 0 {
actionBar
}
}
.navigationTitle(String(localized: "guided_reflection_title"))
.navigationBarTitleDisplayMode(.inline)
.accessibilityIdentifier(AccessibilityID.GuidedReflection.sheet)
.toolbar { navigationToolbar }
.alert(
String(localized: "guided_reflection_unsaved_title"),
isPresented: $showDiscardAlert
) {
Button(String(localized: "guided_reflection_discard"), role: .destructive) {
dismiss()
}
.accessibilityIdentifier(AccessibilityID.GuidedReflection.discardButton)
Button(String(localized: "Cancel"), role: .cancel) { }
.accessibilityIdentifier(AccessibilityID.GuidedReflection.keepEditingButton)
} message: {
Text(String(localized: "guided_reflection_unsaved_message"))
}
.trackScreen(.guidedReflection)
.sheet(isPresented: $showInfoSheet) {
GuidedReflectionInfoView()
}
.onChange(of: currentStepID) { _, newStepID in
DispatchQueue.main.async {
scrollTo(stepID: newStepID, with: proxy)
}
}
.onChange(of: focusedStepID) { _, newFocusedStepID in
guard let newFocusedStepID else { return }
DispatchQueue.main.async {
scrollFocusedEditor(stepID: newFocusedStepID, with: proxy)
}
}
.onChange(of: keyboardHeight) { _, newKeyboardHeight in
guard newKeyboardHeight > 0, let focusedStepID else { return }
DispatchQueue.main.async {
scrollFocusedEditor(stepID: focusedStepID, with: proxy)
}
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification)) { notification in
keyboardHeight = keyboardOverlap(from: notification)
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in
keyboardHeight = 0
}
}
private var sheetLayout: some View {
VStack(spacing: 0) {
entryHeader
.padding()
.background(Color(.systemGray6))
Divider()
reflectionScrollView
}
}
private var reflectionScrollView: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
progressSection
if let step = currentStep {
stepCard(step)
.id(step.id)
}
}
.padding(.horizontal)
.padding(.top, 20)
.padding(.bottom, contentBottomPadding)
}
.scrollDismissesKeyboard(.interactively)
.onScrollPhaseChange(handleScrollPhaseChange)
}
@ToolbarContentBuilder
private var navigationToolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "Cancel")) {
handleCancel()
}
.accessibilityIdentifier(AccessibilityID.GuidedReflection.cancelButton)
}
ToolbarItem(placement: .primaryAction) {
Button {
showInfoSheet = true
} label: {
Image(systemName: "info.circle")
}
.accessibilityLabel(String(localized: "guided_reflection_about_title"))
.accessibilityIdentifier(AccessibilityID.GuidedReflection.infoButton)
}
}
private var entryHeader: some View {
HStack(spacing: 12) {
Circle()
.fill(accentColor.opacity(0.2))
.frame(width: 50, height: 50)
.overlay(
imagePack.icon(forMood: entry.mood)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 28, height: 28)
.foregroundColor(accentColor)
)
VStack(alignment: .leading, spacing: 4) {
Text(entry.forDate, format: .dateTime.weekday(.wide).month().day().year())
.font(.headline)
.foregroundColor(textColor)
Text(entry.moodString)
.font(.subheadline)
.foregroundColor(accentColor)
}
Spacer()
}
}
private var progressSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .firstTextBaseline) {
Text("\(currentStepIndex + 1) / \(max(totalSteps, 1))")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
Spacer()
Text(draft.moodCategory.techniqueName)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.trailing)
}
HStack(spacing: 8) {
ForEach(draft.steps) { step in
Capsule()
.fill(progressColor(for: step))
.frame(maxWidth: .infinity)
.frame(height: 10)
}
}
}
.accessibilityIdentifier(AccessibilityID.GuidedReflection.progressDots)
}
private func stepCard(_ step: GuidedReflectionDraft.Step) -> some View {
VStack(alignment: .leading, spacing: 18) {
if let label = step.label {
Text(label.uppercased())
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
.tracking(1.5)
}
Text(step.question)
.font(.title3)
.fontWeight(.medium)
.foregroundColor(textColor)
.fixedSize(horizontal: false, vertical: true)
.accessibilityIdentifier(AccessibilityID.GuidedReflection.questionLabel(step: currentStepIndex))
editor(for: step)
if let chips = step.chips {
ChipSelectionView(
chips: chips,
accentColor: accentColor,
textAnswer: answerBinding(for: step.id),
onInsert: { chip in
handleChipInsertion(chip, into: step.id)
}
)
}
}
.padding(20)
.background(
RoundedRectangle(cornerRadius: 24)
.fill(Color(.secondarySystemBackground))
)
}
private func editor(for step: GuidedReflectionDraft.Step) -> some View {
VStack(alignment: .leading, spacing: 10) {
AutoSizingReflectionTextEditor(
text: answerBinding(for: step.id),
isFocused: focusBinding(for: step.id),
maxCharacters: Self.maxCharacters,
onDone: { focusedStepID = nil }
)
.id(step.id)
.frame(height: Self.minimumEditorHeight, alignment: .top)
.padding(12)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color(.systemBackground))
)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(Color(.systemGray4), lineWidth: 1)
)
.accessibilityIdentifier(AccessibilityID.GuidedReflection.textEditor)
HStack {
Spacer()
Text("\(step.characterCount)/\(Self.maxCharacters)")
.font(.caption)
.foregroundStyle(step.characterCount >= Self.maxCharacters ? .red : .secondary)
}
Color.clear
.frame(height: 1)
.id(editorAnchorID(for: step.id))
}
}
private var actionBar: some View {
VStack(spacing: 0) {
Divider()
HStack(spacing: 12) {
if canGoBack {
Button(String(localized: "guided_reflection_back")) {
navigateBack()
}
.buttonStyle(.bordered)
.accessibilityIdentifier(AccessibilityID.GuidedReflection.backButton)
}
Spacer(minLength: 0)
Button(isLastStep ? String(localized: "guided_reflection_save") : String(localized: "guided_reflection_next")) {
handlePrimaryAction()
}
.buttonStyle(.borderedProminent)
.tint(isLastStep ? .green : accentColor)
.disabled(primaryActionDisabled)
.accessibilityIdentifier(isLastStep ? AccessibilityID.GuidedReflection.saveButton : AccessibilityID.GuidedReflection.nextButton)
}
.padding(.horizontal)
.padding(.top, 12)
.padding(.bottom, 12)
}
.background(.regularMaterial)
}
private var primaryActionDisabled: Bool {
if isLastStep {
return !draft.isComplete || isSaving
}
return !currentStepHasText
}
private var contentBottomPadding: CGFloat {
28 + (keyboardHeight > 0 ? keyboardHeight + 48 : 0)
}
private func answerBinding(for stepID: Int) -> Binding<String> {
Binding(
get: {
draft.answer(forStepID: stepID) ?? ""
},
set: { newValue in
let truncated = String(newValue.prefix(Self.maxCharacters))
draft.updateAnswer(truncated, forStepID: stepID)
}
)
}
private func focusBinding(for stepID: Int) -> Binding<Bool> {
Binding(
get: {
focusedStepID == stepID
},
set: { isFocused in
if isFocused {
focusedStepID = stepID
} else if focusedStepID == stepID {
focusedStepID = nil
}
}
)
}
private func progressColor(for step: GuidedReflectionDraft.Step) -> Color {
if step.id == currentStepID {
return accentColor
}
if step.hasAnswer {
return accentColor.opacity(0.35)
}
return Color(.systemGray5)
}
private func handleCancel() {
focusedStepID = nil
if hasUnsavedChanges {
showDiscardAlert = true
} else {
dismiss()
}
}
private func navigateForward() {
guard let nextStepID = draft.stepID(after: currentStepID) else { return }
focusedStepID = nil
updateCurrentStep(to: nextStepID)
}
private func navigateBack() {
guard let previousStepID = draft.stepID(before: currentStepID) else { return }
focusedStepID = nil
updateCurrentStep(to: previousStepID)
}
private func updateCurrentStep(to stepID: Int) {
if UIAccessibility.isReduceMotionEnabled {
currentStepID = stepID
} else {
withAnimation(.easeInOut(duration: 0.25)) {
currentStepID = stepID
}
}
}
private func handleChipInsertion(_ chip: String, into stepID: Int) {
focusedStepID = nil
draft.registerChip(chip, forStepID: stepID)
}
private func handleScrollPhaseChange(oldPhase: ScrollPhase, newPhase: ScrollPhase, context: ScrollPhaseChangeContext) {
guard newPhase == .tracking || newPhase == .interacting else { return }
focusedStepID = nil
}
private func handlePrimaryAction() {
if isLastStep {
saveReflection()
} else {
navigateForward()
}
}
private func saveReflection() {
guard !isSaving else { return }
isSaving = true
focusedStepID = nil
var reflection = draft.makeReflection()
if reflection.isComplete {
reflection.completedAt = Date()
}
let success = DataController.shared.updateReflection(
forDate: entry.forDate,
reflectionJSON: reflection.encode()
)
if success {
dismiss()
} else {
isSaving = false
}
}
private func scrollTo(stepID: Int, with proxy: ScrollViewProxy) {
if UIAccessibility.isReduceMotionEnabled {
proxy.scrollTo(stepID, anchor: .top)
} else {
withAnimation(.easeInOut(duration: 0.25)) {
proxy.scrollTo(stepID, anchor: .top)
}
}
}
private func scrollFocusedEditor(stepID: Int, with proxy: ScrollViewProxy) {
let anchorID = editorAnchorID(for: stepID)
if UIAccessibility.isReduceMotionEnabled {
proxy.scrollTo(anchorID, anchor: .bottom)
} else {
withAnimation(.easeInOut(duration: 0.25)) {
proxy.scrollTo(anchorID, anchor: .bottom)
}
}
}
private func editorAnchorID(for stepID: Int) -> String {
"guided_reflection_editor_anchor_\(stepID)"
}
private func keyboardOverlap(from notification: Notification) -> CGFloat {
guard
let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect
else {
return 0
}
let screenHeight = UIScreen.main.bounds.height
return max(0, screenHeight - frame.minY)
}
}
private struct GuidedReflectionDraft: Equatable {
struct Step: Identifiable, Equatable {
let id: Int
let question: String
let label: String?
let chips: QuestionChips?
var answer: String
var selectedChips: [String]
var hasAnswer: Bool {
!answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var characterCount: Int {
answer.count
}
static func == (lhs: Step, rhs: Step) -> Bool {
lhs.id == rhs.id &&
lhs.question == rhs.question &&
lhs.label == rhs.label &&
lhs.answer == rhs.answer &&
lhs.selectedChips == rhs.selectedChips
}
}
let moodCategory: MoodCategory
var steps: [Step]
var completedAt: Date?
init(reflection: GuidedReflection) {
moodCategory = reflection.moodCategory
completedAt = reflection.completedAt
let questions = GuidedReflection.questions(for: reflection.moodCategory)
let labels = reflection.moodCategory.stepLabels
steps = questions.enumerated().map { index, question in
let existingResponse = reflection.responses.first(where: { $0.id == index })
?? (reflection.responses.indices.contains(index) ? reflection.responses[index] : nil)
return Step(
id: index,
question: question,
label: labels.indices.contains(index) ? labels[index] : nil,
chips: QuestionChips.chips(for: reflection.moodCategory, questionIndex: index),
answer: existingResponse?.answer ?? "",
selectedChips: existingResponse?.selectedChips ?? []
)
}
}
var firstUnansweredStepID: Int? {
steps.first(where: { !$0.hasAnswer })?.id
}
var isComplete: Bool {
steps.count == moodCategory.questionCount && steps.allSatisfy(\.hasAnswer)
}
func step(forStepID stepID: Int) -> Step? {
steps.first(where: { $0.id == stepID })
}
func index(forStepID stepID: Int) -> Int? {
steps.firstIndex(where: { $0.id == stepID })
}
func answer(forStepID stepID: Int) -> String? {
step(forStepID: stepID)?.answer
}
func stepID(before stepID: Int) -> Int? {
guard let index = index(forStepID: stepID), index > 0 else { return nil }
return steps[index - 1].id
}
func stepID(after stepID: Int) -> Int? {
guard let index = index(forStepID: stepID), index < steps.count - 1 else { return nil }
return steps[index + 1].id
}
mutating func updateAnswer(_ answer: String, forStepID stepID: Int) {
guard let index = index(forStepID: stepID) else { return }
steps[index].answer = answer
}
mutating func registerChip(_ chip: String, forStepID stepID: Int) {
guard let index = index(forStepID: stepID) else { return }
if !steps[index].selectedChips.contains(chip) {
steps[index].selectedChips.append(chip)
}
}
func makeReflection() -> GuidedReflection {
GuidedReflection(
moodCategory: moodCategory,
responses: steps.map { step in
GuidedReflection.Response(
id: step.id,
question: step.question,
answer: step.answer,
selectedChips: step.selectedChips
)
},
completedAt: completedAt
)
}
}
private struct AutoSizingReflectionTextEditor: UIViewRepresentable {
@Binding var text: String
@Binding var isFocused: Bool
let maxCharacters: Int
let onDone: () -> Void
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.delegate = context.coordinator
textView.isScrollEnabled = true
textView.alwaysBounceVertical = true
textView.font = .preferredFont(forTextStyle: .body)
textView.backgroundColor = .clear
textView.textColor = .label
textView.textContainerInset = .zero
textView.textContainer.lineFragmentPadding = 0
textView.keyboardDismissMode = .interactive
context.coordinator.configureAccessoryView(for: textView)
return textView
}
func updateUIView(_ textView: UITextView, context: Context) {
context.coordinator.parent = self
if textView.text != text {
textView.text = text
DispatchQueue.main.async {
scrollCaretToVisible(in: textView)
}
}
if isFocused && !textView.isFirstResponder {
DispatchQueue.main.async {
textView.becomeFirstResponder()
scrollCaretToVisible(in: textView)
}
} else if !isFocused && textView.isFirstResponder {
DispatchQueue.main.async {
textView.resignFirstResponder()
}
}
}
private func scrollCaretToVisible(in textView: UITextView) {
guard let selectedTextRange = textView.selectedTextRange else { return }
textView.layoutIfNeeded()
let caretRect = textView.caretRect(for: selectedTextRange.end)
let paddedRect = caretRect.insetBy(dx: 0, dy: -16)
textView.scrollRectToVisible(paddedRect, animated: false)
}
final class Coordinator: NSObject, UITextViewDelegate {
var parent: AutoSizingReflectionTextEditor
weak var textView: UITextView?
private let accessoryToolbar = UIToolbar()
private let spacerItem = UIBarButtonItem(systemItem: .flexibleSpace)
private lazy var dismissButtonItem = UIBarButtonItem(
image: UIImage(systemName: "keyboard.chevron.compact.down"),
style: .plain,
target: self,
action: #selector(handleDoneTapped)
)
init(parent: AutoSizingReflectionTextEditor) {
self.parent = parent
}
func configureAccessoryView(for textView: UITextView) {
self.textView = textView
accessoryToolbar.sizeToFit()
accessoryToolbar.setItems([spacerItem, dismissButtonItem], animated: false)
textView.inputAccessoryView = accessoryToolbar
}
func textViewDidBeginEditing(_ textView: UITextView) {
if !parent.isFocused {
parent.isFocused = true
}
parent.scrollCaretToVisible(in: textView)
}
func textViewDidEndEditing(_ textView: UITextView) {
if parent.isFocused {
parent.isFocused = false
}
}
func textViewDidChange(_ textView: UITextView) {
let truncated = String(textView.text.prefix(parent.maxCharacters))
if textView.text != truncated {
textView.text = truncated
}
if parent.text != truncated {
parent.text = truncated
}
parent.scrollCaretToVisible(in: textView)
}
func textViewDidChangeSelection(_ textView: UITextView) {
guard textView.isFirstResponder else { return }
parent.scrollCaretToVisible(in: textView)
}
@objc private func handleDoneTapped() {
parent.onDone()
}
}
}