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.
657 lines
24 KiB
Swift
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)
|
|
}
|
|
}
|