// // 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 @State private var showFeedback = false @State private var savedReflection: GuidedReflection? 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 { ZStack { ScrollViewReader { proxy in reflectionSheetContent(with: proxy) } .blur(radius: showFeedback ? 6 : 0) .allowsHitTesting(!showFeedback) if showFeedback, let savedReflection { Color.black.opacity(0.3) .ignoresSafeArea() .onTapGesture { } ReflectionFeedbackView( mood: entry.mood, reflection: savedReflection, onDismiss: { dismiss() } ) } } } } 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 // Pre-intensity rating — shown only on the first step, once. // Captures the baseline emotional intensity so we can measure shift. if currentStepIndex == 0 { intensityCard( title: String(localized: "guided_reflection_pre_intensity_title"), value: preIntensityBinding ) } if let step = currentStep { stepCard(step) .id(step.id) } // Post-intensity rating — shown on the final step, below the question. // Measures how much the reflection shifted the feeling. if isLastStep { intensityCard( title: String(localized: "guided_reflection_post_intensity_title"), value: postIntensityBinding ) } } .padding(.horizontal) .padding(.top, 20) .padding(.bottom, contentBottomPadding) } .scrollDismissesKeyboard(.interactively) .onScrollPhaseChange(handleScrollPhaseChange) } // MARK: - Intensity Rating UI private var preIntensityBinding: Binding { Binding( get: { draft.preIntensity ?? 5 }, set: { draft.preIntensity = $0 } ) } private var postIntensityBinding: Binding { Binding( get: { draft.postIntensity ?? 5 }, set: { draft.postIntensity = $0 } ) } @ViewBuilder private func intensityCard(title: String, value: Binding) -> some View { VStack(alignment: .leading, spacing: 12) { Text(title) .font(.subheadline) .fontWeight(.medium) .foregroundColor(textColor) HStack { Text(String(localized: "guided_reflection_intensity_low")) .font(.caption) .foregroundStyle(.secondary) Spacer() Text("\(value.wrappedValue) / 10") .font(.caption) .fontWeight(.semibold) .foregroundStyle(accentColor) Spacer() Text(String(localized: "guided_reflection_intensity_high")) .font(.caption) .foregroundStyle(.secondary) } Slider( value: Binding( get: { Double(value.wrappedValue) }, set: { value.wrappedValue = Int($0.rounded()) } ), in: 0...10, step: 1 ) .tint(accentColor) } .padding(16) .background( RoundedRectangle(cornerRadius: 20) .fill(Color(.secondarySystemBackground)) ) } @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) } // Resolve the template against current answers so Socratic back-references // (e.g., "Looking at '' again...") reflect edits in real time. Text(draft.resolvedQuestion(for: step)) .font(.title3) .fontWeight(.medium) .foregroundColor(textColor) .fixedSize(horizontal: false, vertical: true) .accessibilityIdentifier(AccessibilityID.GuidedReflection.questionLabel(step: currentStepIndex)) editor(for: step) // Specificity probe — gentle nudge if the Q1 (situation) answer is too vague. // CBT works better on concrete events than generalized feelings. if step.id == 0 && needsSpecificityProbe(for: step.answer) { specificityProbe } 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)) ) } // MARK: - Specificity Probe /// Vague phrases that should trigger the specificity nudge even if the text is /// technically long enough. Matched case-insensitively against a trimmed answer. private static let vaguePhrases: Set = [ "idk", "i don't know", "i dont know", "nothing", "everything", "nothing really", "same as always", "same old", "dunno", "no idea" ] private func needsSpecificityProbe(for answer: String) -> Bool { let trimmed = answer.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return false } // don't nag before they've started if trimmed.count < 25 { return true } let lower = trimmed.lowercased() return Self.vaguePhrases.contains(where: { lower == $0 || lower.hasPrefix($0 + " ") }) } private var specificityProbe: some View { HStack(alignment: .top, spacing: 10) { Image(systemName: "lightbulb.fill") .foregroundStyle(accentColor) .font(.footnote) Text(String(localized: "guided_reflection_specificity_probe")) .font(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } .padding(12) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 12) .fill(accentColor.opacity(0.08)) ) } 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 // When leaving Q2 on the negative path, classify the automatic thought and // swap Q3's template to the tailored reframe prompt. Idempotent and safe // to run on every forward navigation. if draft.moodCategory == .negative && currentStepID == 1 { draft.recomputeDistortion() } 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 { // Fire-and-forget tag extraction if #available(iOS 26, *), !IAPManager.shared.shouldShowPaywall { Task { await FoundationModelsTagService.shared.extractAndSaveTags(for: entry) } } // Show AI feedback if reflection is complete and AI is potentially available if reflection.isComplete { savedReflection = reflection withAnimation(.easeInOut(duration: 0.3)) { showFeedback = true } } else { 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 /// The template this step renders from. Contains the raw localized text and /// optional placeholder ref. The user-visible question is computed by calling /// `GuidedReflectionDraft.resolvedQuestion(for:)` — which injects prior answers. var template: QuestionTemplate var 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.template == rhs.template && lhs.label == rhs.label && lhs.answer == rhs.answer && lhs.selectedChips == rhs.selectedChips } } let moodCategory: MoodCategory var steps: [Step] var completedAt: Date? var preIntensity: Int? var postIntensity: Int? var detectedDistortion: CognitiveDistortion? init(reflection: GuidedReflection) { moodCategory = reflection.moodCategory completedAt = reflection.completedAt preIntensity = reflection.preIntensity postIntensity = reflection.postIntensity detectedDistortion = reflection.detectedDistortion let templates = GuidedReflection.questionTemplates(for: reflection.moodCategory) let labels = reflection.moodCategory.stepLabels steps = templates.enumerated().map { index, template in // Preserve existing answers if reflection is being resumed. let existingResponse = reflection.responses.first(where: { $0.id == index }) ?? (reflection.responses.indices.contains(index) ? reflection.responses[index] : nil) return Step( id: index, template: template, label: labels.indices.contains(index) ? labels[index] : nil, chips: QuestionChips.chips(for: reflection.moodCategory, questionIndex: index), answer: existingResponse?.answer ?? "", selectedChips: existingResponse?.selectedChips ?? [] ) } // Re-apply any previously-detected distortion so Q3 restores its tailored template. if let distortion = detectedDistortion, moodCategory == .negative { applyDistortion(distortion) } } /// Produces (index, answer) tuples suitable for `QuestionTemplate.resolved(with:)`. private var answerTuples: [(index: Int, text: String)] { steps.map { ($0.id, $0.answer) } } /// Resolves the user-visible question text for a step, injecting the latest /// value of any referenced prior answer. Called at render time by the view. func resolvedQuestion(for step: Step) -> String { step.template.resolved(with: answerTuples) } func resolvedQuestion(forStepID stepID: Int) -> String { guard let step = step(forStepID: stepID) else { return "" } return resolvedQuestion(for: step) } /// Mutating: detect the cognitive distortion in the current Q2 answer (negative path only) /// and swap Q3's template to the tailored prompt. Safe to call repeatedly — if Q2 is empty /// or detection yields `.unknown` this resets to the fallback template. mutating func recomputeDistortion() { guard moodCategory == .negative, let q2 = steps.first(where: { $0.id == 1 }), !q2.answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { detectedDistortion = nil applyDistortion(.unknown) // reset Q3 label to generic return } let distortion = CognitiveDistortionDetector.detect(in: q2.answer) detectedDistortion = distortion == .unknown ? nil : distortion applyDistortion(distortion) } /// Overwrites Q3's template + label based on the detected distortion. private mutating func applyDistortion(_ distortion: CognitiveDistortion) { guard let q3Index = steps.firstIndex(where: { $0.id == 2 }) else { return } steps[q3Index].template = distortion.perspectiveCheckTemplate if distortion != .unknown { steps[q3Index].label = distortion.stepLabel } else { // Reset to the default "Perspective Check" label from MoodCategory.stepLabels. let defaults = moodCategory.stepLabels steps[q3Index].label = defaults.indices.contains(2) ? defaults[2] : nil } } 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 // Persist the user-visible resolved question text — not the raw template — // so downstream consumers (AI feedback, history view) see what the user saw. GuidedReflection.Response( id: step.id, question: resolvedQuestion(for: step), answer: step.answer, selectedChips: step.selectedChips ) }, completedAt: completedAt, preIntensity: preIntensity, postIntensity: postIntensity, detectedDistortion: detectedDistortion ) } } 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() } } }