Simplify guided reflection keyboard toolbar to dismiss-only icon
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>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
// ChipSelectionView.swift
|
||||
// Reflect
|
||||
//
|
||||
// Reusable chip selection grid for guided reflection quick-pick answers.
|
||||
// Reusable chip grid for guided reflection — tapping a chip appends its text.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
@@ -11,8 +11,8 @@ struct ChipSelectionView: View {
|
||||
|
||||
let chips: QuestionChips
|
||||
let accentColor: Color
|
||||
@Binding var selectedChips: [String]
|
||||
@Binding var textAnswer: String
|
||||
var onInsert: ((String) -> Void)? = nil
|
||||
@State private var showMore = false
|
||||
|
||||
var body: some View {
|
||||
@@ -56,9 +56,8 @@ struct ChipSelectionView: View {
|
||||
}
|
||||
|
||||
private func chipButton(_ label: String) -> some View {
|
||||
let isSelected = selectedChips.contains(label)
|
||||
return Button {
|
||||
toggleChip(label)
|
||||
Button {
|
||||
appendChip(label)
|
||||
} label: {
|
||||
Text(label)
|
||||
.font(.subheadline)
|
||||
@@ -66,39 +65,27 @@ struct ChipSelectionView: View {
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(isSelected ? accentColor.opacity(0.2) : Color(.systemGray6))
|
||||
.fill(Color(.systemGray6))
|
||||
)
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(isSelected ? accentColor : Color(.systemGray4), lineWidth: 1)
|
||||
.stroke(Color(.systemGray4), lineWidth: 1)
|
||||
)
|
||||
.foregroundColor(isSelected ? accentColor : .primary)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityID.GuidedReflection.chip(label: label))
|
||||
}
|
||||
|
||||
// MARK: - Chip Toggle
|
||||
// MARK: - Append Chip Text
|
||||
|
||||
private func toggleChip(_ label: String) {
|
||||
if let index = selectedChips.firstIndex(of: label) {
|
||||
selectedChips.remove(at: index)
|
||||
// Remove from text answer
|
||||
let separator = ", "
|
||||
var parts = textAnswer
|
||||
.components(separatedBy: separator)
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.filter { !$0.isEmpty }
|
||||
parts.removeAll { $0 == label }
|
||||
textAnswer = parts.joined(separator: separator)
|
||||
private func appendChip(_ label: String) {
|
||||
let trimmed = textAnswer.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty {
|
||||
textAnswer = label
|
||||
} else {
|
||||
selectedChips.append(label)
|
||||
// Append to text answer
|
||||
if textAnswer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
textAnswer = label
|
||||
} else {
|
||||
textAnswer += ", \(label)"
|
||||
}
|
||||
textAnswer = trimmed + "\n" + label
|
||||
}
|
||||
onInsert?(label)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
// GuidedReflectionView.swift
|
||||
// Reflect
|
||||
//
|
||||
// Card-step guided reflection sheet — one question at a time.
|
||||
// Data-driven guided reflection flow with stable step state.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct GuidedReflectionView: View {
|
||||
|
||||
@@ -16,90 +17,83 @@ struct GuidedReflectionView: View {
|
||||
|
||||
let entry: MoodEntryModel
|
||||
|
||||
@State private var reflection: GuidedReflection
|
||||
@State private var currentStep: Int = 0
|
||||
@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
|
||||
@FocusState private var isTextFieldFocused: Bool
|
||||
|
||||
/// Snapshot of the initial state to detect unsaved changes
|
||||
private let initialReflection: GuidedReflection
|
||||
private let initialDraft: GuidedReflectionDraft
|
||||
|
||||
private let maxCharacters = 500
|
||||
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 totalSteps: Int { reflection.totalQuestions }
|
||||
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 {
|
||||
reflection != initialReflection
|
||||
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)
|
||||
self._reflection = State(initialValue: existing)
|
||||
self.initialReflection = existing
|
||||
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 {
|
||||
VStack(spacing: 0) {
|
||||
// Entry header
|
||||
entryHeader
|
||||
.padding()
|
||||
.background(Color(.systemGray6))
|
||||
ScrollViewReader { proxy in
|
||||
reflectionSheetContent(with: proxy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Progress dots
|
||||
progressDots
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
// Question + answer area
|
||||
questionContent
|
||||
.padding(.horizontal)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Navigation buttons
|
||||
navigationButtons
|
||||
.padding()
|
||||
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 {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(String(localized: "Cancel")) {
|
||||
if hasUnsavedChanges {
|
||||
showDiscardAlert = true
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityID.GuidedReflection.cancelButton)
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
showInfoSheet = true
|
||||
} label: {
|
||||
Image(systemName: "info.circle")
|
||||
}
|
||||
.accessibilityLabel(String(localized: "guided_reflection_about_title"))
|
||||
}
|
||||
|
||||
ToolbarItemGroup(placement: .keyboard) {
|
||||
Spacer()
|
||||
Button("Done") {
|
||||
isTextFieldFocused = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbar { navigationToolbar }
|
||||
.alert(
|
||||
String(localized: "guided_reflection_unsaved_title"),
|
||||
isPresented: $showDiscardAlert
|
||||
@@ -115,22 +109,91 @@ struct GuidedReflectionView: View {
|
||||
.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
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Entry Header
|
||||
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"))
|
||||
}
|
||||
}
|
||||
|
||||
private var entryHeader: some View {
|
||||
HStack(spacing: 12) {
|
||||
Circle()
|
||||
.fill(moodTint.color(forMood: entry.mood).opacity(0.2))
|
||||
.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(moodTint.color(forMood: entry.mood))
|
||||
.foregroundColor(accentColor)
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
@@ -140,185 +203,252 @@ struct GuidedReflectionView: View {
|
||||
|
||||
Text(entry.moodString)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(moodTint.color(forMood: entry.mood))
|
||||
.foregroundColor(accentColor)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Progress Dots
|
||||
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)
|
||||
|
||||
private var progressDots: some View {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(0..<totalSteps, id: \.self) { index in
|
||||
Circle()
|
||||
.fill(index == currentStep ? moodTint.color(forMood: entry.mood) : Color(.systemGray4))
|
||||
.frame(width: 10, height: 10)
|
||||
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)
|
||||
}
|
||||
|
||||
// MARK: - Question Content
|
||||
|
||||
private var questionContent: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if currentStep < reflection.responses.count {
|
||||
let response = reflection.responses[currentStep]
|
||||
let stepLabels = reflection.moodCategory.stepLabels
|
||||
|
||||
// CBT step label
|
||||
if currentStep < stepLabels.count {
|
||||
Text(stepLabels[currentStep].uppercased())
|
||||
.font(.caption)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.secondary)
|
||||
.tracking(1.5)
|
||||
}
|
||||
|
||||
Text(response.question)
|
||||
.font(.title3)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(textColor)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.accessibilityIdentifier(AccessibilityID.GuidedReflection.questionLabel(step: currentStep))
|
||||
.id("question_\(currentStep)")
|
||||
|
||||
if let chips = QuestionChips.chips(for: reflection.moodCategory, questionIndex: currentStep) {
|
||||
ChipSelectionView(
|
||||
chips: chips,
|
||||
accentColor: moodTint.color(forMood: entry.mood),
|
||||
selectedChips: $reflection.responses[currentStep].selectedChips,
|
||||
textAnswer: $reflection.responses[currentStep].answer
|
||||
)
|
||||
}
|
||||
|
||||
TextEditor(text: $reflection.responses[currentStep].answer)
|
||||
.focused($isTextFieldFocused)
|
||||
.frame(minHeight: 120, maxHeight: 200)
|
||||
.scrollContentBackground(.hidden)
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(.systemBackground))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color(.systemGray4), lineWidth: 1)
|
||||
)
|
||||
.onChange(of: reflection.responses[currentStep].answer) { _, newValue in
|
||||
if newValue.count > maxCharacters {
|
||||
reflection.responses[currentStep].answer = String(newValue.prefix(maxCharacters))
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityID.GuidedReflection.textEditor)
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("\(reflection.responses[currentStep].answer.count)/\(maxCharacters)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(
|
||||
reflection.responses[currentStep].answer.count >= maxCharacters ? .red : .secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Navigation Buttons
|
||||
|
||||
private var navigationButtons: some View {
|
||||
HStack {
|
||||
// Back button
|
||||
if currentStep > 0 {
|
||||
Button {
|
||||
navigateBack()
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "chevron.left")
|
||||
Text(String(localized: "guided_reflection_back"))
|
||||
}
|
||||
.font(.body)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityID.GuidedReflection.backButton)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Next / Save button
|
||||
if currentStep < totalSteps - 1 {
|
||||
Button {
|
||||
navigateForward()
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Text(String(localized: "guided_reflection_next"))
|
||||
Image(systemName: "chevron.right")
|
||||
}
|
||||
.font(.body)
|
||||
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)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityID.GuidedReflection.nextButton)
|
||||
} else {
|
||||
Button {
|
||||
saveReflection()
|
||||
} label: {
|
||||
Text(String(localized: "guided_reflection_save"))
|
||||
.font(.body)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.disabled(isSaving)
|
||||
.accessibilityIdentifier(AccessibilityID.GuidedReflection.saveButton)
|
||||
.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))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Navigation
|
||||
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() {
|
||||
isTextFieldFocused = false
|
||||
let animate = !UIAccessibility.isReduceMotionEnabled
|
||||
if animate {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
currentStep += 1
|
||||
}
|
||||
} else {
|
||||
currentStep += 1
|
||||
}
|
||||
focusTextFieldDelayed()
|
||||
guard let nextStepID = draft.stepID(after: currentStepID) else { return }
|
||||
focusedStepID = nil
|
||||
updateCurrentStep(to: nextStepID)
|
||||
}
|
||||
|
||||
private func navigateBack() {
|
||||
isTextFieldFocused = false
|
||||
let animate = !UIAccessibility.isReduceMotionEnabled
|
||||
if animate {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
currentStep -= 1
|
||||
}
|
||||
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 {
|
||||
currentStep -= 1
|
||||
}
|
||||
focusTextFieldDelayed()
|
||||
}
|
||||
|
||||
private func focusTextFieldDelayed() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
isTextFieldFocused = true
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
currentStepID = stepID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Save
|
||||
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 json = reflection.encode()
|
||||
let success = DataController.shared.updateReflection(forDate: entry.forDate, reflectionJSON: json)
|
||||
let success = DataController.shared.updateReflection(
|
||||
forDate: entry.forDate,
|
||||
reflectionJSON: reflection.encode()
|
||||
)
|
||||
|
||||
if success {
|
||||
dismiss()
|
||||
@@ -326,4 +456,265 @@ struct GuidedReflectionView: View {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user