319ab9589e
En iOS 27, NavigationSplitView(.balanced) con la barra lateral en ideal 340 pt
deja la columna fuera de pantalla en regular estrecho (iPad mini vertical,
744 pt): la app abre solo el detalle ("Select a Month" / "Select a Source")
y el recorrido DuoLayoutUITests fallaba al no encontrar el mes. En 26.5 se
mostraban las dos columnas. Con min 280 / ideal 300 el sistema vuelve a
acoplar la lista junto al detalle.
Verificado en iOS 27.0: DuoLayoutUITests pasa en iPad mini (27) y en
iPhone 17 Pro (27).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn
337 lines
13 KiB
Swift
337 lines
13 KiB
Swift
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: 280, ideal: 300, 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
|
|
// ForEach identity (MonthlyNoteItem.id == date) already serves
|
|
// ScrollViewReader; an extra .id() would wrap the .tag trait
|
|
// and break List selection.
|
|
monthlyNoteRow(entry)
|
|
.tag(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()
|
|
}
|
|
}
|