Files
Reflect/Shared/Views/NoteEditorView.swift
Trey T e7648ddd8a Add missing accessibility identifiers to all interactive UI elements
Audit found ~50+ interactive elements (buttons, toggles, pickers, alerts,
links) missing accessibility identifiers across 13 view files. Added
centralized ID definitions and applied them to every entry detail button,
guided reflection control, settings toggle, paywall unlock button,
subscription/IAP button, lock screen control, and photo action dialog.
2026-03-26 07:59:52 -05:00

657 lines
24 KiB
Swift

//
// NoteEditorView.swift
// Reflect
//
// Editor for adding/editing journal notes on mood entries.
//
import SwiftUI
import PhotosUI
struct NoteEditorView: View {
@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("Journal Note")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
.accessibilityIdentifier(AccessibilityID.NoteEditor.cancelButton)
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
saveNote()
}
.disabled(isSaving || noteText.count > maxCharacters)
.fontWeight(.semibold)
.accessibilityIdentifier(AccessibilityID.NoteEditor.saveButton)
}
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button("Done") {
isTextFieldFocused = false
}
.accessibilityIdentifier(AccessibilityID.NoteEditor.keyboardDoneButton)
}
}
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
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("Entry Details")
.navigationBarTitleDisplayMode(.inline)
.accessibilityIdentifier(AccessibilityID.EntryDetail.sheet)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
dismiss()
}
.accessibilityIdentifier(AccessibilityID.EntryDetail.doneButton)
}
}
.sheet(isPresented: $showNoteEditor) {
NoteEditorView(entry: entry)
}
.sheet(isPresented: $showReflectionFlow) {
GuidedReflectionView(entry: entry)
}
.alert("Delete Entry", isPresented: $showDeleteConfirmation) {
Button("Delete", role: .destructive) {
onDelete()
dismiss()
}
.accessibilityIdentifier(AccessibilityID.EntryDetail.deleteConfirmButton)
Button("Cancel", role: .cancel) { }
.accessibilityIdentifier(AccessibilityID.EntryDetail.deleteCancelButton)
} message: {
Text("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)
}
}