Files
InvestmentTrackerApp/PortfolioJournal/Views/Journal/JournalView.swift
T
alexandrev-tibco 319ab9589e Columna lateral de 300 pt para que iOS 27 no la esconda en iPad mini
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
2026-09-17 13:48:36 +02:00

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()
}
}