Files
InvestmentTrackerApp/PortfolioJournal/Views/Journal/JournalView.swift
T
alexandrev-tibco 18f2609d87 Fixes UI iPad + diagnóstico iCloud (build 57)
Feedback del usuario en iPad:
- Allocation: gráfica de tarta → semicírculo estilo hemiciclo (parlamento).
  SemicircleAllocation con arcos dibujados a mano (SemicircleArc Shape),
  segmentos por cuota en 180°, total/selección en el centro, tap por segmento.
- Charts de Analyze (Compare, Period vs Period, Year vs Year) ahora SÍ muestran
  KPIs en la cabecera de iPad como el resto: Compare (líder/rezagado por modo),
  Period (retorno final por periodo), YoY (retorno final por año seleccionado).
- Momentum & Streaks en el sidebar del Journal (iPad, 320pt): variante compact
  — tiles en fila sin subtítulos que rompían a 3 líneas, sin bloque de logros.

iCloud:
- CKErrorPartialFailure (CKErrorDomain code 2) mostraba un mensaje opaco.
  CoreDataStack.hint(for:) recorre CKPartialErrorsByItemIDKey y clasifica el
  código dominante → hint accionable (esquema/quota/red). Settings muestra el
  hint legible sobre el detalle técnico. lastSyncErrorHint publicado. Strings ×7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-10 14:16:24 +02:00

377 lines
14 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
var body: some View {
if horizontalSizeClass == .regular {
iPadJournalLayout
} else {
iPhoneJournalLayout
}
}
// MARK: - iPad Layout (list + inline detail)
private var iPadJournalLayout: some View {
HStack(spacing: 0) {
// Left: month list
NavigationStack {
journalListContent(isPad: true)
.navigationTitle("Journal")
.searchable(text: $searchText, prompt: "Search monthly notes")
.onAppear { viewModel.refresh() }
}
.frame(width: 320)
Divider()
// Right: monthly check-in detail
NavigationStack {
if let date = selectedDate {
MonthlyCheckInView(referenceDate: date)
} else {
noMonthSelectedView
}
}
}
}
// MARK: - iPhone Layout (push navigation)
private var iPhoneJournalLayout: some View {
NavigationStack {
journalListContent(isPad: false)
.navigationTitle("Journal")
.searchable(text: $searchText, prompt: "Search monthly notes")
.onAppear { viewModel.refresh() }
}
}
// MARK: - Shared List Content
private func journalListContent(isPad: Bool) -> some View {
ScrollViewReader { proxy in
ZStack {
AppBackground()
List {
// 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 {
MomentumStreaksCard(compact: isPad)
}
.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))
.foregroundColor(.appSecondary)
Text(String(localized: "journal_empty_title"))
.font(.headline)
Text(String(localized: "journal_empty_body"))
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.padding(.vertical, 16)
.frame(maxWidth: .infinity)
} else {
Text("No matching notes.")
.font(.subheadline)
.foregroundColor(.secondary)
}
} else {
ForEach(filteredMonthlyNotes) { entry in
if isPad {
Button {
selectedDate = entry.date
} label: {
monthlyNoteRow(entry)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.listRowBackground(
selectedDate.map { $0.isSameMonth(as: entry.date) } == true
? Color.appPrimary.opacity(0.08)
: Color.clear
)
.id(entry.date)
.onAppear { currentVisibleMonth = entry.date }
} else {
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)
}
}
}
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))
.foregroundColor(.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.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
Label("Select from the list", systemImage: "arrow.left")
.font(.subheadline.weight(.semibold))
.foregroundColor(.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))
.foregroundColor(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))
.foregroundColor(mood.tint)
}
Text(entry.note.isEmpty ? String(localized: "journal_no_note") : entry.note)
.font(.subheadline)
.foregroundColor(.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")
.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)
}
}
}
}
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()
}
}