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? @State private var selectedDate: Date? @Environment(\.horizontalSizeClass) private var horizontalSizeClass /// Month list + inline check-in: two columns in regular width (iPad, iPhone /// Duo inner screen), a collapsing stack in compact width. The selected /// month is the only navigation state, so it survives the Duo opening/closing. @State private var columnVisibility: NavigationSplitViewVisibility = .all var body: some View { NavigationSplitView(columnVisibility: $columnVisibility) { journalListContent .navigationTitle("Journal") .searchable(text: $searchText, prompt: "Search monthly notes") .onAppear { viewModel.refresh() } .navigationSplitViewColumnWidth(min: 300, ideal: 340, max: 420) } detail: { NavigationStack { if let date = selectedDate { MonthlyCheckInView(referenceDate: date) .id(date) } else { noMonthSelectedView } } } .navigationSplitViewStyle(.balanced) } // MARK: - Shared List Content private var journalListContent: some View { ScrollViewReader { proxy in ZStack { AppBackground() List(selection: $selectedDate) { // Calm 2.0: the habit story (streaks, achievements) lives in // the Journal — this is the emotional home of the ritual. if !viewModel.monthlyNotes.isEmpty && searchText.isEmpty { Section { // Narrow sidebar column in regular width → compact stat tiles. MomentumStreaksCard(compact: horizontalSizeClass == .regular) } .listRowInsets(EdgeInsets()) .listRowBackground(Color.clear) .listSectionSeparator(.hidden) } Section("Monthly Check-ins") { if filteredMonthlyNotes.isEmpty { if searchText.isEmpty { VStack(spacing: 12) { Image(systemName: "book.closed") .font(.system(size: 36)) .foregroundStyle(Color.appSecondary) Text(String(localized: "journal_empty_title")) .font(.headline) Text(String(localized: "journal_empty_body")) .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) } .padding(.vertical, 16) .frame(maxWidth: .infinity) } else { Text("No matching notes.") .font(.subheadline) .foregroundStyle(.secondary) } } else { ForEach(filteredMonthlyNotes) { entry in monthlyNoteRow(entry) .tag(entry.date) .id(entry.date) .onAppear { currentVisibleMonth = entry.date } } } } } .listStyle(.insetGrouped) .scrollContentBackground(.hidden) } .overlay(alignment: .trailing) { monthScrubber(proxy: proxy) } } } private var noMonthSelectedView: some View { ZStack { AppBackground() VStack(spacing: 32) { // Icon badge con gradiente ZStack { Circle() .fill(LinearGradient( colors: [Color.appSecondary, Color.appSecondary.lighter()], startPoint: .topLeading, endPoint: .bottomTrailing )) .frame(width: 120, height: 120) .shadow(color: Color.appSecondary.opacity(0.25), radius: 28, y: 10) Circle() .fill(Color.white.opacity(0.12)) .frame(width: 120, height: 120) Image(systemName: "book.closed.fill") .font(.system(size: 50, weight: .light)) .foregroundStyle(.white) } VStack(spacing: 10) { Text("Select a Month") .font(.title2.weight(.semibold)) Text("Choose a month from the list on the\nleft to view your check-in notes.") .frame(maxWidth: 360) .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) } Label("Select from the list", systemImage: "arrow.left") .font(.subheadline.weight(.semibold)) .foregroundStyle(Color.appSecondary) .padding(.horizontal, 22) .padding(.vertical, 11) .background(Color.appSecondary.opacity(0.1)) .clipShape(Capsule()) .overlay(Capsule().stroke(Color.appSecondary.opacity(0.2), lineWidth: 1)) } .padding(48) } .frame(maxWidth: .infinity, maxHeight: .infinity) } 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) } } /// Timeline-style row: the mood is the visual anchor of each month. private func monthlyNoteRow(_ entry: MonthlyNoteItem) -> some View { HStack(alignment: .top, spacing: 12) { ZStack { Circle() .fill((entry.mood?.tint ?? Color.secondary).opacity(0.14)) .frame(width: 40, height: 40) Image(systemName: entry.mood?.iconName ?? "book.closed") .font(.system(size: 16)) .foregroundStyle(entry.mood?.tint ?? .secondary) } VStack(alignment: .leading, spacing: 4) { HStack { Text(entry.date.monthYearString) .font(.subheadline.weight(.semibold)) Spacer() if entry.rating != nil { starRow(rating: entry.rating) .font(.caption2) } } if let mood = entry.mood { Text(mood.title) .font(.caption.weight(.medium)) .foregroundStyle(mood.tint) } Text(entry.note.isEmpty ? String(localized: "journal_no_note") : entry.note) .font(.subheadline) .foregroundStyle(.secondary) .lineLimit(2) } } .padding(.vertical, 6) } 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") .foregroundStyle(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)) .foregroundStyle(.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) .floatingPillBackground() .offset(x: -16) } if scrubberActive { Text(scrubberLabel) .font(.caption.weight(.semibold)) .padding(.horizontal, 10) .padding(.vertical, 6) .floatingPillBackground() .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)) .clipShape(RoundedRectangle(cornerRadius: 12)) } .padding() .navigationTitle("Monthly Note") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Save") { MonthlyCheckInStore.setNote(note, for: date) dismiss() } .fontWeight(.semibold) } } } } extension MonthlyCheckInMood { /// Semantic tint per mood — the timeline's color language. var tint: Color { switch self { case .energized: return .orange case .confident: return .positiveGreen case .balanced: return .teal case .cautious: return .yellow case .stressed: return .negativeRed } } } #Preview { NavigationStack { JournalView() } }