From 680255f69dcc51d49fd39d445ccd304cfa86e814 Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Wed, 16 Sep 2026 11:44:50 +0200 Subject: [PATCH] =?UTF-8?q?iPhone=20Duo=20(fase=201):=20navegaci=C3=B3n=20?= =?UTF-8?q?adaptativa=20por=20size=20class=20y=20layouts=20regular=C3=97re?= =?UTF-8?q?gular?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ContentView: un único TabView (sidebarAdaptable en iOS 18+) para todas las size classes; desaparece el cambio de jerarquía iPhone/iPad, así abrir o cerrar el Duo conserva el estado de cada pestaña. @SceneStorage de la tab. - Fuentes y Diario: NavigationSplitView (lista + detalle a la vez en ancho regular, stack colapsable en compacto) con selección nativa de List. - Dashboard: Quick Update como .inspector (panel lateral junto a los gráficos en regular, sheet en compacto). - Gráficos: chartHeightScale (+35% de alto en regular) vía chartFrame(height:), sin ignoresSafeArea en contenido interactivo. - Liquid Glass (glassEffect) en las etiquetas flotantes del Diario en iOS 26+. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn --- PortfolioJournal/App/ContentView.swift | 119 +++++----- .../Views/Charts/AllocationPieChart.swift | 38 ++-- .../Charts/AllocationSimulatorView.swift | 28 +-- .../Views/Charts/ChartSummaryComponents.swift | 24 +- .../Views/Charts/ChartValueLabels.swift | 8 +- PortfolioJournal/Views/Charts/ChartZoom.swift | 6 +- .../Views/Charts/ChartsContainerView.swift | 118 +++++----- .../Views/Charts/ComparisonChartView.swift | 18 +- .../Views/Charts/DrawdownChart.swift | 32 +-- .../Views/Charts/PerformanceBarChart.swift | 30 +-- .../Charts/PeriodComparisonChartView.swift | 30 +-- .../Views/Charts/PredictionChartView.swift | 32 +-- .../Views/Charts/YearOverYearChartView.swift | 26 +-- .../Views/Components/AdaptiveLayout.swift | 39 ++++ .../Views/Dashboard/DashboardView.swift | 209 +++++++++--------- .../Views/Journal/JournalView.swift | 114 +++------- .../Views/Sources/SourceListView.swift | 161 +++++--------- 17 files changed, 485 insertions(+), 547 deletions(-) create mode 100644 PortfolioJournal/Views/Components/AdaptiveLayout.swift diff --git a/PortfolioJournal/App/ContentView.swift b/PortfolioJournal/App/ContentView.swift index aa1db2a..512a41b 100644 --- a/PortfolioJournal/App/ContentView.swift +++ b/PortfolioJournal/App/ContentView.swift @@ -41,13 +41,14 @@ struct ContentView: View { @AppStorage("lockOnBackground") private var lockOnBackground = false @AppStorage("lastSeenWhatsNewVersion") private var lastSeenWhatsNewVersion = "" @Environment(\.scenePhase) private var scenePhase - @Environment(\.horizontalSizeClass) private var horizontalSizeClass @State private var isUnlocked = false @State private var resolvedOnboardingCompleted: Bool? @State private var iCloudCheckDone = false @State private var loadingMessageKey: LocalizedStringKey = "loading_data" - @State private var sidebarSelection: AppTab? = .dashboard @State private var showingWhatsNew = false + /// Per-scene tab restoration: the interface is re-laid out when iPhone Duo + /// opens/closes or a window is resized — the selected tab must survive it. + @SceneStorage("selectedTab") private var restoredTab: Int = -1 private var currentVersion: String { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" @@ -75,8 +76,6 @@ struct ContentView: View { OnboardingICloudCheckView(onSkip: { iCloudCheckDone = true }) } else if resolvedOnboardingCompleted == false { OnboardingView(onboardingCompleted: $onboardingCompleted) - } else if horizontalSizeClass == .regular { - iPadMainContent } else { mainContent } @@ -92,6 +91,12 @@ struct ContentView: View { } else { isUnlocked = !lockOnLaunch } + if restoredTab >= 0, AppTab(rawValue: restoredTab) != nil { + tabSelection.selectedTab = restoredTab + } + } + .onChange(of: tabSelection.selectedTab) { _, tab in + restoredTab = tab } .onChange(of: lockEnabled) { _, enabled in if !enabled { @@ -119,7 +124,7 @@ struct ContentView: View { if (resolvedOnboardingCompleted == true) && currentVersion != lastSeenWhatsNewVersion, !WhatsNewCatalog.pendingReleases(since: lastSeenWhatsNewVersion, current: currentVersion).isEmpty { // Small delay to let the UI settle before showing sheet - try? await Task.sleep(nanoseconds: 300_000_000) + try? await Task.sleep(for: .milliseconds(300)) showingWhatsNew = true } } @@ -144,18 +149,15 @@ struct ContentView: View { )) } .onReceive(NotificationCenter.default.publisher(for: .openBatchUpdate)) { _ in - tabSelection.selectedTab = 1 - sidebarSelection = .sources + tabSelection.selectedTab = AppTab.sources.rawValue NotificationCenter.default.post(name: .openQuickUpdate, object: nil) } .onReceive(NotificationCenter.default.publisher(for: .openDashboard)) { _ in - tabSelection.selectedTab = 0 - sidebarSelection = .dashboard + tabSelection.selectedTab = AppTab.dashboard.rawValue } .onOpenURL { url in if url.host == "quickupdate" { - tabSelection.selectedTab = 0 - sidebarSelection = .dashboard + tabSelection.selectedTab = AppTab.dashboard.rawValue NotificationCenter.default.post(name: .openQuickUpdate, object: nil) } } @@ -168,7 +170,7 @@ struct ContentView: View { private func waitForDataAndResolveOnboarding() async { // Wait for Core Data to be loaded while !coreDataStack.isLoaded { - try? await Task.sleep(nanoseconds: 50_000_000) // 50ms + try? await Task.sleep(for: .milliseconds(50)) // 50ms } // If CloudKit is enabled and no local data yet, wait briefly for the @@ -192,7 +194,7 @@ struct ContentView: View { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { if hasExistingData() { return } - try? await Task.sleep(nanoseconds: 300_000_000) // poll every 300ms + try? await Task.sleep(for: .milliseconds(300)) // poll every 300ms } } @@ -240,33 +242,50 @@ struct ContentView: View { return ((try? context.count(for: accountRequest)) ?? 0) > 0 } - // MARK: - iPad Layout + // MARK: - Adaptive navigation + // + // One TabView for every size class. iOS 18+ uses the sidebar-adaptable + // style: a tab bar in compact width (iPhone, iPhone Duo outer screen) and a + // top tab bar / sidebar in regular width (iPad, iPhone Duo inner screen). + // Because the hierarchy never changes, opening or closing the Duo keeps + // every tab's navigation and scroll state. Each tab that has a list+detail + // structure (Sources, Journal) owns a NavigationSplitView that collapses + // to a stack in compact width and shows both columns in regular width. - private var iPadMainContent: some View { - NavigationSplitView(columnVisibility: .constant(.all)) { - List(AppTab.allCases, id: \.self, selection: $sidebarSelection) { tab in - Label(tab.title, systemImage: tab.icon) - } - .navigationTitle("Portfolio Journal") - .navigationSplitViewColumnWidth(min: 200, ideal: 240, max: 280) - } detail: { - iPadDetailView(for: sidebarSelection ?? .dashboard) + @ViewBuilder + private var mainContent: some View { + if #available(iOS 18.0, *) { + adaptiveTabs + } else { + legacyTabs } - .onChange(of: sidebarSelection) { _, tab in - guard let tab else { return } - if tabSelection.selectedTab != tab.rawValue { - tabSelection.selectedTab = tab.rawValue + } + + @available(iOS 18.0, *) + private var adaptiveTabs: some View { + TabView(selection: $tabSelection.selectedTab) { + ForEach(AppTab.allCases, id: \.self) { tab in + Tab(tab.title, systemImage: tab.icon, value: tab.rawValue) { + tabContent(for: tab) + } } } - .onChange(of: tabSelection.selectedTab) { _, value in - if let tab = AppTab(rawValue: value), sidebarSelection != tab { - sidebarSelection = tab + .tabViewStyle(.sidebarAdaptable) + } + + /// iOS 17 fallback: classic tab bar in every size class. + private var legacyTabs: some View { + TabView(selection: $tabSelection.selectedTab) { + ForEach(AppTab.allCases, id: \.self) { tab in + tabContent(for: tab) + .tabItem { Label(tab.title, systemImage: tab.icon) } + .tag(tab.rawValue) } } } @ViewBuilder - private func iPadDetailView(for tab: AppTab) -> some View { + private func tabContent(for tab: AppTab) -> some View { switch tab { case .dashboard: bannerInsetView(DashboardView()) @@ -281,44 +300,6 @@ struct ContentView: View { } } - // MARK: - iPhone Layout - - private var mainContent: some View { - ZStack { - TabView(selection: $tabSelection.selectedTab) { - bannerInsetView(DashboardView()) - .tabItem { - Label("Home", systemImage: "house.fill") - } - .tag(0) - - bannerInsetView(SourceListView(iapService: iapService)) - .tabItem { - Label("Sources", systemImage: "list.bullet") - } - .tag(1) - - bannerInsetView(ChartsContainerView(iapService: iapService)) - .tabItem { - Label("Charts", systemImage: "chart.xyaxis.line") - } - .tag(2) - - bannerInsetView(JournalView()) - .tabItem { - Label("Journal", systemImage: "book.closed") - } - .tag(3) - - bannerInsetView(SettingsView(iapService: iapService)) - .tabItem { - Label("Settings", systemImage: "gearshape.fill") - } - .tag(4) - } - } - } - private func bannerInsetView(_ content: Content) -> some View { content.safeAreaInset(edge: .bottom, spacing: 0) { if !iapService.isPremium && adMobService.canShowAds { diff --git a/PortfolioJournal/Views/Charts/AllocationPieChart.swift b/PortfolioJournal/Views/Charts/AllocationPieChart.swift index 0918d38..b14b2eb 100644 --- a/PortfolioJournal/Views/Charts/AllocationPieChart.swift +++ b/PortfolioJournal/Views/Charts/AllocationPieChart.swift @@ -57,14 +57,14 @@ struct AllocationPieChart: View { VStack(alignment: .leading, spacing: 0) { Text(item.category) .font(.subheadline) - .foregroundColor(.primary) + .foregroundStyle(.primary) let percentage = total > 0 ? NSDecimalNumber(decimal: item.value / total).doubleValue * 100 : 0 Text(String(format: "%.1f%%", percentage)) .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } .opacity(selectedSlice == nil || selectedSlice == item.category ? 1 : 0.5) @@ -77,7 +77,7 @@ struct AllocationPieChart: View { } label: { Image(systemName: "chevron.right.circle.fill") .font(.footnote) - .foregroundColor(.secondary.opacity(0.6)) + .foregroundStyle(.secondary.opacity(0.6)) } .buttonStyle(.plain) } @@ -92,14 +92,14 @@ struct AllocationPieChart: View { } } else { Text("No allocation data available") - .foregroundColor(.secondary) - .frame(height: 200) + .foregroundStyle(.secondary) + .chartFrame(height: 200) .frame(maxWidth: .infinity) } } .padding() .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } } @@ -167,7 +167,7 @@ struct SemicircleAllocation: View { y: center.y + radius * CoreGraphics.sin(angle)) Text("\(Int((frac * 100).rounded()))%") .font(.system(size: lineWidth * 0.42, weight: .bold)) - .foregroundColor(.white) + .foregroundStyle(.white) .position(pos) .opacity(selectedSlice == nil || selectedSlice == seg.category ? 1 : 0.4) } @@ -178,14 +178,14 @@ struct SemicircleAllocation: View { VStack(spacing: 1) { Text(c.label) .font(.caption2) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .lineLimit(1) Text(c.value) .font(.headline) if let sub = c.sub { Text(sub) .font(.caption2) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } .position(x: center.x, y: center.y - lineWidth * 0.25) @@ -243,7 +243,7 @@ struct AllocationTargetsComparisonChart: View { if targetData.allSatisfy({ $0.target == 0 }) { Text("Set allocation targets to compare your portfolio against your plan.") .font(.subheadline) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } else { Chart { ForEach(targetData, id: \.category) { item in @@ -275,7 +275,7 @@ struct AllocationTargetsComparisonChart: View { AxisValueLabel() } } - .frame(height: 220) + .chartFrame(height: 220) ForEach(targetData, id: \.category) { item in let drift = item.actual - item.target @@ -289,20 +289,20 @@ struct AllocationTargetsComparisonChart: View { Spacer() Text("Actual \(String(format: "%.1f%%", item.actual))") .font(.caption2) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) Text("Target \(String(format: "%.0f%%", item.target))") .font(.caption2) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) Text("\(prefix)\(String(format: "%.1f%%", drift))") .font(.caption2.weight(.semibold)) - .foregroundColor(drift >= 0 ? .positiveGreen : .negativeRed) + .foregroundStyle(drift >= 0 ? Color.positiveGreen : Color.negativeRed) } } } } .padding() .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } @@ -351,7 +351,7 @@ struct AllocationListView: View { Text(item.value.compactCurrencyString) .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .frame(width: 70, alignment: .trailing) } @@ -365,12 +365,12 @@ struct AllocationListView: View { Rectangle() .fill(Color.gray.opacity(0.1)) .frame(height: 6) - .cornerRadius(3) + .clipShape(RoundedRectangle(cornerRadius: 3)) Rectangle() .fill(Color(hex: item.color) ?? .gray) .frame(width: geometry.size.width * percentage, height: 6) - .cornerRadius(3) + .clipShape(RoundedRectangle(cornerRadius: 3)) } } .frame(height: 6) @@ -379,7 +379,7 @@ struct AllocationListView: View { } .padding() .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } } diff --git a/PortfolioJournal/Views/Charts/AllocationSimulatorView.swift b/PortfolioJournal/Views/Charts/AllocationSimulatorView.swift index 6c808fc..e99cde5 100644 --- a/PortfolioJournal/Views/Charts/AllocationSimulatorView.swift +++ b/PortfolioJournal/Views/Charts/AllocationSimulatorView.swift @@ -19,7 +19,7 @@ struct AllocationSimulatorView: View { } .padding() .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } @@ -29,13 +29,13 @@ struct AllocationSimulatorView: View { VStack(spacing: 12) { Image(systemName: "slider.horizontal.3") .font(.system(size: 36)) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) Text("No sources available") .font(.subheadline) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } .frame(maxWidth: .infinity) - .frame(height: 300) + .chartFrame(height: 300) } // MARK: - Allocation Card @@ -56,7 +56,7 @@ struct AllocationSimulatorView: View { } .padding(12) .background(Color(.systemGray6)) - .cornerRadius(10) + .clipShape(RoundedRectangle(cornerRadius: 10)) } private var totalAllocationBadge: some View { @@ -67,8 +67,8 @@ struct AllocationSimulatorView: View { .padding(.horizontal, 8) .padding(.vertical, 3) .background(isNear100 ? Color.green.opacity(0.15) : Color.orange.opacity(0.15)) - .foregroundColor(isNear100 ? .green : .orange) - .cornerRadius(8) + .foregroundStyle(isNear100 ? .green : .orange) + .clipShape(RoundedRectangle(cornerRadius: 8)) } private var resetButton: some View { @@ -87,7 +87,7 @@ struct AllocationSimulatorView: View { } label: { Image(systemName: "arrow.counterclockwise") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } .buttonStyle(.plain) } @@ -104,11 +104,11 @@ struct AllocationSimulatorView: View { Spacer() Text(String(format: "%.0f%%", source.simulatedPct)) .font(.caption.weight(.semibold)) - .foregroundColor(.primary) + .foregroundStyle(.primary) .frame(width: 36, alignment: .trailing) Text(String(format: "(%.0f%%)", source.currentPct)) .font(.caption2) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } Slider( @@ -142,8 +142,8 @@ struct AllocationSimulatorView: View { if viewModel.simulatorActualData.isEmpty && viewModel.simulatorData.isEmpty { Text("Not enough data to simulate.") .font(.subheadline) - .foregroundColor(.secondary) - .frame(height: 220) + .foregroundStyle(.secondary) + .chartFrame(height: 220) } else { simulationChart chartLegend @@ -194,7 +194,7 @@ struct AllocationSimulatorView: View { .foregroundStyle(Color.secondary.opacity(0.15)) } } - .frame(height: 220) + .chartFrame(height: 220) .chartDrawingGroup(disabledForExport: chartImageExport) } @@ -224,7 +224,7 @@ struct AllocationSimulatorView: View { } Text(label) .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } diff --git a/PortfolioJournal/Views/Charts/ChartSummaryComponents.swift b/PortfolioJournal/Views/Charts/ChartSummaryComponents.swift index d540cc5..9115f5b 100644 --- a/PortfolioJournal/Views/Charts/ChartSummaryComponents.swift +++ b/PortfolioJournal/Views/Charts/ChartSummaryComponents.swift @@ -24,16 +24,16 @@ struct ChartStatsRow: View { VStack(alignment: .center, spacing: 3) { Text(stats[i].label) .font(.caption2) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) Text(stats[i].value) .font(.subheadline.weight(.semibold)) - .foregroundColor(stats[i].color) + .foregroundStyle(stats[i].color) } .frame(minWidth: 60) .padding(.horizontal, 12) .padding(.vertical, 8) .background(Color(.systemGray6)) - .cornerRadius(8) + .clipShape(RoundedRectangle(cornerRadius: 8)) } } } @@ -84,28 +84,28 @@ struct ChartDataTable: View { Image(systemName: isExpanded ? "chevron.up" : "chevron.down") .font(.caption2) } - .foregroundColor(.appPrimary) + .foregroundStyle(Color.appPrimary) .frame(maxWidth: .infinity) .padding(.vertical, 8) } } } .background(Color(.systemGray6)) - .cornerRadius(8) + .clipShape(RoundedRectangle(cornerRadius: 8)) } private var tableHeader: some View { HStack(spacing: 0) { - Text("Date").font(.caption2.weight(.semibold)).foregroundColor(.secondary) + Text("Date").font(.caption2.weight(.semibold)).foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) - Text(valueHeader).font(.caption2.weight(.semibold)).foregroundColor(.secondary) + Text(valueHeader).font(.caption2.weight(.semibold)).foregroundStyle(.secondary) .frame(width: 72, alignment: .trailing) if let h = deltaPrevHeader { - Text(h).font(.caption2.weight(.semibold)).foregroundColor(.secondary) + Text(h).font(.caption2.weight(.semibold)).foregroundStyle(.secondary) .frame(width: 62, alignment: .trailing) } if let h = deltaFirstHeader { - Text(h).font(.caption2.weight(.semibold)).foregroundColor(.secondary) + Text(h).font(.caption2.weight(.semibold)).foregroundStyle(.secondary) .frame(width: 62, alignment: .trailing) } } @@ -115,18 +115,18 @@ struct ChartDataTable: View { private func tableDataRow(_ row: ChartDataTableRow) -> some View { HStack(spacing: 0) { - Text(row.label).font(.caption2).foregroundColor(.primary) + Text(row.label).font(.caption2).foregroundStyle(.primary) .frame(maxWidth: .infinity, alignment: .leading) Text(row.value).font(.caption2.weight(.medium)) .frame(width: 72, alignment: .trailing) if let dp = row.deltaPrev, deltaPrevHeader != nil { Text(dp).font(.caption2.weight(.medium)) - .foregroundColor(row.isPrevPositive ? .positiveGreen : .negativeRed) + .foregroundStyle(row.isPrevPositive ? Color.positiveGreen : Color.negativeRed) .frame(width: 62, alignment: .trailing) } if let df = row.deltaFirst, deltaFirstHeader != nil { Text(df).font(.caption2.weight(.medium)) - .foregroundColor(row.isFirstPositive ? .positiveGreen : .negativeRed) + .foregroundStyle(row.isFirstPositive ? Color.positiveGreen : Color.negativeRed) .frame(width: 62, alignment: .trailing) } } diff --git a/PortfolioJournal/Views/Charts/ChartValueLabels.swift b/PortfolioJournal/Views/Charts/ChartValueLabels.swift index 3d47046..b5c7e4c 100644 --- a/PortfolioJournal/Views/Charts/ChartValueLabels.swift +++ b/PortfolioJournal/Views/Charts/ChartValueLabels.swift @@ -57,7 +57,7 @@ struct ChartValueBubble: View { var body: some View { Text(text) .font(prominent ? .caption.weight(.bold) : .caption2.weight(.semibold)) - .foregroundColor(prominent ? .white : color) + .foregroundStyle(prominent ? .white : color) .padding(.horizontal, prominent ? 8 : 5) .padding(.vertical, prominent ? 4 : 2) .background( @@ -76,16 +76,16 @@ struct ChartSelectionCard: View { VStack(alignment: .leading, spacing: 3) { Text(title) .font(.caption2.weight(.semibold)) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) ForEach(Array(rows.enumerated()), id: \.offset) { _, row in HStack(spacing: 5) { Circle().fill(row.color).frame(width: 6, height: 6) Text(row.label) .font(.caption2) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) Text(row.value) .font(.caption2.weight(.semibold)) - .foregroundColor(.primary) + .foregroundStyle(.primary) } } } diff --git a/PortfolioJournal/Views/Charts/ChartZoom.swift b/PortfolioJournal/Views/Charts/ChartZoom.swift index 3f1682b..8145ae8 100644 --- a/PortfolioJournal/Views/Charts/ChartZoom.swift +++ b/PortfolioJournal/Views/Charts/ChartZoom.swift @@ -191,10 +191,10 @@ struct ChartRangeBrush: View { HStack(spacing: 5) { Text("\(Self.labelFormatter.string(from: dates[lo])) – \(Self.labelFormatter.string(from: dates[hi]))") .font(.caption.weight(.semibold)) - .foregroundColor(.appPrimary) + .foregroundStyle(Color.appPrimary) Image(systemName: "chevron.up.chevron.down") .font(.system(size: 8, weight: .semibold)) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } .buttonStyle(.plain) @@ -410,7 +410,7 @@ struct ChartRangePickerSheet: View { VStack(spacing: 2) { Text(title) .font(.caption.weight(.semibold)) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) Picker(title, selection: selection) { ForEach(dates.indices, id: \.self) { i in Text(Self.optionFormatter.string(from: dates[i])).tag(i) diff --git a/PortfolioJournal/Views/Charts/ChartsContainerView.swift b/PortfolioJournal/Views/Charts/ChartsContainerView.swift index 08d6192..46542d8 100644 --- a/PortfolioJournal/Views/Charts/ChartsContainerView.swift +++ b/PortfolioJournal/Views/Charts/ChartsContainerView.swift @@ -1,5 +1,6 @@ import SwiftUI import Charts +import CoreData import TipKit struct ChartsContainerView: View { @@ -28,10 +29,10 @@ struct ChartsContainerView: View { // pattern) — replaces the 14-chip horizontal carousel. .toolbarTitleMenu { chartTypePicker } .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { accountFilterMenu } - ToolbarItem(placement: .navigationBarTrailing) { filterMenu } + ToolbarItem(placement: .topBarTrailing) { accountFilterMenu } + ToolbarItem(placement: .topBarTrailing) { filterMenu } if horizontalSizeClass != .regular { - ToolbarItem(placement: .navigationBarTrailing) { shareButton } + ToolbarItem(placement: .topBarTrailing) { shareButton } } } .onAppear { syncState() } @@ -105,11 +106,14 @@ struct ChartsContainerView: View { } } .padding() + // Regular width has a wide canvas next to the sidebar: give + // every plot ~35% more height so the extra area shows data, + // not just a stretched iPhone chart. + .environment(\.chartHeightScale, 1.35) } } .clipped() } - .ignoresSafeArea(edges: .bottom) // Attached here (not on the layout-switching Group): sheets presented from // a view that gets replaced when the size class changes were unreliable in // NavigationSplitView — premium tiles looked like they "did nothing". @@ -144,7 +148,7 @@ struct ChartsContainerView: View { if chartType.isPremium && !viewModel.isPremium { Image(systemName: "lock.fill") .font(.caption2) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } } @@ -465,19 +469,19 @@ struct ChartsContainerView: View { VStack(alignment: .leading, spacing: 4) { Text(label) .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .lineLimit(1) .minimumScaleFactor(0.8) Text(value) .font(.system(.title3, design: .rounded).weight(.semibold)) - .foregroundColor(color) + .foregroundStyle(color) .lineLimit(1) .minimumScaleFactor(0.6) } .frame(maxWidth: .infinity, alignment: .leading) .padding(12) .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.smallCornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius)) .shadow(color: .black.opacity(0.05), radius: 4, y: 1) } @@ -492,7 +496,7 @@ struct ChartsContainerView: View { .font(.headline) Text(viewModel.selectedChartType.description) .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .lineLimit(1) } Spacer() @@ -589,10 +593,10 @@ struct ChartsContainerView: View { .font(.caption) } .font(.footnote.weight(.medium)) - .foregroundColor(.appPrimary) + .foregroundStyle(Color.appPrimary) .padding(12) .background(Color.appPrimary.opacity(0.08)) - .cornerRadius(AppConstants.UI.smallCornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius)) } .buttonStyle(.plain) } @@ -673,7 +677,7 @@ struct ChartsContainerView: View { } .padding(.horizontal, 12) .padding(.vertical, 7) - .foregroundColor(isSelected ? .white : (locked ? .secondary : .primary)) + .foregroundStyle(isSelected ? .white : (locked ? .secondary : .primary)) .background( Capsule().fill(isSelected ? Color.appPrimary : Color(.systemBackground)) ) @@ -706,7 +710,7 @@ struct ChartsContainerView: View { Image(systemName: "xmark.circle.fill") .font(.footnote) } - .foregroundColor(.appPrimary) + .foregroundStyle(Color.appPrimary) .padding(.horizontal, 12) .padding(.vertical, 7) .background(Capsule().fill(Color.appPrimary.opacity(0.12))) @@ -777,7 +781,7 @@ struct ChartsContainerView: View { } label: { Image(systemName: "calendar.badge.clock") .font(.system(size: 14, weight: .medium)) - .foregroundColor(showRangeBrush ? .white : .appPrimary) + .foregroundStyle(showRangeBrush ? .white : Color.appPrimary) .frame(width: 38, height: 30) .background( RoundedRectangle(cornerRadius: 8) @@ -899,7 +903,7 @@ struct ChartsContainerView: View { premiumLockedView } else if viewModel.isLoading { ProgressView() - .frame(height: 300) + .chartFrame(height: 300) } else if !viewModel.hasData { emptyStateView } else { @@ -975,10 +979,10 @@ struct ChartsContainerView: View { .frame(width: 88, height: 88) Image(systemName: viewModel.selectedChartType.icon) .font(.system(size: 34)) - .foregroundColor(.appPrimary) + .foregroundStyle(Color.appPrimary) Image(systemName: "lock.circle.fill") .font(.system(size: 26)) - .foregroundColor(.appWarning) + .foregroundStyle(Color.appWarning) .background(Circle().fill(Color(.systemBackground))) .offset(x: 32, y: 30) } @@ -988,7 +992,7 @@ struct ChartsContainerView: View { Text(viewModel.selectedChartType.description) .font(.subheadline) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .multilineTextAlignment(.center) Button { @@ -1000,14 +1004,14 @@ struct ChartsContainerView: View { .padding(.horizontal, 24) .padding(.vertical, 12) .background(Color.appPrimary) - .foregroundColor(.white) - .cornerRadius(24) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: 24)) } } .padding(32) .frame(maxWidth: .infinity, minHeight: 320) .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } @@ -1015,17 +1019,17 @@ struct ChartsContainerView: View { VStack(spacing: 16) { Image(systemName: "chart.bar.xaxis") .font(.system(size: 48)) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) Text("No Data Available") .font(.headline) Text("Add some investment sources and snapshots to see charts.") .font(.subheadline) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .multilineTextAlignment(.center) } - .frame(height: 300) + .chartFrame(height: 300) } private var accountFilterMenu: some View { @@ -1167,10 +1171,10 @@ struct EvolutionChartView: View { HStack(spacing: 6) { Image(systemName: "lightbulb.fill") .font(.caption2) - .foregroundColor(.appWarning) + .foregroundStyle(Color.appWarning) Text(insight) .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } if !data.isEmpty && chartMode == .total { @@ -1179,7 +1183,7 @@ struct EvolutionChartView: View { } .padding() .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } @@ -1228,7 +1232,7 @@ struct EvolutionChartView: View { } .padding(.horizontal, 10) .padding(.vertical, 5) - .foregroundColor(showContributions ? .appSecondary : .secondary) + .foregroundStyle(showContributions ? Color.appSecondary : .secondary) .background( Capsule().fill(showContributions ? Color.appSecondary.opacity(0.16) : Color(.systemGray6)) ) @@ -1307,7 +1311,7 @@ struct EvolutionChartView: View { if series.count > 1 { Text(String(format: "%+.1f%%", deltaPct)) .font(.caption.weight(.bold)) - .foregroundColor(deltaPct >= 0 ? .positiveGreen : .negativeRed) + .foregroundStyle(deltaPct >= 0 ? Color.positiveGreen : Color.negativeRed) .padding(.horizontal, 7) .padding(.vertical, 3) .background( @@ -1317,7 +1321,7 @@ struct EvolutionChartView: View { } Text(subtitle) .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } Spacer() @@ -1326,7 +1330,7 @@ struct EvolutionChartView: View { showGoalLines.toggle() } label: { Image(systemName: showGoalLines ? "target" : "slash.circle") - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } .disabled(goals.isEmpty) .accessibilityLabel(showGoalLines ? "Hide goals" : "Show goals") @@ -1349,8 +1353,8 @@ struct EvolutionChartView: View { chartView } else { Text("Not enough data") - .foregroundColor(.secondary) - .frame(height: 300) + .foregroundStyle(.secondary) + .chartFrame(height: 300) } } @@ -1414,7 +1418,7 @@ struct EvolutionChartView: View { } } } - .frame(height: 300) + .chartFrame(height: 300) .zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom) // Performance: GPU rendering for smoother scrolling on older devices .chartDrawingGroup(disabledForExport: chartImageExport) @@ -1435,7 +1439,7 @@ struct EvolutionChartView: View { series: .value("Series", "total") ) .foregroundStyle( - LinearGradient(colors: [.appPrimary, .cyan], + LinearGradient(colors: [Color.appPrimary, .cyan], startPoint: .leading, endPoint: .trailing) ) .lineStyle(StrokeStyle(lineWidth: 2.2, lineCap: .round, lineJoin: .round)) @@ -1571,8 +1575,8 @@ struct ContributionsChartView: View { if data.isEmpty { Text("No contributions yet.") - .foregroundColor(.secondary) - .frame(height: 260) + .foregroundStyle(.secondary) + .chartFrame(height: 260) } else { Chart { ForEach(data, id: \.date) { item in @@ -1581,7 +1585,7 @@ struct ContributionsChartView: View { y: .value("Amount", NSDecimalNumber(decimal: item.amount).doubleValue) ) .foregroundStyle(Color.appSecondary) - .cornerRadius(6) + .clipShape(RoundedRectangle(cornerRadius: 6)) } } .chartXAxis { @@ -1599,14 +1603,14 @@ struct ContributionsChartView: View { } } } - .frame(height: 260) + .chartFrame(height: 260) .zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom) ChartStatsRow(stats: contributionStats).padding(.top, 4) } } .padding() .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } @@ -1649,8 +1653,8 @@ struct RollingReturnChartView: View { if data.isEmpty { Text("Not enough data for rolling returns.") - .foregroundColor(.secondary) - .frame(height: 260) + .foregroundStyle(.secondary) + .chartFrame(height: 260) } else { Chart { ForEach(Array(data.enumerated()), id: \.element.date) { pair in @@ -1706,7 +1710,7 @@ struct RollingReturnChartView: View { } } } - .frame(height: 260) + .chartFrame(height: 260) .zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom) ChartStatsRow(stats: rollingStats).padding(.top, 4) ChartDataTable( @@ -1719,7 +1723,7 @@ struct RollingReturnChartView: View { } .padding() .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } @@ -1771,8 +1775,8 @@ struct RiskReturnChartView: View { if data.isEmpty { Text("Not enough data to compare categories.") - .foregroundColor(.secondary) - .frame(height: 260) + .foregroundStyle(.secondary) + .chartFrame(height: 260) } else { Chart { ForEach(data, id: \.category) { item in @@ -1785,7 +1789,7 @@ struct RiskReturnChartView: View { .annotation(position: .top) { Text(item.category) .font(.caption2) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } } @@ -1809,12 +1813,12 @@ struct RiskReturnChartView: View { } } } - .frame(height: 260) + .chartFrame(height: 260) } } .padding() .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } } @@ -1832,8 +1836,8 @@ struct CashflowStackedChartView: View { if data.isEmpty { Text("Not enough data to compare cashflow.") - .foregroundColor(.secondary) - .frame(height: 260) + .foregroundStyle(.secondary) + .chartFrame(height: 260) } else { Chart { ForEach(data, id: \.date) { item in @@ -1871,14 +1875,14 @@ struct CashflowStackedChartView: View { } } } - .frame(height: 260) + .chartFrame(height: 260) .zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom) ChartStatsRow(stats: cashflowStats).padding(.top, 4) } } .padding() .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } @@ -1934,14 +1938,14 @@ struct AllocationEvolutionChart: View { } .padding() .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } private var emptyView: some View { Text("Not enough data to show allocation evolution.") - .foregroundColor(.secondary) - .frame(height: 260) + .foregroundStyle(.secondary) + .chartFrame(height: 260) } /// Categories in stable order (preserving the ViewModel's sort: largest overall first) @@ -1989,7 +1993,7 @@ struct AllocationEvolutionChart: View { } } } - .frame(height: 260) + .chartFrame(height: 260) .chartDrawingGroup(disabledForExport: chartImageExport) } } diff --git a/PortfolioJournal/Views/Charts/ComparisonChartView.swift b/PortfolioJournal/Views/Charts/ComparisonChartView.swift index ea31273..2b31b00 100644 --- a/PortfolioJournal/Views/Charts/ComparisonChartView.swift +++ b/PortfolioJournal/Views/Charts/ComparisonChartView.swift @@ -32,7 +32,7 @@ struct ComparisonChartView: View { } .padding() .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } @@ -66,7 +66,7 @@ struct ComparisonChartView: View { RoundedRectangle(cornerRadius: 16) .stroke(isSelected ? chipColor : Color.clear, lineWidth: 1.5) ) - .cornerRadius(16) + .clipShape(RoundedRectangle(cornerRadius: 16)) } .buttonStyle(.plain) } @@ -92,23 +92,23 @@ struct ComparisonChartView: View { VStack(spacing: 12) { Image(systemName: "chart.xyaxis.line") .font(.system(size: 36)) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) Text("Select 2+ sources to compare") .font(.subheadline) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity) - .frame(height: 260) + .chartFrame(height: 260) } private var noDataView: some View { Text("No data available for selected sources in this period.") .font(.subheadline) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .multilineTextAlignment(.center) .frame(maxWidth: .infinity) - .frame(height: 260) + .chartFrame(height: 260) } /// Multi-series comparison: labelling every point of every series overlaps @@ -186,7 +186,7 @@ struct ComparisonChartView: View { .foregroundStyle(Color.secondary.opacity(0.15)) } } - .frame(height: 260) + .chartFrame(height: 260) .chartDrawingGroup(disabledForExport: chartImageExport) legend @@ -203,7 +203,7 @@ struct ComparisonChartView: View { .frame(width: 20, height: 3) Text(series.name) .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } } diff --git a/PortfolioJournal/Views/Charts/DrawdownChart.swift b/PortfolioJournal/Views/Charts/DrawdownChart.swift index 8b586e9..27f9f8c 100644 --- a/PortfolioJournal/Views/Charts/DrawdownChart.swift +++ b/PortfolioJournal/Views/Charts/DrawdownChart.swift @@ -38,16 +38,16 @@ struct DrawdownChart: View { VStack(alignment: .trailing, spacing: 2) { Text("Max Drawdown") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) Text(String(format: "%.1f%%", maxDrawdown)) .font(.subheadline.weight(.semibold)) - .foregroundColor(.negativeRed) + .foregroundStyle(Color.negativeRed) } } Text("Shows percentage decline from peak values") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) if data.count >= 2 { Chart { @@ -119,7 +119,7 @@ struct DrawdownChart: View { } } .chartYScale(domain: (data.map { $0.drawdown }.min() ?? -50)...0) - .frame(height: 250) + .chartFrame(height: 250) .zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom) // Statistics @@ -152,14 +152,14 @@ struct DrawdownChart: View { .padding(.top, 4) } else { Text("Not enough data for drawdown analysis") - .foregroundColor(.secondary) - .frame(height: 250) + .foregroundStyle(.secondary) + .chartFrame(height: 250) .frame(maxWidth: .infinity) } } .padding() .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } @@ -197,11 +197,11 @@ struct DrawdownStatView: View { VStack(spacing: 4) { Text(title) .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) Text(value) .font(.subheadline.weight(.semibold)) - .foregroundColor(isHighlighted ? .negativeRed : .primary) + .foregroundStyle(isHighlighted ? Color.negativeRed : .primary) } .frame(maxWidth: .infinity) } @@ -247,7 +247,7 @@ struct VolatilityChartView: View { Text("Measures price variability over time") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) if data.count >= 2 { Chart { @@ -293,7 +293,7 @@ struct VolatilityChartView: View { .annotation(position: .top, alignment: .leading) { Text("Avg: \(String(format: "%.1f%%", averageVolatility))") .font(.caption2) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } if let sel = selectedPoint { @@ -328,7 +328,7 @@ struct VolatilityChartView: View { } } } - .frame(height: 250) + .chartFrame(height: 250) .zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom) ChartStatsRow(stats: [ @@ -346,14 +346,14 @@ struct VolatilityChartView: View { ) } else { Text("Not enough data for volatility analysis") - .foregroundColor(.secondary) - .frame(height: 250) + .foregroundStyle(.secondary) + .chartFrame(height: 250) .frame(maxWidth: .infinity) } } .padding() .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } @@ -400,7 +400,7 @@ struct VolatilityLevelView: View { .font(.caption2) Text(range) .font(.caption2) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } } diff --git a/PortfolioJournal/Views/Charts/PerformanceBarChart.swift b/PortfolioJournal/Views/Charts/PerformanceBarChart.swift index 9ab0088..186a0e0 100644 --- a/PortfolioJournal/Views/Charts/PerformanceBarChart.swift +++ b/PortfolioJournal/Views/Charts/PerformanceBarChart.swift @@ -22,7 +22,7 @@ struct PerformanceBarChart: View { Text("Compound Annual Growth Rate (CAGR)") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) if !data.isEmpty { ChartStatsRow(stats: perfStats) @@ -44,14 +44,14 @@ struct PerformanceBarChart: View { .padding(.top, 8) } else { Text("No performance data available") - .foregroundColor(.secondary) - .frame(height: 250) + .foregroundStyle(.secondary) + .chartFrame(height: 250) .frame(maxWidth: .infinity) } } .padding() .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } @@ -62,11 +62,11 @@ struct PerformanceBarChart: View { y: .value("CAGR", item.cagr) ) .foregroundStyle(Color(hex: item.color) ?? .gray) - .cornerRadius(4) + .clipShape(RoundedRectangle(cornerRadius: 4)) .annotation(position: item.cagr >= 0 ? .top : .bottom) { Text(String(format: "%.1f%%", item.cagr)) .font(.caption2) - .foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed) + .foregroundStyle(item.cagr >= 0 ? Color.positiveGreen : Color.negativeRed) } } .chartXAxis { @@ -91,7 +91,7 @@ struct PerformanceBarChart: View { } } } - .frame(height: 250) + .chartFrame(height: 250) } private var horizontalChart: some View { @@ -101,11 +101,11 @@ struct PerformanceBarChart: View { y: .value("Category", item.category) ) .foregroundStyle(Color(hex: item.color) ?? .gray) - .cornerRadius(4) + .clipShape(RoundedRectangle(cornerRadius: 4)) .annotation(position: item.cagr >= 0 ? .trailing : .leading) { Text(String(format: "%.1f%%", item.cagr)) .font(.caption2) - .foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed) + .foregroundStyle(item.cagr >= 0 ? Color.positiveGreen : Color.negativeRed) } } .chartXAxis { @@ -147,12 +147,12 @@ struct PerformanceBarChart: View { Text(String(format: "%.2f%%", item.cagr)) .font(.subheadline.weight(.semibold)) - .foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed) + .foregroundStyle(item.cagr >= 0 ? Color.positiveGreen : Color.negativeRed) if onDrillDown != nil { Image(systemName: "chevron.right") .font(.caption2.weight(.semibold)) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } if let onDrillDown { @@ -213,7 +213,7 @@ struct HorizontalPerformanceChart: View { Text(String(format: "%.2f%%", item.cagr)) .font(.subheadline.weight(.semibold)) - .foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed) + .foregroundStyle(item.cagr >= 0 ? Color.positiveGreen : Color.negativeRed) } GeometryReader { geometry in @@ -224,12 +224,12 @@ struct HorizontalPerformanceChart: View { Rectangle() .fill(Color.gray.opacity(0.1)) .frame(height: 8) - .cornerRadius(4) + .clipShape(RoundedRectangle(cornerRadius: 4)) Rectangle() .fill(item.cagr >= 0 ? Color.positiveGreen : Color.negativeRed) .frame(width: barWidth, height: 8) - .cornerRadius(4) + .clipShape(RoundedRectangle(cornerRadius: 4)) } } .frame(height: 8) @@ -238,7 +238,7 @@ struct HorizontalPerformanceChart: View { } .padding() .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } } diff --git a/PortfolioJournal/Views/Charts/PeriodComparisonChartView.swift b/PortfolioJournal/Views/Charts/PeriodComparisonChartView.swift index 6466343..68ec19d 100644 --- a/PortfolioJournal/Views/Charts/PeriodComparisonChartView.swift +++ b/PortfolioJournal/Views/Charts/PeriodComparisonChartView.swift @@ -28,7 +28,7 @@ struct PeriodComparisonChartView: View { } .padding() .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } @@ -49,7 +49,7 @@ struct PeriodComparisonChartView: View { endPoint: .bottomTrailing ) ) - .cornerRadius(12) + .clipShape(RoundedRectangle(cornerRadius: 12)) .overlay( RoundedRectangle(cornerRadius: 12) .stroke(Color.secondary.opacity(0.1), lineWidth: 1) @@ -64,7 +64,7 @@ struct PeriodComparisonChartView: View { Text(label) .font(.caption.weight(.bold)) - .foregroundColor(color) + .foregroundStyle(color) .frame(width: 60, alignment: .leading) DatePicker( @@ -81,7 +81,7 @@ struct PeriodComparisonChartView: View { Text("→") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) DatePicker( "", @@ -132,28 +132,28 @@ struct PeriodComparisonChartView: View { .frame(width: 6, height: 6) Text(series.label) .font(.caption2.weight(.medium)) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .lineLimit(1) } Text(String(format: "%+.2f%%", finalReturn)) .font(.title2.weight(.bold)) - .foregroundColor(finalReturn >= 0 ? .positiveGreen : .negativeRed) + .foregroundStyle(finalReturn >= 0 ? Color.positiveGreen : Color.negativeRed) HStack(spacing: 10) { HStack(spacing: 3) { Image(systemName: "arrow.up") .font(.caption2.weight(.semibold)) - .foregroundColor(.positiveGreen) + .foregroundStyle(Color.positiveGreen) Text(String(format: "%.1f%%", maxReturn)) .font(.caption2) - .foregroundColor(.positiveGreen) + .foregroundStyle(Color.positiveGreen) } HStack(spacing: 3) { Image(systemName: "arrow.down") .font(.caption2.weight(.semibold)) - .foregroundColor(.negativeRed) + .foregroundStyle(Color.negativeRed) Text(String(format: "%.1f%%", minReturn)) .font(.caption2) - .foregroundColor(.negativeRed) + .foregroundStyle(Color.negativeRed) } } } @@ -175,14 +175,14 @@ struct PeriodComparisonChartView: View { VStack(spacing: 12) { Image(systemName: "calendar.badge.clock") .font(.system(size: 36)) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) Text("No data available for the selected periods.") .font(.subheadline) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity) - .frame(height: 260) + .chartFrame(height: 260) } // MARK: - Flat data for Chart @@ -303,7 +303,7 @@ struct PeriodComparisonChartView: View { .foregroundStyle(Color.secondary.opacity(0.15)) } } - .frame(height: 260) + .chartFrame(height: 260) .chartDrawingGroup(disabledForExport: chartImageExport) .onChange(of: seriesIds) { _, _ in } } @@ -317,7 +317,7 @@ struct PeriodComparisonChartView: View { .frame(width: 20, height: 3) Text(series.label) .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } Spacer() diff --git a/PortfolioJournal/Views/Charts/PredictionChartView.swift b/PortfolioJournal/Views/Charts/PredictionChartView.swift index 6c34d50..6329fed 100644 --- a/PortfolioJournal/Views/Charts/PredictionChartView.swift +++ b/PortfolioJournal/Views/Charts/PredictionChartView.swift @@ -36,10 +36,10 @@ struct PredictionChartView: View { VStack(alignment: .trailing, spacing: 2) { Text("Forecast") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) Text(lastPrediction.formattedValue) .font(.subheadline.weight(.semibold)) - .foregroundColor(.appPrimary) + .foregroundStyle(Color.appPrimary) } } } @@ -47,7 +47,7 @@ struct PredictionChartView: View { if let algorithm = predictions.first?.algorithm { Text("Algorithm: \(algorithm.displayName)") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } if !predictions.isEmpty && historicalData.count >= 2 { @@ -185,7 +185,7 @@ struct PredictionChartView: View { } } } - .frame(height: 280) + .chartFrame(height: 280) // Stats row if let currentValue = historicalData.last?.value, @@ -206,7 +206,7 @@ struct PredictionChartView: View { .frame(width: 20, height: 3) Text("Historical") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } HStack(spacing: 6) { @@ -223,7 +223,7 @@ struct PredictionChartView: View { ) Text("Prediction") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } HStack(spacing: 6) { @@ -232,7 +232,7 @@ struct PredictionChartView: View { .frame(width: 20, height: 10) Text("Confidence") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } HStack(spacing: 6) { @@ -241,7 +241,7 @@ struct PredictionChartView: View { .frame(width: 20, height: 2) Text("Bull") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } HStack(spacing: 6) { @@ -250,7 +250,7 @@ struct PredictionChartView: View { .frame(width: 20, height: 2) Text("Bear") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } @@ -274,7 +274,7 @@ struct PredictionChartView: View { Spacer() Text(lastPrediction.formattedConfidenceRange) .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } if let currentValue = historicalData.last?.value { @@ -293,7 +293,7 @@ struct PredictionChartView: View { Text(String(format: "%+.1f%%", changePercent)) .font(.subheadline.weight(.medium)) } - .foregroundColor(change >= 0 ? .positiveGreen : .negativeRed) + .foregroundStyle(change >= 0 ? Color.positiveGreen : Color.negativeRed) } } } @@ -310,24 +310,24 @@ struct PredictionChartView: View { VStack(spacing: 12) { Image(systemName: "wand.and.stars") .font(.system(size: 40)) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) Text("Not enough data for predictions") .font(.subheadline) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) Text("Add at least 3 snapshots to generate predictions") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .multilineTextAlignment(.center) } - .frame(height: 280) + .chartFrame(height: 280) .frame(maxWidth: .infinity) } } .padding() .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } diff --git a/PortfolioJournal/Views/Charts/YearOverYearChartView.swift b/PortfolioJournal/Views/Charts/YearOverYearChartView.swift index f06e42b..3af7635 100644 --- a/PortfolioJournal/Views/Charts/YearOverYearChartView.swift +++ b/PortfolioJournal/Views/Charts/YearOverYearChartView.swift @@ -30,9 +30,9 @@ struct YearOverYearChartView: View { if allYears.isEmpty { Text(String(localized: "chart_yoy_empty")) .font(.subheadline) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .center) - .frame(height: 200) + .chartFrame(height: 200) } else { yearSelector if !selectedSeries.isEmpty { @@ -44,7 +44,7 @@ struct YearOverYearChartView: View { if hasEstimate { Text(String(localized: "chart_yoy_estimated_note")) .font(.caption2) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } // Detalle debajo (#147) chartBody @@ -57,7 +57,7 @@ struct YearOverYearChartView: View { } .padding() .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } @@ -82,12 +82,12 @@ struct YearOverYearChartView: View { .padding(.horizontal, 12) .padding(.vertical, 6) .background(isSelected ? color.opacity(0.15) : Color.gray.opacity(0.1)) - .foregroundColor(isSelected ? color : .secondary) + .foregroundStyle(isSelected ? color : .secondary) .overlay( RoundedRectangle(cornerRadius: 16) .stroke(isSelected ? color : Color.clear, lineWidth: 1.5) ) - .cornerRadius(16) + .clipShape(RoundedRectangle(cornerRadius: 16)) } .buttonStyle(.plain) } @@ -181,11 +181,11 @@ struct YearOverYearChartView: View { AxisGridLine() } } - .frame(height: 220) + .chartFrame(height: 220) } else { Text("iOS 16+ required for chart") - .foregroundColor(.secondary) - .frame(height: 220) + .foregroundStyle(.secondary) + .chartFrame(height: 220) } } @@ -202,7 +202,7 @@ struct YearOverYearChartView: View { .frame(width: 20, height: 3) Text(String(yearSeries.year)) .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } } @@ -221,16 +221,16 @@ struct YearOverYearChartView: View { VStack(alignment: .center, spacing: 3) { Text(String(yearSeries.year)) .font(.caption2) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) Text(String(format: "%+.1f%%", end.value) + (end.estimated ? "*" : "")) .font(.subheadline.weight(.semibold)) - .foregroundColor(end.value >= 0 ? color : .negativeRed) + .foregroundStyle(end.value >= 0 ? color : Color.negativeRed) } .frame(minWidth: 60) .padding(.horizontal, 12) .padding(.vertical, 8) .background(Color(.systemGray6)) - .cornerRadius(8) + .clipShape(RoundedRectangle(cornerRadius: 8)) } } } diff --git a/PortfolioJournal/Views/Components/AdaptiveLayout.swift b/PortfolioJournal/Views/Components/AdaptiveLayout.swift new file mode 100644 index 0000000..2ac3f15 --- /dev/null +++ b/PortfolioJournal/Views/Components/AdaptiveLayout.swift @@ -0,0 +1,39 @@ +import SwiftUI + +// MARK: - Chart height scaling +// +// Charts declare a base height for compact width. Containers with a wider +// canvas (regular width: iPad, iPhone Duo inner screen, Mac windows) raise the +// scale so plots gain vertical room instead of just stretching horizontally. + +extension EnvironmentValues { + @Entry var chartHeightScale: CGFloat = 1 +} + +private struct ChartFrameModifier: ViewModifier { + @Environment(\.chartHeightScale) private var scale + let base: CGFloat + + func body(content: Content) -> some View { + content.frame(height: base * scale) + } +} + +extension View { + /// Fixed chart height that follows `chartHeightScale` from the environment. + func chartFrame(height: CGFloat) -> some View { + modifier(ChartFrameModifier(base: height)) + } + + /// Floating label background: Liquid Glass on iOS 26+, material fallback. + @ViewBuilder + func floatingPillBackground() -> some View { + if #available(iOS 26.0, *) { + self.glassEffect(.regular, in: .capsule) + } else { + self + .background(.regularMaterial, in: Capsule()) + .shadow(color: .black.opacity(0.12), radius: 6, y: 2) + } + } +} diff --git a/PortfolioJournal/Views/Dashboard/DashboardView.swift b/PortfolioJournal/Views/Dashboard/DashboardView.swift index dec7974..8c9ba9e 100644 --- a/PortfolioJournal/Views/Dashboard/DashboardView.swift +++ b/PortfolioJournal/Views/Dashboard/DashboardView.swift @@ -77,7 +77,7 @@ struct DashboardView: View { } } .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { + ToolbarItem(placement: .topBarTrailing) { Button { QuickUpdateTip().invalidate(reason: .actionPerformed) showingQuickUpdate = true @@ -86,7 +86,7 @@ struct DashboardView: View { } .popoverTip(QuickUpdateTip()) } - ToolbarItem(placement: .navigationBarTrailing) { + ToolbarItem(placement: .topBarTrailing) { Button { balancePrivacy.toggleHidden() } label: { @@ -94,10 +94,10 @@ struct DashboardView: View { } .accessibilityLabel(balancePrivacy.balancesHidden ? Text("Show balances") : Text("Hide balances")) } - ToolbarItem(placement: .navigationBarTrailing) { + ToolbarItem(placement: .topBarTrailing) { accountFilterMenu } - ToolbarItem(placement: .navigationBarLeading) { + ToolbarItem(placement: .topBarLeading) { Button { showingCustomize = true } label: { @@ -143,10 +143,6 @@ struct DashboardView: View { .sheet(isPresented: $showingCustomize) { DashboardCustomizeView(configs: $sectionConfigs) } - .sheet(isPresented: $showingQuickUpdate) { - QuickUpdateView() - .environment(\.managedObjectContext, CoreDataStack.shared.viewContext) - } .sheet(isPresented: $viewModel.showingPaywall) { PaywallView() } @@ -175,6 +171,18 @@ struct DashboardView: View { if !viewModel.hasData { showingAddSource = true } } } + // Quick Update lives in an inspector: a trailing panel in regular width + // (iPad, iPhone Duo inner screen) so the dashboard charts stay visible + // while values are typed; the system presents it as a sheet in compact. + .inspector(isPresented: $showingQuickUpdate) { + QuickUpdateView() + .environment(\.managedObjectContext, CoreDataStack.shared.viewContext) + .environmentObject(iapService) + .environmentObject(accountStore) + .environmentObject(tabSelection) + .environmentObject(balancePrivacy) + .inspectorColumnWidth(min: 340, ideal: 400, max: 480) + } } private var accountFilterMenu: some View { @@ -478,20 +486,20 @@ struct TotalValueCard: View { HStack { Text("Total Portfolio Value") .font(.subheadline.weight(.medium)) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) Spacer() if let onShareTap { Button(action: onShareTap) { Image(systemName: "square.and.arrow.up") .font(.subheadline.weight(.semibold)) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } } Text(totalValue) .font(.system(size: 44, weight: .bold, design: .rounded)) - .foregroundColor(.primary) + .foregroundStyle(.primary) .minimumScaleFactor(0.6) .lineLimit(1) .hiddenBalance(balancesHidden) @@ -503,7 +511,7 @@ struct TotalValueCard: View { Text(changeText) .font(.subheadline.weight(.semibold)) } - .foregroundColor(isPositive ? .positiveGreen : .negativeRed) + .foregroundStyle(isPositive ? Color.positiveGreen : Color.negativeRed) .padding(.horizontal, 10) .padding(.vertical, 5) .background((isPositive ? Color.positiveGreen : Color.negativeRed).opacity(0.12)) @@ -511,7 +519,7 @@ struct TotalValueCard: View { Text(changeLabel) .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } if sparklineData.count >= 2 { @@ -544,10 +552,10 @@ struct TotalValueCard: View { Image(systemName: "lock.fill").font(.caption2) Text("12m forecast").font(.caption2) } - .foregroundColor(.secondary) + .foregroundStyle(.secondary) Text("Unlock") .font(.caption.weight(.semibold)) - .foregroundColor(.appPrimary) + .foregroundStyle(Color.appPrimary) } .frame(maxWidth: .infinity) } @@ -559,17 +567,17 @@ struct TotalValueCard: View { .padding(20) .frame(maxWidth: .infinity, alignment: .leading) .background(Color(.secondarySystemGroupedBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)) } private func heroMetric(label: String, value: String, positive: Bool) -> some View { VStack(spacing: 2) { Text(label) .font(.caption2) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) Text(value) .font(.caption.weight(.semibold)) - .foregroundColor(positive ? .positiveGreen : .negativeRed) + .foregroundStyle(positive ? Color.positiveGreen : Color.negativeRed) } .frame(maxWidth: .infinity) } @@ -760,15 +768,10 @@ struct MonthlyCheckInCard: View { } .padding(isMonthComplete ? 14 : 20) .background(Color(.secondarySystemGroupedBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) - .background( - NavigationLink(isActive: $startDestinationActive) { - MonthlyCheckInView(referenceDate: navigationReferenceDate) - } label: { - EmptyView() - } - .opacity(0) - ) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)) + .navigationDestination(isPresented: $startDestinationActive) { + MonthlyCheckInView(referenceDate: navigationReferenceDate) + } // Calm 2.0 Fase 2: the ritual runs as a guided full-screen flow — one // source per step, reflection, closing summary. .fullScreenCover(isPresented: $showingGuidedFlow) { @@ -782,14 +785,14 @@ struct MonthlyCheckInCard: View { HStack(spacing: 10) { Image(systemName: "checkmark.seal.fill") .font(.title3) - .foregroundColor(.positiveGreen) + .foregroundStyle(Color.positiveGreen) VStack(alignment: .leading, spacing: 2) { Text(String(format: String(localized: "checkin_done_title"), currentMonthLabel)) .font(.subheadline.weight(.semibold)) if let nextDate = nextCheckInDate { Text(String(format: String(localized: "checkin_done_next"), nextDate.mediumDateString)) .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } Spacer() @@ -800,7 +803,7 @@ struct MonthlyCheckInCard: View { Text("\(streak)") .font(.caption.weight(.bold)) } - .foregroundColor(.orange) + .foregroundStyle(.orange) .padding(.horizontal, 10) .padding(.vertical, 5) .background(Color.orange.opacity(0.12)) @@ -810,7 +813,7 @@ struct MonthlyCheckInCard: View { Button(action: onShareSummary) { Image(systemName: "square.and.arrow.up") .font(.caption.weight(.semibold)) - .foregroundColor(.appPrimary) + .foregroundStyle(Color.appPrimary) } .buttonStyle(.plain) .accessibilityLabel(Text("Share monthly summary")) @@ -820,7 +823,7 @@ struct MonthlyCheckInCard: View { } label: { Image(systemName: "chevron.right") .font(.caption.weight(.semibold)) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } .contentShape(Rectangle()) @@ -836,7 +839,7 @@ struct MonthlyCheckInCard: View { .font(.headline) Text(currentMonthLabel) .font(.subheadline) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } Spacer() if streak >= 2 { @@ -844,7 +847,7 @@ struct MonthlyCheckInCard: View { Image(systemName: "flame.fill").font(.caption) Text("\(streak)").font(.caption.weight(.bold)) } - .foregroundColor(.orange) + .foregroundStyle(.orange) .padding(.horizontal, 10) .padding(.vertical, 5) .background(Color.orange.opacity(0.12)) @@ -867,7 +870,7 @@ struct MonthlyCheckInCard: View { ) } label: { Image(systemName: "calendar.badge.plus") - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } .font(.subheadline.weight(.semibold)) } @@ -879,7 +882,7 @@ struct MonthlyCheckInCard: View { .tint(progressBarTint) Text(String(format: String(localized: "checkin_sources_progress"), updatedCount, totalSources)) .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } else { ProgressView(value: checkInProgress) @@ -890,15 +893,15 @@ struct MonthlyCheckInCard: View { HStack(spacing: 6) { Image(systemName: "calendar") .font(.caption) - .foregroundColor(isOverdue ? .red : .secondary) + .foregroundStyle(isOverdue ? .red : .secondary) Text(String(format: String(localized: "checkin_next_due"), nextDate.mediumDateString)) .font(.caption) - .foregroundColor(isOverdue ? .red : .secondary) + .foregroundStyle(isOverdue ? .red : .secondary) Spacer() if !deadlineBadgeText.isEmpty { Text(deadlineBadgeText) .font(.caption2.weight(.semibold)) - .foregroundColor(progressBarTint) + .foregroundStyle(progressBarTint) .padding(.horizontal, 8) .padding(.vertical, 3) .background(progressBarTint.opacity(0.12)) @@ -908,7 +911,7 @@ struct MonthlyCheckInCard: View { .padding(.horizontal, 10) .padding(.vertical, 8) .background(Color(.tertiarySystemFill)) - .cornerRadius(10) + .clipShape(RoundedRectangle(cornerRadius: 10)) } Button { @@ -919,8 +922,8 @@ struct MonthlyCheckInCard: View { .frame(maxWidth: .infinity) .padding(.vertical, 12) .background(Color.appPrimary) - .foregroundColor(.white) - .cornerRadius(AppConstants.UI.cornerRadius) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)) } .accessibilityIdentifier("checkin_start_cta") } @@ -962,11 +965,11 @@ struct MomentumStreaksCard: View { if stats.totalCheckIns > 0 { Text(String(format: NSLocalizedString("on_time_rate", comment: ""), onTimeRateText)) .font(.subheadline.weight(.semibold)) - .foregroundColor(.appSecondary) + .foregroundStyle(Color.appSecondary) } else { Text("Log a check-in to start a streak") .font(.subheadline) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } @@ -1002,7 +1005,7 @@ struct MomentumStreaksCard: View { HStack { Text("On-time score") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) Spacer() Text( String( @@ -1012,7 +1015,7 @@ struct MomentumStreaksCard: View { ) ) .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } if let closest = stats.closestCutoffDays { @@ -1023,7 +1026,7 @@ struct MomentumStreaksCard: View { ) ) .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } if !stats.achievements.isEmpty && !compact { @@ -1053,7 +1056,7 @@ struct MomentumStreaksCard: View { Spacer() Image(systemName: "chevron.right") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } .padding(.vertical, 8) .contentShape(Rectangle()) @@ -1062,7 +1065,7 @@ struct MomentumStreaksCard: View { } .padding() .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) .onAppear(perform: refreshStats) .onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange)) { _ in @@ -1097,14 +1100,14 @@ struct MomentumStreaksCard: View { .lineLimit(1) Text(title) .font(.caption2) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .lineLimit(1) .minimumScaleFactor(0.8) } .frame(maxWidth: .infinity) .padding(.vertical, 10) .background(Color.gray.opacity(0.08)) - .cornerRadius(AppConstants.UI.smallCornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius)) } @ViewBuilder @@ -1112,17 +1115,17 @@ struct MomentumStreaksCard: View { VStack(alignment: .leading, spacing: 4) { Text(title) .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) Text(value) .font(.title3.weight(.semibold)) Text(subtitle) .font(.caption2) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } .padding() .frame(maxWidth: .infinity, alignment: .leading) .background(Color.gray.opacity(0.08)) - .cornerRadius(AppConstants.UI.smallCornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius)) } @ViewBuilder @@ -1130,18 +1133,18 @@ struct MomentumStreaksCard: View { HStack(alignment: .top, spacing: 8) { Image(systemName: achievement.icon) .font(.headline) - .foregroundColor(.appSecondary) + .foregroundStyle(Color.appSecondary) VStack(alignment: .leading, spacing: 2) { Text(achievement.title) .font(.caption.weight(.semibold)) Text(achievement.detail) .font(.caption2) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } .padding(10) .background(Color.appSecondary.opacity(0.12)) - .cornerRadius(AppConstants.UI.smallCornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius)) } private func formattedDaysText(for days: Double?) -> String { @@ -1191,7 +1194,7 @@ struct MonthlySummaryCard: View { VStack(alignment: .leading, spacing: 4) { Text("Contributions") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) Text(summary.formattedContributions) .font(.subheadline.weight(.semibold)) .hiddenBalance() @@ -1202,17 +1205,17 @@ struct MonthlySummaryCard: View { VStack(alignment: .trailing, spacing: 4) { Text("Net Performance") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) Text("\(summary.formattedNetPerformance) (\(summary.formattedNetPerformancePercentage))") .font(.subheadline.weight(.semibold)) - .foregroundColor(summary.netPerformance >= 0 ? .positiveGreen : .negativeRed) + .foregroundStyle(summary.netPerformance >= 0 ? Color.positiveGreen : Color.negativeRed) .hiddenBalance() } } } .padding() .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } } @@ -1233,7 +1236,7 @@ struct DashboardCustomizeView: View { .font(.subheadline.weight(.semibold)) Text(config.isCollapsed ? "Compact" : "Expanded") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } Spacer() @@ -1256,10 +1259,10 @@ struct DashboardCustomizeView: View { } .navigationTitle("Customize Dashboard") .toolbar { - ToolbarItem(placement: .navigationBarLeading) { + ToolbarItem(placement: .topBarLeading) { EditButton() } - ToolbarItem(placement: .navigationBarTrailing) { + ToolbarItem(placement: .topBarTrailing) { Button("Done") { DashboardLayoutStore.save(configs) dismiss() @@ -1286,18 +1289,18 @@ struct CompactCard: View { if subtitleIsBalance { Text(subtitle) .font(.subheadline) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .hiddenBalance() } else { Text(subtitle) .font(.subheadline) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } .padding() .frame(maxWidth: .infinity, alignment: .leading) .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } } @@ -1312,7 +1315,7 @@ struct EvolutionCompactCard: View { if let last = data.last { Text(last.value.compactCurrencyString) .font(.subheadline.weight(.semibold)) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } SparklineView(data: data, color: .appPrimary) .frame(height: 40) @@ -1320,7 +1323,7 @@ struct EvolutionCompactCard: View { .padding() .frame(maxWidth: .infinity, alignment: .leading) .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } } @@ -1367,7 +1370,7 @@ struct PeriodReturnsCard: View { } .padding() .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } } @@ -1381,11 +1384,11 @@ struct ReturnPeriodView: View { VStack(spacing: 4) { Text(period) .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) Text(change) .font(.subheadline.weight(.semibold)) - .foregroundColor(isPositive ? .positiveGreen : .negativeRed) + .foregroundStyle(isPositive ? Color.positiveGreen : Color.negativeRed) .lineLimit(1) .minimumScaleFactor(0.8) } @@ -1404,14 +1407,14 @@ struct EmptyDashboardView: View { VStack(spacing: 20) { Image(systemName: "chart.pie") .font(.system(size: 60)) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) Text("Welcome to Portfolio Journal") .font(.custom("Avenir Next", size: 24).weight(.semibold)) Text("Start by adding your first investment source to track your portfolio.") .font(.subheadline) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .multilineTextAlignment(.center) Button { @@ -1419,11 +1422,11 @@ struct EmptyDashboardView: View { } label: { Label("Add Investment Source", systemImage: "plus") .font(.headline) - .foregroundColor(.white) + .foregroundStyle(.white) .padding() .frame(maxWidth: .infinity) .background(Color.appPrimary) - .cornerRadius(AppConstants.UI.cornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)) } HStack(spacing: 12) { @@ -1435,8 +1438,8 @@ struct EmptyDashboardView: View { .frame(maxWidth: .infinity) .padding() .background(Color.appPrimary.opacity(0.1)) - .foregroundColor(.appPrimary) - .cornerRadius(AppConstants.UI.cornerRadius) + .foregroundStyle(Color.appPrimary) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)) } Button { @@ -1447,8 +1450,8 @@ struct EmptyDashboardView: View { .frame(maxWidth: .infinity) .padding() .background(Color.appSecondary.opacity(0.1)) - .foregroundColor(.appSecondary) - .cornerRadius(AppConstants.UI.cornerRadius) + .foregroundStyle(Color.appSecondary) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)) } } } @@ -1465,24 +1468,24 @@ struct PendingUpdatesAlertBanner: View { var body: some View { HStack(spacing: 10) { Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.white) + .foregroundStyle(.white) Text("\(count) source\(count == 1 ? "" : "s") pending update") .font(.subheadline.weight(.semibold)) - .foregroundColor(.white) + .foregroundStyle(.white) Spacer() Button(action: onDismiss) { Image(systemName: "xmark") .font(.caption.weight(.bold)) - .foregroundColor(.white.opacity(0.8)) + .foregroundStyle(.white.opacity(0.8)) } } .padding(.horizontal, 16) .padding(.vertical, 12) .background(Color.appWarning) - .cornerRadius(AppConstants.UI.cornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)) } } @@ -1496,13 +1499,13 @@ struct PendingUpdatesCard: View { VStack(alignment: .leading, spacing: 12) { HStack { Image(systemName: "bell.badge.fill") - .foregroundColor(.appWarning) + .foregroundStyle(Color.appWarning) Text("Pending Updates") .font(.headline) Spacer() Text("\(sources.count)") .font(.subheadline) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } ForEach(sources.prefix(3), id: \.objectID) { source in @@ -1519,11 +1522,11 @@ struct PendingUpdatesCard: View { Text(source.latestSnapshot?.date.relativeDescription ?? "Never") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) Image(systemName: "chevron.right") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } .buttonStyle(.plain) @@ -1532,12 +1535,12 @@ struct PendingUpdatesCard: View { if sources.count > 3 { Text("+ \(sources.count - 3) more") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } .padding() .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } } @@ -1593,7 +1596,7 @@ struct GoalsSummaryCard: View { Text(category.name) .font(.caption2.weight(.semibold)) } - .foregroundColor(category.color) + .foregroundStyle(category.color) } } HStack(spacing: 4) { @@ -1601,22 +1604,22 @@ struct GoalsSummaryCard: View { .font(.caption.weight(.semibold)) Text("of \(goal.targetDecimal.compactCurrencyString)") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } if let targetDate = goal.targetDate { Text("Target: \(targetDate.mediumDateString)") .font(.caption2.weight(.semibold)) - .foregroundColor(targetUrgency == .critical ? .negativeRed : (targetUrgency == .warning ? .appWarning : .secondary)) + .foregroundStyle(targetUrgency == .critical ? Color.negativeRed : (targetUrgency == .warning ? Color.appWarning : .secondary)) } if let etaText = etaProvider(goal) { Text(etaText) .font(.caption2) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } if let paceStatus { Text(paceStatus.statusText) .font(.caption2.weight(.semibold)) - .foregroundColor(paceStatus.isBehind ? .appWarning : .positiveGreen) + .foregroundStyle(paceStatus.isBehind ? Color.appWarning : Color.positiveGreen) } } Spacer() @@ -1630,14 +1633,14 @@ struct GoalsSummaryCard: View { } label: { Image(systemName: "square.and.arrow.up") .font(.caption) - .foregroundColor(.appPrimary) + .foregroundStyle(Color.appPrimary) } } } } .padding() .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } } @@ -1657,11 +1660,11 @@ struct EmptyGoalsCard: View { } Text("Add a milestone like 1M and track your progress each month.") .font(.subheadline) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } .padding() .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } } @@ -1706,7 +1709,7 @@ struct ContributionsVsReturnsCard: View { Circle().fill(Color.appSecondary).frame(width: 10, height: 10) VStack(alignment: .leading, spacing: 2) { Text(String(localized: "contributions_vs_returns_invested")) - .font(.caption).foregroundColor(.secondary) + .font(.caption).foregroundStyle(.secondary) Text(totalContributions.currencyString) .font(.subheadline.weight(.semibold)) } @@ -1716,18 +1719,18 @@ struct ContributionsVsReturnsCard: View { Circle().fill(isReturnPositive ? Color.positiveGreen : Color.negativeRed).frame(width: 10, height: 10) VStack(alignment: .trailing, spacing: 2) { Text(String(localized: "contributions_vs_returns_returns")) - .font(.caption).foregroundColor(.secondary) + .font(.caption).foregroundStyle(.secondary) let prefix = totalReturns >= 0 ? "+" : "" Text("\(prefix)\(totalReturns.currencyString)") .font(.subheadline.weight(.semibold)) - .foregroundColor(isReturnPositive ? .positiveGreen : .negativeRed) + .foregroundStyle(isReturnPositive ? Color.positiveGreen : Color.negativeRed) } } } } .padding() .background(Color(.systemBackground)) - .cornerRadius(AppConstants.UI.cornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } } diff --git a/PortfolioJournal/Views/Journal/JournalView.swift b/PortfolioJournal/Views/Journal/JournalView.swift index 31ce9a9..4c7f49e 100644 --- a/PortfolioJournal/Views/Journal/JournalView.swift +++ b/PortfolioJournal/Views/Journal/JournalView.swift @@ -10,64 +10,45 @@ struct JournalView: View { @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 { - 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 + 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 } } } - } - - // 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() } - } + .navigationSplitViewStyle(.balanced) } // MARK: - Shared List Content - private func journalListContent(isPad: Bool) -> some View { + private var journalListContent: some View { ScrollViewReader { proxy in ZStack { AppBackground() - List { + 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 { - MomentumStreaksCard(compact: isPad) + // Narrow sidebar column in regular width → compact stat tiles. + MomentumStreaksCard(compact: horizontalSizeClass == .regular) } .listRowInsets(EdgeInsets()) .listRowBackground(Color.clear) @@ -80,12 +61,12 @@ struct JournalView: View { VStack(spacing: 12) { Image(systemName: "book.closed") .font(.system(size: 36)) - .foregroundColor(.appSecondary) + .foregroundStyle(Color.appSecondary) Text(String(localized: "journal_empty_title")) .font(.headline) Text(String(localized: "journal_empty_body")) .font(.subheadline) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .multilineTextAlignment(.center) } .padding(.vertical, 16) @@ -93,34 +74,14 @@ struct JournalView: View { } else { Text("No matching notes.") .font(.subheadline) - .foregroundColor(.secondary) + .foregroundStyle(.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 - ) + monthlyNoteRow(entry) + .tag(entry.date) .id(entry.date) .onAppear { currentVisibleMonth = entry.date } - } else { - NavigationLink { - MonthlyCheckInView(referenceDate: entry.date) - } label: { - monthlyNoteRow(entry) - } - .id(entry.date) - .onAppear { currentVisibleMonth = entry.date } - } } } } @@ -156,7 +117,7 @@ struct JournalView: View { Image(systemName: "book.closed.fill") .font(.system(size: 50, weight: .light)) - .foregroundColor(.white) + .foregroundStyle(.white) } VStack(spacing: 10) { @@ -164,14 +125,15 @@ struct JournalView: View { .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) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .multilineTextAlignment(.center) } Label("Select from the list", systemImage: "arrow.left") .font(.subheadline.weight(.semibold)) - .foregroundColor(.appSecondary) + .foregroundStyle(Color.appSecondary) .padding(.horizontal, 22) .padding(.vertical, 11) .background(Color.appSecondary.opacity(0.1)) @@ -203,7 +165,7 @@ struct JournalView: View { .frame(width: 40, height: 40) Image(systemName: entry.mood?.iconName ?? "book.closed") .font(.system(size: 16)) - .foregroundColor(entry.mood?.tint ?? .secondary) + .foregroundStyle(entry.mood?.tint ?? .secondary) } VStack(alignment: .leading, spacing: 4) { @@ -220,12 +182,12 @@ struct JournalView: View { if let mood = entry.mood { Text(mood.title) .font(.caption.weight(.medium)) - .foregroundColor(mood.tint) + .foregroundStyle(mood.tint) } Text(entry.note.isEmpty ? String(localized: "journal_no_note") : entry.note) .font(.subheadline) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .lineLimit(2) } } @@ -237,7 +199,7 @@ struct JournalView: View { return HStack(spacing: 4) { ForEach(1...5, id: \.self) { index in Image(systemName: index <= value ? "star.fill" : "star") - .foregroundColor(index <= value ? .yellow : .secondary) + .foregroundStyle(index <= value ? .yellow : .secondary) } } .accessibilityLabel( @@ -251,7 +213,7 @@ struct JournalView: View { .padding(.horizontal, 10) .padding(.vertical, 6) .background(Color.gray.opacity(0.15)) - .foregroundColor(.primary) + .foregroundStyle(.primary) .clipShape(Capsule()) } @@ -269,9 +231,7 @@ struct JournalView: View { .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) + .floatingPillBackground() .offset(x: -16) } @@ -280,9 +240,7 @@ struct JournalView: View { .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) + .floatingPillBackground() .offset(x: -16, y: scrubberOffset - height / 2) } @@ -339,13 +297,13 @@ struct MonthlyNoteEditorView: View { .frame(minHeight: 200) .padding(8) .background(Color.gray.opacity(0.08)) - .cornerRadius(12) + .clipShape(RoundedRectangle(cornerRadius: 12)) } .padding() .navigationTitle("Monthly Note") .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { + ToolbarItem(placement: .topBarTrailing) { Button("Save") { MonthlyCheckInStore.setNote(note, for: date) dismiss() diff --git a/PortfolioJournal/Views/Sources/SourceListView.swift b/PortfolioJournal/Views/Sources/SourceListView.swift index 38b10c9..e4ac2ba 100644 --- a/PortfolioJournal/Views/Sources/SourceListView.swift +++ b/PortfolioJournal/Views/Sources/SourceListView.swift @@ -7,70 +7,21 @@ struct SourceListView: View { @Environment(\.managedObjectContext) private var context @StateObject private var viewModel: SourceListViewModel @State private var sourceToDelete: InvestmentSource? - @State private var navigationPath = NavigationPath() @State private var showingSearch = false @State private var selectedSourceID: NSManagedObjectID? - @Environment(\.horizontalSizeClass) private var horizontalSizeClass init(iapService: IAPService) { _viewModel = StateObject(wrappedValue: SourceListViewModel(iapService: iapService)) } + /// Sidebar/detail split: two columns in regular width (iPad, iPhone Duo + /// inner screen), a single collapsing stack in compact width. Selection is + /// the only navigation state, so it survives the size-class change when the + /// Duo opens or closes. + @State private var columnVisibility: NavigationSplitViewVisibility = .all + var body: some View { - if horizontalSizeClass == .regular { - iPadSourcesLayout - } else { - iPhoneSourcesLayout - } - } - - // MARK: - iPad Layout (master-detail) - - private var iPadSourcesLayout: some View { - HStack(spacing: 0) { - // Left: source list panel - NavigationStack { - ZStack { - AppBackground() - if viewModel.isEmpty { emptyStateView } else { sourcesList } - } - .navigationTitle("Sources") - .toolbar { sourcesToolbar } - .sheet(isPresented: $viewModel.showingAddSource) { AddSourceView() } - .sheet(isPresented: $viewModel.showingPaywall) { PaywallView() } - .onAppear { syncAccountState() } - .onReceive(accountStore.$selectedAccount) { viewModel.selectedAccount = $0 } - .onReceive(accountStore.$showAllAccounts) { viewModel.showAllAccounts = $0 } - .confirmationDialog( - "Delete Source", - isPresented: Binding(get: { sourceToDelete != nil }, set: { if !$0 { sourceToDelete = nil } }), - titleVisibility: .visible - ) { deleteSourceDialogButtons } message: { deleteSourceDialogMessage } - } - .frame(width: 340) - - Divider() - - // Right: detail panel - NavigationStack { - Group { - if let id = selectedSourceID, - let source = try? context.existingObject(with: id) as? InvestmentSource, - !source.isDeleted { - SourceDetailView(source: source, iapService: iapService) - .id(id) - } else { - noSourceSelectedView - } - } - } - } - } - - // MARK: - iPhone Layout (push navigation) - - private var iPhoneSourcesLayout: some View { - NavigationStack(path: $navigationPath) { + NavigationSplitView(columnVisibility: $columnVisibility) { ZStack { AppBackground() if viewModel.isEmpty { emptyStateView } else { sourcesList } @@ -82,19 +33,32 @@ struct SourceListView: View { .onAppear { syncAccountState() } .onReceive(accountStore.$selectedAccount) { viewModel.selectedAccount = $0 } .onReceive(accountStore.$showAllAccounts) { viewModel.showAllAccounts = $0 } - .navigationDestination(for: NSManagedObjectID.self) { objectID in - if let source = try? context.existingObject(with: objectID) as? InvestmentSource, - !source.isDeleted { - SourceDetailView(source: source, iapService: iapService) - } else { - MissingSourceView() - } - } .confirmationDialog( "Delete Source", isPresented: Binding(get: { sourceToDelete != nil }, set: { if !$0 { sourceToDelete = nil } }), titleVisibility: .visible ) { deleteSourceDialogButtons } message: { deleteSourceDialogMessage } + .navigationSplitViewColumnWidth(min: 300, ideal: 340, max: 420) + } detail: { + NavigationStack { + detailContent + } + } + .navigationSplitViewStyle(.balanced) + } + + @ViewBuilder + private var detailContent: some View { + if let id = selectedSourceID { + if let source = try? context.existingObject(with: id) as? InvestmentSource, + !source.isDeleted { + SourceDetailView(source: source, iapService: iapService) + .id(id) + } else { + MissingSourceView() + } + } else { + noSourceSelectedView } } @@ -107,21 +71,21 @@ struct SourceListView: View { @ToolbarContentBuilder private var sourcesToolbar: some ToolbarContent { - ToolbarItem(placement: .navigationBarTrailing) { + ToolbarItem(placement: .topBarTrailing) { Button { viewModel.addSourceTapped() } label: { Image(systemName: "plus") } } - ToolbarItem(placement: .navigationBarTrailing) { + ToolbarItem(placement: .topBarTrailing) { Button { withAnimation(.easeInOut(duration: 0.2)) { showingSearch.toggle() } if !showingSearch { viewModel.searchText = "" } } label: { Image(systemName: showingSearch ? "xmark.circle.fill" : "magnifyingglass") - .foregroundColor(showingSearch ? .secondary : .primary) + .foregroundStyle(showingSearch ? .secondary : .primary) } } - ToolbarItem(placement: .navigationBarLeading) { + ToolbarItem(placement: .topBarLeading) { accountFilterMenu } } @@ -163,7 +127,7 @@ struct SourceListView: View { Image(systemName: "chart.line.uptrend.xyaxis") .font(.system(size: 50, weight: .light)) - .foregroundColor(.white) + .foregroundStyle(.white) } VStack(spacing: 10) { @@ -171,15 +135,16 @@ struct SourceListView: View { .font(.title2.weight(.semibold)) Text("Pick an investment source from the\npanel on the left to see its details.") + .frame(maxWidth: 360) .font(.subheadline) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .multilineTextAlignment(.center) } // Call-to-action pill Label("Select from the list", systemImage: "arrow.left") .font(.subheadline.weight(.semibold)) - .foregroundColor(.appPrimary) + .foregroundStyle(Color.appPrimary) .padding(.horizontal, 22) .padding(.vertical, 11) .background(Color.appPrimary.opacity(0.1)) @@ -194,7 +159,7 @@ struct SourceListView: View { // MARK: - Sources List private var sourcesList: some View { - List { + List(selection: $selectedSourceID) { // Filter Bar Section { filterBarContent @@ -210,7 +175,7 @@ struct SourceListView: View { VStack(alignment: .leading) { Text("Total Value") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) Text(viewModel.formattedTotalValue) .font(.title2.weight(.bold)) .hiddenBalance() @@ -221,7 +186,7 @@ struct SourceListView: View { VStack(alignment: .trailing) { Text("Sources") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) Text("\(viewModel.sources.count)") .font(.title2.weight(.bold)) } @@ -235,7 +200,7 @@ struct SourceListView: View { Section { HStack { Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.appWarning) + .foregroundStyle(Color.appWarning) Text("Source limit reached. Upgrade to Premium for unlimited sources.") .font(.subheadline) @@ -246,7 +211,7 @@ struct SourceListView: View { viewModel.showingPaywall = true } .font(.subheadline.weight(.semibold)) - .foregroundColor(.appPrimary) + .foregroundStyle(Color.appPrimary) } } } @@ -255,19 +220,7 @@ struct SourceListView: View { Section { ForEach(viewModel.sources, id: \.objectID) { source in SourceRowView(source: source) - .contentShape(Rectangle()) - .background( - horizontalSizeClass == .regular && selectedSourceID == source.objectID - ? Color.appPrimary.opacity(0.08) - : Color.clear - ) - .onTapGesture { - if horizontalSizeClass == .regular { - selectedSourceID = source.objectID - } else { - navigationPath.append(source.objectID) - } - } + .tag(source.objectID) .swipeActions(edge: .trailing, allowsFullSwipe: false) { Button(role: .destructive) { sourceToDelete = source @@ -302,14 +255,14 @@ struct SourceListView: View { VStack(spacing: 20) { Image(systemName: "tray") .font(.system(size: 60)) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) Text("No Investment Sources") .font(.custom("Avenir Next", size: 24).weight(.semibold)) Text("Add your first investment source to start tracking your portfolio.") .font(.subheadline) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, 40) @@ -318,11 +271,11 @@ struct SourceListView: View { } label: { Label("Add Source", systemImage: "plus") .font(.headline) - .foregroundColor(.white) + .foregroundStyle(.white) .padding() .frame(maxWidth: 200) .background(Color.appPrimary) - .cornerRadius(AppConstants.UI.cornerRadius) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)) } } } @@ -358,7 +311,7 @@ struct SourceListView: View { if showingSearch { HStack(spacing: 8) { Image(systemName: "magnifyingglass") - .foregroundColor(.secondary) + .foregroundStyle(.secondary) TextField("Search sources", text: $viewModel.searchText) .textFieldStyle(.plain) @@ -369,14 +322,14 @@ struct SourceListView: View { viewModel.searchText = "" } label: { Image(systemName: "xmark.circle.fill") - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } } .padding(.horizontal, 12) .padding(.vertical, 9) .background(Color(.systemGray6)) - .cornerRadius(10) + .clipShape(RoundedRectangle(cornerRadius: 10)) .padding(.horizontal, 16) .padding(.bottom, 10) .transition(.move(edge: .top).combined(with: .opacity)) @@ -452,8 +405,8 @@ private struct CategoryChip: View { .padding(.horizontal, 12) .padding(.vertical, 6) .background(isSelected ? Color.appPrimary : Color(.systemGray5)) - .foregroundColor(isSelected ? .white : .primary) - .cornerRadius(20) + .foregroundStyle(isSelected ? .white : .primary) + .clipShape(RoundedRectangle(cornerRadius: 20)) } .buttonStyle(.plain) } @@ -464,12 +417,12 @@ private struct MissingSourceView: View { VStack(spacing: 12) { Image(systemName: "exclamationmark.triangle") .font(.system(size: 42)) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) Text("Source not available") .font(.headline) Text("This source was deleted.") .font(.subheadline) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } .padding() } @@ -490,7 +443,7 @@ struct SourceRowView: View { Image(systemName: source.category?.icon ?? "questionmark") .font(.system(size: 18)) - .foregroundColor(source.category?.color ?? .gray) + .foregroundStyle(source.category?.color ?? .gray) } // Source info @@ -501,12 +454,12 @@ struct SourceRowView: View { HStack(spacing: 8) { Text(source.category?.name ?? "Uncategorized") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) if source.needsUpdate { Label("Needs update", systemImage: "clock.fill") .font(.caption2.weight(.semibold)) - .foregroundColor(.appWarning) + .foregroundStyle(Color.appWarning) .padding(.horizontal, 8) .padding(.vertical, 3) .background(Color.appWarning.opacity(0.12)) @@ -529,7 +482,7 @@ struct SourceRowView: View { Text(String(format: "%.1f%%", NSDecimalNumber(decimal: source.totalReturn).doubleValue)) .font(.caption) } - .foregroundColor(source.totalReturn >= 0 ? .positiveGreen : .negativeRed) + .foregroundStyle(source.totalReturn >= 0 ? Color.positiveGreen : Color.negativeRed) } } .padding(.vertical, 4)