diff --git a/PortfolioJournal.xcodeproj/project.pbxproj b/PortfolioJournal.xcodeproj/project.pbxproj index 386b171..4b23084 100644 --- a/PortfolioJournal.xcodeproj/project.pbxproj +++ b/PortfolioJournal.xcodeproj/project.pbxproj @@ -541,7 +541,7 @@ CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements; ENABLE_USER_SCRIPT_SANDBOXING = NO; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 61; + CURRENT_PROJECT_VERSION = 62; DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets; DEVELOPMENT_TEAM = 2825Q76T7H; ENABLE_PREVIEWS = YES; @@ -582,7 +582,7 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; PROVISIONING_PROFILE_SPECIFIER = "porfoliojournal"; - CURRENT_PROJECT_VERSION = 61; + CURRENT_PROJECT_VERSION = 62; DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets; DEVELOPMENT_TEAM = 2825Q76T7H; ENABLE_PREVIEWS = YES; @@ -739,7 +739,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 61; + CURRENT_PROJECT_VERSION = 62; DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = NO; @@ -774,7 +774,7 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; PROVISIONING_PROFILE_SPECIFIER = "Portfolio Journalwidget"; - CURRENT_PROJECT_VERSION = 61; + CURRENT_PROJECT_VERSION = 62; DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = NO; @@ -805,7 +805,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 61; + CURRENT_PROJECT_VERSION = 62; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; @@ -829,7 +829,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 61; + CURRENT_PROJECT_VERSION = 62; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; @@ -852,7 +852,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 61; + CURRENT_PROJECT_VERSION = 62; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; @@ -875,7 +875,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 61; + CURRENT_PROJECT_VERSION = 62; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; @@ -899,7 +899,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 61; + CURRENT_PROJECT_VERSION = 62; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = PortfolioJournalQuickUpdate/Info.plist; @@ -927,7 +927,7 @@ CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 61; + CURRENT_PROJECT_VERSION = 62; DEVELOPMENT_TEAM = 2825Q76T7H; PROVISIONING_PROFILE_SPECIFIER = "PortfolioJournal QuickUpdate AppStore"; GENERATE_INFOPLIST_FILE = NO; diff --git a/PortfolioJournal/Views/Dashboard/DashboardView.swift b/PortfolioJournal/Views/Dashboard/DashboardView.swift index 6e69eae..043405e 100644 --- a/PortfolioJournal/Views/Dashboard/DashboardView.swift +++ b/PortfolioJournal/Views/Dashboard/DashboardView.swift @@ -306,7 +306,10 @@ struct DashboardView: View { EvolutionChartCard( data: viewModel.evolutionData, categoryData: viewModel.categoryEvolutionData, - goals: goalsViewModel.goals + goals: goalsViewModel.goals, + allocation: viewModel.categoryMetrics.map { + (category: $0.categoryName, value: $0.totalValue, color: $0.colorHex) + } ) } case .categoryBreakdown: diff --git a/PortfolioJournal/Views/Dashboard/EvolutionChart.swift b/PortfolioJournal/Views/Dashboard/EvolutionChart.swift index 291f6a9..726e45d 100644 --- a/PortfolioJournal/Views/Dashboard/EvolutionChart.swift +++ b/PortfolioJournal/Views/Dashboard/EvolutionChart.swift @@ -1,15 +1,42 @@ import SwiftUI import Charts +/// Swipeable pages for the Home evolution card. +enum DashboardChartPage: Hashable { + case evolution, allocation + + var title: String { + switch self { + case .evolution: return String(localized: "Portfolio Evolution") + case .allocation: return String(localized: "Asset Allocation") + } + } +} + struct EvolutionChartCard: View { let data: [(date: Date, value: Decimal)] let categoryData: [CategoryEvolutionPoint] let goals: [Goal] + /// Current allocation per category (name, value, colorHex) for the swipeable + /// Allocation page. Empty → the Allocation page is skipped. + var allocation: [(category: String, value: Decimal, color: String)] = [] @State private var selectedDataPoint: (date: Date, value: Decimal)? @State private var chartMode: ChartMode = .total @State private var showGoalLines = true @State private var chartWidth: CGFloat = 300 + // Feedback #4: swipe the Home card between a curated set of glanceable charts. + @State private var page: DashboardChartPage = .evolution + @State private var pageInsertionEdge: Edge = .trailing + @State private var allocationSelection: String? + + /// Pages available given the data on hand. "By Category" already lives as a + /// toggle inside the Evolution page, so it isn't a separate swipe page. + private var pages: [DashboardChartPage] { + var p: [DashboardChartPage] = [.evolution] + if !allocation.isEmpty { p.append(.allocation) } + return p + } enum ChartMode: String, CaseIterable, Identifiable { case total = "Total" @@ -45,8 +72,14 @@ struct EvolutionChartCard: View { var body: some View { VStack(alignment: .leading, spacing: 12) { headerView - modePicker - chartSection + pageContent + .id(page) + .transition(.asymmetric( + insertion: .move(edge: pageInsertionEdge).combined(with: .opacity), + removal: .opacity + )) + .gesture(pageSwipeGesture) + if pages.count > 1 { pageIndicator } } .padding() .background(Color(.systemBackground)) @@ -54,29 +87,95 @@ struct EvolutionChartCard: View { .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } + @ViewBuilder + private var pageContent: some View { + switch page { + case .evolution: + modePicker + chartSection + case .allocation: + SemicircleAllocation( + data: allocation, + total: allocation.reduce(Decimal.zero) { $0 + $1.value }, + selectedSlice: $allocationSelection + ) + .frame(height: 150) + .padding(.vertical, 8) + allocationLegend + } + } + private var headerView: some View { HStack { - Text("Portfolio Evolution") + Text(page.title) .font(.headline) Spacer() - Button { - showGoalLines.toggle() - } label: { - Image(systemName: showGoalLines ? "target" : "slash.circle") - .foregroundColor(.secondary) - } - .accessibilityLabel(showGoalLines ? "Hide goals" : "Show goals") - - if let selected = selectedDataPoint, chartMode == .total { - VStack(alignment: .trailing) { - Text(selected.value.compactCurrencyString) - .font(.subheadline.weight(.semibold)) - Text(selected.date.monthYearString) - .font(.caption) + if page == .evolution { + Button { + showGoalLines.toggle() + } label: { + Image(systemName: showGoalLines ? "target" : "slash.circle") .foregroundColor(.secondary) } + .accessibilityLabel(showGoalLines ? "Hide goals" : "Show goals") + + if let selected = selectedDataPoint, chartMode == .total { + VStack(alignment: .trailing) { + Text(selected.value.compactCurrencyString) + .font(.subheadline.weight(.semibold)) + Text(selected.date.monthYearString) + .font(.caption) + .foregroundColor(.secondary) + } + } + } + } + } + + // MARK: - Swipe between pages + + private var pageSwipeGesture: some Gesture { + DragGesture(minimumDistance: 30) + .onEnded { value in + guard abs(value.translation.width) > abs(value.translation.height) * 1.5, + let idx = pages.firstIndex(of: page) else { return } + if value.translation.width < 0, idx < pages.count - 1 { + pageInsertionEdge = .trailing + withAnimation(.snappy) { page = pages[idx + 1] } + } else if value.translation.width > 0, idx > 0 { + pageInsertionEdge = .leading + withAnimation(.snappy) { page = pages[idx - 1] } + } + } + } + + private var pageIndicator: some View { + let current = pages.firstIndex(of: page) ?? 0 + return HStack(spacing: 5) { + ForEach(pages.indices, id: \.self) { i in + Circle() + .fill(i == current ? Color.appPrimary : Color.secondary.opacity(0.25)) + .frame(width: i == current ? 7 : 5, height: i == current ? 7 : 5) + } + } + .frame(maxWidth: .infinity) + .animation(.snappy, value: current) + } + + private var allocationLegend: some View { + VStack(spacing: 6) { + ForEach(allocation.prefix(4), id: \.category) { item in + let total = allocation.reduce(Decimal.zero) { $0 + $1.value } + let pct = total > 0 ? NSDecimalNumber(decimal: item.value / total).doubleValue * 100 : 0 + HStack(spacing: 8) { + Circle().fill(Color(hex: item.color) ?? .gray).frame(width: 9, height: 9) + Text(item.category).font(.caption) + Spacer() + Text(String(format: "%.1f%%", pct)).font(.caption.weight(.medium)) + Text(item.value.compactCurrencyString).font(.caption).foregroundColor(.secondary) + } } } }