// // NoteEditorView.swift // Reflect // // Editor for adding/editing journal notes on mood entries. // import SwiftUI import PhotosUI struct NoteEditorView: View { private enum AnimationConstants { static let keyboardAppearDelay: TimeInterval = 0.5 } @Environment(\.dismiss) private var dismiss @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system private var textColor: Color { theme.currentTheme.labelColor } let entry: MoodEntryModel @State private var noteText: String @State private var isSaving = false @FocusState private var isTextFieldFocused: Bool private let maxCharacters = 2000 init(entry: MoodEntryModel) { self.entry = entry self._noteText = State(initialValue: entry.notes ?? "") } var body: some View { NavigationStack { VStack(spacing: 0) { // Entry header entryHeader .padding() .background(Color(.systemGray6)) Divider() // Notes editor VStack(alignment: .leading, spacing: 8) { TextEditor(text: $noteText) .focused($isTextFieldFocused) .frame(maxHeight: .infinity) .scrollContentBackground(.hidden) .padding(.horizontal, 4) .accessibilityIdentifier(AccessibilityID.NoteEditor.textEditor) // Character count HStack { Spacer() Text("\(noteText.count)/\(maxCharacters)") .font(.caption) .foregroundStyle(noteText.count > maxCharacters ? .red : .secondary) } } .padding() } .navigationTitle(String(localized: "Journal Note")) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button(String(localized: "Cancel")) { dismiss() } .accessibilityIdentifier(AccessibilityID.NoteEditor.cancelButton) } ToolbarItem(placement: .confirmationAction) { Button(String(localized: "Save")) { saveNote() } .disabled(isSaving || noteText.count > maxCharacters) .fontWeight(.semibold) .accessibilityIdentifier(AccessibilityID.NoteEditor.saveButton) } ToolbarItemGroup(placement: .keyboard) { Spacer() Button(String(localized: "Done")) { isTextFieldFocused = false } .accessibilityIdentifier(AccessibilityID.NoteEditor.keyboardDoneButton) } } .onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + AnimationConstants.keyboardAppearDelay) { isTextFieldFocused = true } } } } private var entryHeader: some View { HStack(spacing: 12) { // Mood icon Circle() .fill(entry.mood.color.opacity(0.2)) .frame(width: 50, height: 50) .overlay( entry.mood.icon .resizable() .aspectRatio(contentMode: .fit) .frame(width: 28, height: 28) .foregroundColor(entry.mood.color) ) 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(entry.mood.color) } Spacer() } } private func saveNote() { isSaving = true let trimmedNote = noteText.trimmingCharacters(in: .whitespacesAndNewlines) let noteToSave: String? = trimmedNote.isEmpty ? nil : trimmedNote let success = DataController.shared.updateNotes(forDate: entry.forDate, notes: noteToSave) if success { dismiss() } else { isSaving = false } } } // MARK: - Entry Detail View (combines mood edit, notes, photos) struct EntryDetailView: View { @Environment(\.dismiss) private var dismiss @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 @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @AppStorage(UserDefaultsStore.Keys.deleteEnable.rawValue, store: GroupUserDefaults.groupDefaults) private var deleteEnabled = true private var textColor: Color { theme.currentTheme.labelColor } let entry: MoodEntryModel let onMoodUpdate: (Mood) -> Void let onDelete: () -> Void @State private var showNoteEditor = false @State private var showPhotoOptions = false @State private var showPhotoPicker = false @State private var showCamera = false @State private var showDeleteConfirmation = false @State private var showFullScreenPhoto = false @State private var selectedPhotoItem: PhotosPickerItem? @State private var showReflectionFlow = false @State private var selectedMood: Mood? private var currentMood: Mood { selectedMood ?? entry.mood } private var moodColor: Color { moodTint.color(forMood: currentMood) } private func savePhoto(_ image: UIImage) { if let photoID = PhotoManager.shared.savePhoto(image) { _ = DataController.shared.updatePhoto(forDate: entry.forDate, photoID: photoID) } } var body: some View { NavigationStack { ScrollView { VStack(spacing: 24) { // Date header dateHeader // Mood section moodSection // Guided reflection section if currentMood != .missing && currentMood != .placeholder { reflectionSection } // Notes section notesSection // Photo section photoSection // Delete button if deleteEnabled && entry.mood != .missing { deleteSection } } .padding() } .background(Color(.systemGroupedBackground)) .navigationTitle(String(localized: "Entry Details")) .navigationBarTitleDisplayMode(.inline) .accessibilityIdentifier(AccessibilityID.EntryDetail.sheet) .toolbar { ToolbarItem(placement: .confirmationAction) { Button(String(localized: "Done")) { dismiss() } .accessibilityIdentifier(AccessibilityID.EntryDetail.doneButton) } } .sheet(isPresented: $showNoteEditor) { NoteEditorView(entry: entry) } .sheet(isPresented: $showReflectionFlow) { GuidedReflectionView(entry: entry) } .alert(String(localized: "Delete Entry"), isPresented: $showDeleteConfirmation) { Button(String(localized: "Delete"), role: .destructive) { onDelete() dismiss() } .accessibilityIdentifier(AccessibilityID.EntryDetail.deleteConfirmButton) Button(String(localized: "Cancel"), role: .cancel) { } .accessibilityIdentifier(AccessibilityID.EntryDetail.deleteCancelButton) } message: { Text(String(localized: "Are you sure you want to delete this mood entry? This cannot be undone.")) } .photosPicker(isPresented: $showPhotoPicker, selection: $selectedPhotoItem, matching: .images) .onChange(of: selectedPhotoItem) { _, newItem in guard let newItem else { return } Task { if let data = try? await newItem.loadTransferable(type: Data.self), let image = UIImage(data: data) { savePhoto(image) } selectedPhotoItem = nil } } .fullScreenCover(isPresented: $showCamera) { CameraView { image in savePhoto(image) } } .fullScreenCover(isPresented: $showFullScreenPhoto) { FullScreenPhotoView(photoID: entry.photoID) } } } private var dateHeader: some View { HStack { VStack(alignment: .leading, spacing: 4) { Text(entry.forDate, format: .dateTime.weekday(.wide)) .font(.title2) .fontWeight(.semibold) .foregroundColor(textColor) Text(entry.forDate, format: .dateTime.month(.wide).day().year()) .font(.subheadline) .foregroundStyle(.secondary) } Spacer() // Weather if available if let weatherJSON = entry.weatherJSON, let weather = WeatherData.decode(from: weatherJSON) { HStack(spacing: 8) { Image(systemName: weather.conditionSymbol) .font(.title) .foregroundStyle(.secondary) VStack(alignment: .leading, spacing: 2) { Text(weather.condition) .font(.subheadline) .foregroundStyle(.secondary) Text("H: \(Int(round(weather.highTemperature)))° L: \(Int(round(weather.lowTemperature)))°") .font(.caption) .foregroundStyle(.tertiary) } } } } .padding(.horizontal, 20) .padding(.vertical, 16) .background( RoundedRectangle(cornerRadius: 16) .fill(Color(.systemBackground)) ) } private var moodSection: some View { VStack(alignment: .leading, spacing: 12) { Text("Mood") .font(.headline) .foregroundColor(textColor) // Current mood display HStack(spacing: 16) { ZStack { Circle() .fill( LinearGradient( colors: [moodColor.opacity(0.8), moodColor.opacity(0.4)], startPoint: .topLeading, endPoint: .bottomTrailing ) ) .frame(width: 60, height: 60) imagePack.icon(forMood: currentMood) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 34, height: 34) .foregroundColor(.white) .accessibilityLabel(currentMood.strValue) } .shadow(color: moodColor.opacity(0.4), radius: 8, x: 0, y: 4) VStack(alignment: .leading, spacing: 4) { Text(currentMood.strValue) .font(.title3) .fontWeight(.semibold) .foregroundColor(moodColor) Text("Tap to change") .font(.caption) .foregroundStyle(.secondary) } Spacer() } .padding() .background( RoundedRectangle(cornerRadius: 16) .fill(Color(.systemBackground)) ) // Mood selection grid LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 5), spacing: 12) { ForEach(Mood.allValues) { mood in Button { // Update local state immediately for instant feedback if UIAccessibility.isReduceMotionEnabled { selectedMood = mood } else { withAnimation(.easeInOut(duration: 0.15)) { selectedMood = mood } } // Then persist the change onMoodUpdate(mood) } label: { VStack(spacing: 6) { Circle() .fill(currentMood == mood ? moodTint.color(forMood: mood) : Color(.systemGray5)) .frame(width: 50, height: 50) .overlay( imagePack.icon(forMood: mood) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 28, height: 28) .foregroundColor(currentMood == mood ? .white : .gray) .accessibilityLabel(mood.strValue) ) Text(mood.strValue) .font(.caption2) .foregroundColor(currentMood == mood ? moodTint.color(forMood: mood) : .secondary) } } .buttonStyle(.plain) .accessibilityIdentifier(AccessibilityID.MoodButton.id(for: mood.widgetDisplayName)) } } .padding() .background( RoundedRectangle(cornerRadius: 16) .fill(Color(.systemBackground)) ) .accessibilityIdentifier(AccessibilityID.EntryDetail.moodGrid) } } private var notesSection: some View { VStack(alignment: .leading, spacing: 12) { HStack { Text("Journal Note") .font(.headline) .foregroundColor(textColor) Spacer() Button { showNoteEditor = true } label: { Text(entry.notes == nil ? "Add" : "Edit") .font(.subheadline) .fontWeight(.medium) } .accessibilityIdentifier(AccessibilityID.EntryDetail.noteButton) } Button { showNoteEditor = true } label: { HStack { if let notes = entry.notes, !notes.isEmpty { Text(notes) .font(.body) .foregroundColor(textColor) .multilineTextAlignment(.leading) .lineLimit(5) } else { HStack(spacing: 8) { Image(systemName: "note.text") .foregroundStyle(.secondary) Text("Add a note about how you're feeling...") .foregroundStyle(.secondary) } } Spacer() Image(systemName: "chevron.right") .font(.caption) .foregroundStyle(.tertiary) } .padding() .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 16) .fill(Color(.systemBackground)) ) } .buttonStyle(.plain) .accessibilityIdentifier(AccessibilityID.EntryDetail.noteArea) } } private var reflectionSection: some View { VStack(alignment: .leading, spacing: 12) { HStack { Text(String(localized: "guided_reflection_title")) .font(.headline) .foregroundColor(textColor) Spacer() Button { AnalyticsManager.shared.track(.reflectionStarted) showReflectionFlow = true } label: { Text(entry.reflectionJSON != nil ? String(localized: "guided_reflection_edit") : String(localized: "guided_reflection_begin")) .font(.subheadline) .fontWeight(.medium) } .accessibilityIdentifier(AccessibilityID.EntryDetail.reflectionBeginButton) } Button { AnalyticsManager.shared.track(.reflectionStarted) showReflectionFlow = true } label: { HStack { if let json = entry.reflectionJSON, let reflection = GuidedReflection.decode(from: json), reflection.answeredCount > 0 { VStack(alignment: .leading, spacing: 6) { Text(reflection.responses.first(where: { !$0.answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })?.answer ?? "") .font(.body) .foregroundColor(textColor) .multilineTextAlignment(.leading) .lineLimit(3) Text(String(localized: "guided_reflection_answered_count \(reflection.answeredCount) \(reflection.totalQuestions)")) .font(.caption) .foregroundStyle(.secondary) } } else { HStack(spacing: 8) { Image(systemName: "sparkles") .foregroundStyle(.secondary) Text(String(localized: "guided_reflection_empty_prompt")) .foregroundStyle(.secondary) } } Spacer() Image(systemName: "chevron.right") .font(.caption) .foregroundStyle(.tertiary) } .padding() .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 16) .fill(Color(.systemBackground)) ) } .buttonStyle(.plain) .accessibilityIdentifier(AccessibilityID.EntryDetail.reflectionCard) } } private var photoSection: some View { VStack(alignment: .leading, spacing: 12) { HStack { Text("Photo") .font(.headline) .foregroundColor(textColor) Spacer() Button { showPhotoOptions = true } label: { Text(entry.photoID == nil ? "Add" : "Change") .font(.subheadline) .fontWeight(.medium) } .accessibilityIdentifier(AccessibilityID.EntryDetail.photoButton) } .zIndex(1) if let photoID = entry.photoID, let image = PhotoManager.shared.thumbnail(for: photoID) { image .resizable() .aspectRatio(contentMode: .fill) .frame(height: 200) .frame(maxWidth: .infinity) .clipShape(RoundedRectangle(cornerRadius: 16)) .clipped() .contentShape(Rectangle()) .onTapGesture { showFullScreenPhoto = true } .accessibilityIdentifier(AccessibilityID.EntryDetail.photoImage) } else { Button { showPhotoOptions = true } label: { VStack(spacing: 12) { Image(systemName: "photo.badge.plus") .font(.largeTitle) .foregroundStyle(.secondary) Text("Add a photo") .font(.subheadline) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity) .frame(height: 120) .background( RoundedRectangle(cornerRadius: 16) .fill(Color(.systemBackground)) .overlay( RoundedRectangle(cornerRadius: 16) .strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [8])) .foregroundStyle(.tertiary) ) ) } .buttonStyle(.plain) .accessibilityIdentifier(AccessibilityID.EntryDetail.photoPlaceholder) } } .confirmationDialog("Photo", isPresented: $showPhotoOptions, titleVisibility: .visible) { Button("Take Photo") { showCamera = true } .accessibilityIdentifier(AccessibilityID.EntryDetail.photoTakeButton) Button("Choose from Library") { showPhotoPicker = true } .accessibilityIdentifier(AccessibilityID.EntryDetail.photoChooseButton) if let photoID = entry.photoID { Button("Remove Photo", role: .destructive) { _ = PhotoManager.shared.deletePhoto(id: photoID) _ = DataController.shared.updatePhoto(forDate: entry.forDate, photoID: nil) } .accessibilityIdentifier(AccessibilityID.EntryDetail.photoRemoveButton) } Button("Cancel", role: .cancel) { } .accessibilityIdentifier(AccessibilityID.EntryDetail.photoCancelButton) } } private var deleteSection: some View { Button(role: .destructive) { showDeleteConfirmation = true } label: { HStack { Image(systemName: "trash") Text("Delete Entry") } .frame(maxWidth: .infinity) .padding() .background( RoundedRectangle(cornerRadius: 16) .fill(Color(.systemBackground)) ) } .padding(.top, 8) .accessibilityIdentifier(AccessibilityID.EntryDetail.deleteButton) } } // MARK: - Full Screen Photo View struct FullScreenPhotoView: View { @Environment(\.dismiss) private var dismiss let photoID: UUID? var body: some View { ZStack { Color.black.ignoresSafeArea() if let photoID = photoID, let image = PhotoManager.shared.loadPhoto(id: photoID) { Image(uiImage: image) .resizable() .aspectRatio(contentMode: .fit) .ignoresSafeArea() } } .overlay(alignment: .topTrailing) { Button { dismiss() } label: { Image(systemName: "xmark.circle.fill") .font(.title) .foregroundStyle(.white.opacity(0.8)) .padding() } .accessibilityIdentifier(AccessibilityID.FullScreenPhoto.closeButton) } .onTapGesture { dismiss() } .accessibilityIdentifier(AccessibilityID.FullScreenPhoto.dismissArea) } }