Files
Reflect/Shared/Views/GuidedReflectionView.swift
Trey T 1f040ab676 v1.1 polish: accessibility, error logging, localization, and code quality sweep
- Wrap 30+ production print() statements in #if DEBUG guards across 18 files
- Add VoiceOver labels, hints, and traits to Watch app, Live Activities, widgets
- Add .accessibilityAddTraits(.isButton) to 15+ onTapGesture views
- Add text alternatives for color-only indicators (progress dots, mood circles)
- Localize raw string literals in NoteEditorView, EntryDetailView, widgets
- Replace 25+ silent try? with do/catch + AppLogger error logging
- Replace hardcoded font sizes with semantic Dynamic Type fonts
- Fix FIXME in IconPickerView (log icon change errors)
- Extract magic animation delays to named constants across 8 files
- Add widget empty state "Log your first mood!" messaging
- Hide decorative images from VoiceOver, add labels to ColorPickers
- Remove stale TODO in Color+Codable (alpha change deferred for migration)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:09:14 -05:00

726 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)
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(String(localized: "\(draft.steps.filter(\.hasAnswer).count) of \(draft.steps.count) steps completed"))
}
.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()
}
}
}