// // GuidedReflectionView.swift // Reflect // // Card-step guided reflection sheet — one question at a time. // import SwiftUI 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 reflection: GuidedReflection @State private var currentStep: Int = 0 @State private var isSaving = false @State private var showDiscardAlert = false @FocusState private var isTextFieldFocused: Bool /// Snapshot of the initial state to detect unsaved changes private let initialReflection: GuidedReflection private let maxCharacters = 500 private var textColor: Color { theme.currentTheme.labelColor } private var totalSteps: Int { reflection.totalQuestions } private var hasUnsavedChanges: Bool { reflection != initialReflection } 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 } var body: some View { NavigationStack { VStack(spacing: 0) { // Entry header entryHeader .padding() .background(Color(.systemGray6)) Divider() // Progress dots progressDots .padding(.top, 20) .padding(.bottom, 8) // Question + answer area questionContent .padding(.horizontal) Spacer() // Navigation buttons navigationButtons .padding() } .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) } ToolbarItemGroup(placement: .keyboard) { Spacer() Button("Done") { isTextFieldFocused = false } } } .alert( String(localized: "guided_reflection_unsaved_title"), isPresented: $showDiscardAlert ) { Button(String(localized: "guided_reflection_discard"), role: .destructive) { dismiss() } Button(String(localized: "Cancel"), role: .cancel) { } } message: { Text(String(localized: "guided_reflection_unsaved_message")) } .trackScreen(.guidedReflection) } } // MARK: - Entry Header private var entryHeader: some View { HStack(spacing: 12) { Circle() .fill(moodTint.color(forMood: entry.mood).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)) ) 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(moodTint.color(forMood: entry.mood)) } Spacer() } } // MARK: - Progress Dots private var progressDots: some View { HStack(spacing: 8) { ForEach(0.. 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) .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) } } } // MARK: - Navigation private func navigateForward() { isTextFieldFocused = false let animate = !UIAccessibility.isReduceMotionEnabled if animate { withAnimation(.easeInOut(duration: 0.3)) { currentStep += 1 } } else { currentStep += 1 } focusTextFieldDelayed() } private func navigateBack() { isTextFieldFocused = false let animate = !UIAccessibility.isReduceMotionEnabled if animate { withAnimation(.easeInOut(duration: 0.3)) { currentStep -= 1 } } else { currentStep -= 1 } focusTextFieldDelayed() } private func focusTextFieldDelayed() { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { isTextFieldFocused = true } } // MARK: - Save private func saveReflection() { isSaving = true if reflection.isComplete { reflection.completedAt = Date() } let json = reflection.encode() let success = DataController.shared.updateReflection(forDate: entry.forDate, reflectionJSON: json) if success { dismiss() } else { isSaving = false } } }