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