Files
Reflect/Shared/Views/NoteEditorView.swift
Trey t 0442eab1f8 Rebrand entire project from Feels to Reflect
Complete rename across all bundle IDs, App Groups, CloudKit containers,
StoreKit product IDs, data store filenames, URL schemes, logger subsystems,
Swift identifiers, user-facing strings (7 languages), file names, directory
names, Xcode project, schemes, assets, and documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:47:16 -06:00

544 lines
19 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
}
}
}
.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 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
// 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)
}
.alert("Delete Entry", isPresented: $showDeleteConfirmation) {
Button("Delete", role: .destructive) {
onDelete()
dismiss()
}
Button("Cancel", role: .cancel) { }
} 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 {
VStack(spacing: 8) {
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)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 20)
.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 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)
}
}
.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
}
} 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)
}
}
.confirmationDialog("Photo", isPresented: $showPhotoOptions, titleVisibility: .visible) {
Button("Take Photo") {
showCamera = true
}
Button("Choose from Library") {
showPhotoPicker = true
}
if entry.photoID != nil {
Button("Remove Photo", role: .destructive) {
_ = PhotoManager.shared.deletePhoto(id: entry.photoID!)
_ = DataController.shared.updatePhoto(forDate: entry.forDate, photoID: nil)
}
}
Button("Cancel", role: .cancel) { }
}
}
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()
}
}
.onTapGesture {
dismiss()
}
}
}