import SwiftUI struct JournalView: View { @StateObject private var viewModel = JournalViewModel() @State private var searchText = "" @State private var scrubberActive = false @State private var scrubberLabel = "" @State private var scrubberOffset: CGFloat = 0 @State private var currentVisibleMonth: Date? var body: some View { NavigationStack { ScrollViewReader { proxy in ZStack { AppBackground() List { Section("Monthly Check-ins") { if filteredMonthlyNotes.isEmpty { Text(searchText.isEmpty ? "No monthly notes yet." : "No matching notes.") .font(.subheadline) .foregroundColor(.secondary) } else { ForEach(filteredMonthlyNotes) { entry in NavigationLink { MonthlyCheckInView(referenceDate: entry.date) } label: { monthlyNoteRow(entry) } .id(entry.date) .onAppear { currentVisibleMonth = entry.date } } } } } .listStyle(.insetGrouped) .scrollContentBackground(.hidden) } .overlay(alignment: .trailing) { monthScrubber(proxy: proxy) } .navigationTitle("Journal") .searchable(text: $searchText, prompt: "Search monthly notes") .onAppear { viewModel.refresh() } } } } private var filteredMonthlyNotes: [MonthlyNoteItem] { let trimmedQuery = searchText.trimmingCharacters(in: .whitespacesAndNewlines) let hasQuery = !trimmedQuery.isEmpty let query = trimmedQuery.lowercased() return viewModel.monthlyNotes.filter { entry in !hasQuery || entry.note.lowercased().contains(query) || entry.date.monthYearString.lowercased().contains(query) } } private func monthlyNoteRow(_ entry: MonthlyNoteItem) -> some View { VStack(alignment: .leading, spacing: 8) { HStack { Text(entry.date.monthYearString) .font(.subheadline.weight(.semibold)) Spacer() if let mood = entry.mood { moodPill(mood) } else { Text("Mood not set") .font(.caption) .foregroundColor(.secondary) } } HStack(spacing: 6) { starRow(rating: entry.rating) if entry.rating == nil { Text("No rating") .font(.caption) .foregroundColor(.secondary) } } Text(entry.note.isEmpty ? "No note yet." : entry.note) .font(.subheadline) .foregroundColor(.secondary) .lineLimit(2) } .padding(.vertical, 4) } private func starRow(rating: Int?) -> some View { let value = rating ?? 0 return HStack(spacing: 4) { ForEach(1...5, id: \.self) { index in Image(systemName: index <= value ? "star.fill" : "star") .foregroundColor(index <= value ? .yellow : .secondary) } } .accessibilityLabel( Text(String(format: NSLocalizedString("rating_accessibility", comment: ""), value)) ) } private func moodPill(_ mood: MonthlyCheckInMood) -> some View { Label(mood.title, systemImage: mood.iconName) .font(.caption.weight(.semibold)) .padding(.horizontal, 10) .padding(.vertical, 6) .background(Color.gray.opacity(0.15)) .foregroundColor(.primary) .clipShape(Capsule()) } private func monthScrubber(proxy: ScrollViewProxy) -> some View { GeometryReader { geometry in let height = geometry.size.height let count = filteredMonthlyNotes.count let currentIndex = currentVisibleMonth.flatMap { month in filteredMonthlyNotes.firstIndex(where: { $0.date.isSameMonth(as: month) }) } ZStack(alignment: .trailing) { if let currentVisibleMonth, !scrubberActive { Text(currentVisibleMonth.monthYearString) .font(.caption.weight(.semibold)) .padding(.horizontal, 10) .padding(.vertical, 6) .background(Color(.systemBackground).opacity(0.95)) .cornerRadius(12) .shadow(color: .black.opacity(0.12), radius: 6, y: 2) .offset(x: -16) } if scrubberActive { Text(scrubberLabel) .font(.caption.weight(.semibold)) .padding(.horizontal, 10) .padding(.vertical, 6) .background(Color(.systemBackground).opacity(0.95)) .cornerRadius(12) .shadow(color: .black.opacity(0.12), radius: 6, y: 2) .offset(x: -16, y: scrubberOffset - height / 2) } if let currentIndex, count > 1 { let progress = CGFloat(currentIndex) / CGFloat(max(count - 1, 1)) Circle() .fill(Color.appSecondary.opacity(0.9)) .frame(width: 12, height: 12) .offset(y: progress * height - height / 2) } Capsule() .fill(Color.secondary.opacity(0.35)) .frame(width: 6) .frame(maxHeight: .infinity, alignment: .trailing) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) .padding(.trailing, 8) .contentShape(Rectangle()) .gesture( DragGesture(minimumDistance: 0) .onChanged { value in guard count > 0 else { return } scrubberActive = true let clampedY = min(max(value.location.y, 0), height) scrubberOffset = clampedY let ratio = clampedY / max(height, 1) let index = min(max(Int(round(ratio * CGFloat(count - 1))), 0), count - 1) let entry = filteredMonthlyNotes[index] scrubberLabel = entry.date.monthYearString proxy.scrollTo(entry.id, anchor: .top) } .onEnded { _ in scrubberActive = false } ) .allowsHitTesting(count > 0) } .frame(width: 72) } } struct MonthlyNoteEditorView: View { @Environment(\.dismiss) private var dismiss let date: Date @Binding var note: String var body: some View { VStack(alignment: .leading, spacing: 16) { Text(date.monthYearString) .font(.headline) TextEditor(text: $note) .frame(minHeight: 200) .padding(8) .background(Color.gray.opacity(0.08)) .cornerRadius(12) } .padding() .navigationTitle("Monthly Note") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Save") { MonthlyCheckInStore.setNote(note, for: date) dismiss() } .fontWeight(.semibold) } } } } #Preview { NavigationStack { JournalView() } }