Files
InvestmentTrackerApp/PortfolioJournal/Views/Dashboard/DashboardView.swift
T
alexandrev-tibco 86c95a9e73 Feedback TestFlight build 58 (build 59): labels, needs-update, period selector, Home masonry
De 7 comentarios de TestFlight:
- Labels de línea ilegibles (puntos 5+7): ChartLabels.showsLabel thin-out
  (máx 5 iPhone / 8 iPad, siempre primero y último). Charts multi-serie
  (Compare, Period, Year vs Year) etiquetan solo el ENDPOINT de cada serie.
  Prediction etiqueta solo el forecast final. Aplicado a Evolution, Rolling,
  Drawdown, Volatility, Compare, Period, YoY, Prediction.
- 'Needs update' en meses pasados (punto 2): MonthlyCheckInView.snapshotForViewedMonth
  — el estado es por el MES QUE SE VE (effective month de referenceDate), no
  contra la última completion global. Diff usa el snapshot de ese mes.
- Selector de periodo Performance (punto 6): slider 1..60 → picker segmentado
  consistente con el resto, opciones capadas a los meses de datos disponibles
  (availableHistoryMonths). Ya no deja pedir ventanas sin datos.
- Home iPad/Mac (feedback previo): masonry con nº de columnas según ancho
  (2 iPad / 3 Mac ancho >=1250), reparto por peso, propio ScrollView.

Pendiente de este lote: Goals no sincronizan (verificado modelo Goal CloudKit-OK
→ es el subset del partial-failure, no filtro ni esquema; necesita códigos
internos del error), paste/OCR por campo (punto 3) y swipe entre charts (punto 4).

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

1595 lines
61 KiB
Swift

import SwiftUI
import Charts
import CoreData
import TipKit
struct DashboardView: View {
@EnvironmentObject var iapService: IAPService
@EnvironmentObject var accountStore: AccountStore
@StateObject private var viewModel: DashboardViewModel
@StateObject private var goalsViewModel = GoalsViewModel()
@State private var showingImport = false
@State private var showingAddSource = false
@State private var showingCustomize = false
@State private var sectionConfigs = DashboardLayoutStore.load()
@AppStorage("showForecast") private var showForecast = true
@State private var showingQuickUpdate = false
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
init() {
_viewModel = StateObject(wrappedValue: DashboardViewModel())
}
var body: some View {
NavigationStack {
ZStack {
AppBackground()
// Calm 2.0: three zones state (hero), action (check-in),
// context (everything else). The old top clutter (streak badge,
// pending banner, insight chips) folded into them.
if viewModel.hasData {
if horizontalSizeClass == .regular {
// iPad/Mac provides its own GeometryReader + ScrollView so
// the masonry can size columns to the window width.
iPadDashboardLayout
} else {
ScrollView {
VStack(spacing: 16) {
ForEach(visibleSections) { config in
sectionView(for: config)
if config.id == DashboardSection.monthlyCheckIn.id,
!viewModel.insights.isEmpty {
InsightsRow(insights: viewModel.insights)
}
}
}
.padding()
}
}
} else {
ScrollView {
EmptyDashboardView(
onAddSource: { showingAddSource = true },
onImport: { showingImport = true },
onLoadSample: { SampleDataService.shared.seedSampleData() }
)
.padding()
}
}
}
.navigationTitle("Home")
.refreshable {
viewModel.refreshData()
}
.overlay {
if viewModel.isLoading && !viewModel.hasData {
ProgressView()
}
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
QuickUpdateTip().invalidate(reason: .actionPerformed)
showingQuickUpdate = true
} label: {
Image(systemName: "plus.circle.fill")
}
.popoverTip(QuickUpdateTip())
}
ToolbarItem(placement: .navigationBarTrailing) {
accountFilterMenu
}
ToolbarItem(placement: .navigationBarLeading) {
Button {
showingCustomize = true
} label: {
Image(systemName: "slider.horizontal.3")
}
}
}
.onAppear {
viewModel.selectedAccount = accountStore.selectedAccount
viewModel.showAllAccounts = accountStore.showAllAccounts
viewModel.refreshData()
goalsViewModel.selectedAccount = accountStore.selectedAccount
goalsViewModel.showAllAccounts = accountStore.showAllAccounts
goalsViewModel.refresh()
}
// Performance: Combine account selection changes into a single handler
.onChange(of: accountStore.selectedAccount) { _, newAccount in
viewModel.selectedAccount = newAccount
viewModel.refreshData()
goalsViewModel.selectedAccount = newAccount
goalsViewModel.refresh()
}
.onChange(of: accountStore.showAllAccounts) { _, showAll in
viewModel.showAllAccounts = showAll
viewModel.refreshData()
goalsViewModel.showAllAccounts = showAll
goalsViewModel.refresh()
}
.sheet(isPresented: $showingImport) {
ImportDataView()
}
.sheet(isPresented: $showingAddSource) {
AddSourceView()
}
.sheet(isPresented: $showingCustomize) {
DashboardCustomizeView(configs: $sectionConfigs)
}
.sheet(isPresented: $showingQuickUpdate) {
QuickUpdateView()
.environment(\.managedObjectContext, CoreDataStack.shared.viewContext)
}
.sheet(isPresented: $viewModel.showingPaywall) {
PaywallView()
}
.onReceive(NotificationCenter.default.publisher(for: .openQuickUpdate)) { _ in
showingQuickUpdate = true
}
.onReceive(NotificationCenter.default.publisher(for: .openAddFirstSource)) { _ in
if !viewModel.hasData { showingAddSource = true }
}
}
}
private var accountFilterMenu: some View {
Menu {
Button {
accountStore.selectAllAccounts()
} label: {
HStack {
Text("All Accounts")
if accountStore.showAllAccounts {
Image(systemName: "checkmark")
}
}
}
Divider()
ForEach(availableAccounts, id: \.objectID) { account in
Button {
accountStore.selectAccount(account)
} label: {
HStack {
Text(account.name)
if accountStore.selectedAccount?.safeId == account.safeId && !accountStore.showAllAccounts {
Image(systemName: "checkmark")
}
}
}
}
} label: {
Image(systemName: "person.2.circle")
}
}
private var availableAccounts: [Account] {
accountStore.accounts.filter { $0.safeId != nil }
}
private var visibleSections: [DashboardSectionConfig] {
sectionConfigs.filter { $0.isVisible }
}
// MARK: - iPad Layout Helpers
/// Full-width "hero" zones that lead the dashboard, in order.
private var iPadLeadSections: [DashboardSectionConfig] {
let leadIds = [DashboardSection.totalValue.id, DashboardSection.monthlyCheckIn.id]
return leadIds.compactMap { id in visibleSections.first { $0.id == id } }
}
/// Rough relative height per section drives greedy column balancing so the
/// masonry columns end up similar heights instead of one long, one short.
private func sectionWeight(_ id: String) -> Int {
switch DashboardSection(rawValue: id) {
case .momentumStreaks, .evolution: return 3
case .categoryBreakdown, .goals: return 2
default: return 1
}
}
/// Distributes the context cards into `count` masonry columns, greedily
/// placing each into the currently-shortest column (by accumulated weight).
private func iPadContextColumns(count: Int) -> [[DashboardSectionConfig]] {
let leadIds = Set([DashboardSection.totalValue.id, DashboardSection.monthlyCheckIn.id])
let rest = visibleSections.filter { !leadIds.contains($0.id) }
var columns = Array(repeating: [DashboardSectionConfig](), count: count)
var weights = Array(repeating: 0, count: count)
for cfg in rest {
let target = weights.enumerated().min(by: { $0.element < $1.element })?.offset ?? 0
columns[target].append(cfg)
weights[target] += sectionWeight(cfg.id)
}
return columns
}
@ViewBuilder
private var iPadDashboardLayout: some View {
GeometryReader { geo in
// Scale column count with available width so a wide Mac window fills
// (3 columns) while an iPad stays at 2. Lead cards span full width.
let columnCount = geo.size.width >= 1250 ? 3 : 2
let columns = iPadContextColumns(count: columnCount)
ScrollView {
VStack(spacing: 16) {
ForEach(iPadLeadSections) { config in
sectionView(for: config)
if config.id == DashboardSection.monthlyCheckIn.id, !viewModel.insights.isEmpty {
InsightsRow(insights: viewModel.insights)
}
}
HStack(alignment: .top, spacing: 16) {
ForEach(columns.indices, id: \.self) { i in
VStack(spacing: 16) {
ForEach(columns[i]) { sectionView(for: $0) }
}
.frame(maxWidth: .infinity)
}
}
}
.padding()
}
}
}
@ViewBuilder
private func sectionView(for config: DashboardSectionConfig) -> some View {
if let section = DashboardSection(rawValue: config.id) {
switch section {
case .totalValue:
if config.isCollapsed {
CompactCard(title: "Portfolio", subtitle: viewModel.portfolioSummary.formattedTotalValue)
} else {
TotalValueCard(
totalValue: viewModel.portfolioSummary.formattedTotalValue,
changeText: "\(viewModel.latestPortfolioChange.formattedAbsolute) (\(viewModel.latestPortfolioChange.formattedPercentage))",
changeLabel: "since last check-in",
isPositive: viewModel.latestPortfolioChange.absolute >= 0,
forecast: showForecast ? viewModel.portfolioForecast : nil,
isPremium: iapService.isPremium,
onUnlockTap: {
viewModel.showingPaywall = true
},
yearChange: viewModel.portfolioSummary.formattedYearChange,
sinceInceptionChange: viewModel.portfolioSummary.formattedAllTimeReturn,
isYearPositive: viewModel.isYearChangePositive,
isSinceInceptionPositive: viewModel.portfolioSummary.allTimeReturn >= 0,
onShareTap: {
ShareService.shared.sharePortfolioValue(
totalValue: viewModel.portfolioSummary.formattedTotalValue,
changeText: "\(viewModel.latestPortfolioChange.formattedAbsolute) (\(viewModel.latestPortfolioChange.formattedPercentage))",
changeLabel: "since last check-in",
yearChange: viewModel.portfolioSummary.formattedYearChange,
sinceInceptionChange: viewModel.portfolioSummary.formattedAllTimeReturn
)
},
sparklineData: viewModel.evolutionData
)
}
case .monthlyCheckIn:
if config.isCollapsed {
CompactCard(title: "Monthly Check-in", subtitle: "Last update: \(viewModel.formattedLastUpdate)")
} else {
MonthlyCheckInCard(
lastUpdated: viewModel.formattedLastUpdate,
pendingCount: viewModel.sourcesNeedingUpdate.count,
totalSources: viewModel.totalSourceCount,
streak: viewModel.updateStreak
)
}
case .momentumStreaks:
if config.isCollapsed {
MomentumStreaksCompactCard()
} else {
MomentumStreaksCard()
}
case .monthlySummary:
if viewModel.monthlySummary.contributions != 0 {
if config.isCollapsed {
CompactCard(
title: "Cashflow vs Growth",
subtitle: "\(viewModel.monthlySummary.formattedContributions) contributions"
)
} else {
MonthlySummaryCard(summary: viewModel.monthlySummary)
}
}
case .evolution:
if config.isCollapsed {
EvolutionCompactCard(data: viewModel.evolutionData)
} else if !viewModel.evolutionData.isEmpty {
EvolutionChartCard(
data: viewModel.evolutionData,
categoryData: viewModel.categoryEvolutionData,
goals: goalsViewModel.goals
)
}
case .categoryBreakdown:
if config.isCollapsed {
let top = viewModel.topCategories.first
CompactCard(
title: "Top Category",
subtitle: "\(top?.categoryName ?? "") \(top?.formattedPercentage ?? "")"
)
} else if !viewModel.categoryMetrics.isEmpty {
CategoryBreakdownCard(categories: viewModel.topCategories)
}
case .goals:
let homeGoals = goalsViewModel.goals.filter { !GoalsViewModel.isAchieved(progress: goalsViewModel.progress(for: $0)) }
if config.isCollapsed {
CompactCard(title: "Goals", subtitle: "\(homeGoals.count) active")
} else {
GoalsSummaryCard(
goals: homeGoals,
progressProvider: goalsViewModel.progress(for:),
currentValueProvider: goalsViewModel.totalValue(for:),
paceStatusProvider: goalsViewModel.paceStatus(for:),
etaProvider: { goal in
let currentValue = goalsViewModel.totalValue(for: goal)
return viewModel.goalEtaText(for: goal, currentValue: currentValue)
}
)
}
case .pendingUpdates:
if config.isCollapsed {
CompactCard(title: "Pending Updates", subtitle: "\(viewModel.sourcesNeedingUpdate.count) sources")
} else if !viewModel.sourcesNeedingUpdate.isEmpty {
PendingUpdatesCard(sources: viewModel.sourcesNeedingUpdate)
}
case .periodReturns:
if config.isCollapsed {
CompactCard(title: "Returns", subtitle: viewModel.portfolioSummary.formattedMonthChange)
} else {
PeriodReturnsCard(
monthChange: viewModel.portfolioSummary.formattedMonthChange,
yearChange: viewModel.portfolioSummary.formattedYearChange,
allTimeChange: viewModel.portfolioSummary.formattedAllTimeReturn,
isMonthPositive: viewModel.isMonthChangePositive,
isYearPositive: viewModel.isYearChangePositive,
isAllTimePositive: viewModel.portfolioSummary.allTimeReturn >= 0
)
}
case .contributionsVsReturns:
let contrib = viewModel.portfolioSummary.totalContributions
let returns = viewModel.portfolioSummary.allTimeReturn
if contrib > 0 || returns != 0 {
if config.isCollapsed {
CompactCard(title: "Invested vs. Returns", subtitle: contrib.currencyString)
} else {
ContributionsVsReturnsCard(
totalContributions: contrib,
totalReturns: returns
)
}
}
}
} else {
EmptyView()
}
}
}
// MARK: - Total Value Card
// Calm 2.0 hero: typography-led the number IS the brand. Clean card,
// semantic delta chip, sparkline for trend at a glance (Stocks-style).
struct TotalValueCard: View {
let totalValue: String
let changeText: String
let changeLabel: String
let isPositive: Bool
var forecast: PortfolioForecast?
var isPremium: Bool = false
var onUnlockTap: (() -> Void)?
var yearChange: String?
var sinceInceptionChange: String?
var isYearPositive: Bool = true
var isSinceInceptionPositive: Bool = true
var onShareTap: (() -> Void)?
var sparklineData: [(date: Date, value: Decimal)] = []
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Total Portfolio Value")
.font(.subheadline.weight(.medium))
.foregroundColor(.secondary)
Spacer()
if let onShareTap {
Button(action: onShareTap) {
Image(systemName: "square.and.arrow.up")
.font(.subheadline.weight(.semibold))
.foregroundColor(.secondary)
}
}
}
Text(totalValue)
.font(.system(size: 44, weight: .bold, design: .rounded))
.foregroundColor(.primary)
.minimumScaleFactor(0.6)
.lineLimit(1)
HStack(spacing: 8) {
HStack(spacing: 4) {
Image(systemName: isPositive ? "arrow.up.right" : "arrow.down.right")
.font(.caption.weight(.semibold))
Text(changeText)
.font(.subheadline.weight(.semibold))
}
.foregroundColor(isPositive ? .positiveGreen : .negativeRed)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background((isPositive ? Color.positiveGreen : Color.negativeRed).opacity(0.12))
.clipShape(Capsule())
Text(changeLabel)
.font(.caption)
.foregroundColor(.secondary)
}
if sparklineData.count >= 2 {
heroSparkline
}
if yearChange != nil || sinceInceptionChange != nil || forecast != nil {
Divider()
HStack(spacing: 0) {
if let yearChange {
heroMetric(label: "YoY", value: yearChange, positive: isYearPositive)
}
if let sinceInceptionChange {
heroMetric(label: "Since inception", value: sinceInceptionChange, positive: isSinceInceptionPositive)
}
if let forecast {
if isPremium {
heroMetric(
label: "12m forecast",
value: forecast.formattedForecastValue,
positive: forecast.isPositiveGrowth
)
} else {
Button {
onUnlockTap?()
} label: {
VStack(spacing: 2) {
HStack(spacing: 3) {
Image(systemName: "lock.fill").font(.caption2)
Text("12m forecast").font(.caption2)
}
.foregroundColor(.secondary)
Text("Unlock")
.font(.caption.weight(.semibold))
.foregroundColor(.appPrimary)
}
.frame(maxWidth: .infinity)
}
}
}
}
}
}
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
}
private func heroMetric(label: String, value: String, positive: Bool) -> some View {
VStack(spacing: 2) {
Text(label)
.font(.caption2)
.foregroundColor(.secondary)
Text(value)
.font(.caption.weight(.semibold))
.foregroundColor(positive ? .positiveGreen : .negativeRed)
}
.frame(maxWidth: .infinity)
}
private var heroSparkline: some View {
Chart {
ForEach(sparklineData, id: \.date) { item in
LineMark(
x: .value("Date", item.date),
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
)
.foregroundStyle(Color.appPrimary)
.interpolationMethod(.catmullRom)
AreaMark(
x: .value("Date", item.date),
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
)
.foregroundStyle(
LinearGradient(
colors: [Color.appPrimary.opacity(0.18), .clear],
startPoint: .top,
endPoint: .bottom
)
)
.interpolationMethod(.catmullRom)
}
}
.chartXAxis(.hidden)
.chartYAxis(.hidden)
.chartYScale(domain: sparklineDomain)
.frame(height: 56)
.allowsHitTesting(false)
}
/// Tight domain so small monthly moves are still visible in a 56pt strip.
private var sparklineDomain: ClosedRange<Double> {
let values = sparklineData.map { NSDecimalNumber(decimal: $0.value).doubleValue }
guard let min = values.min(), let max = values.max(), max > min else {
let v = values.first ?? 0
return (v * 0.95)...(v * 1.05 + 1)
}
let padding = (max - min) * 0.15
return (min - padding)...(max + padding)
}
}
// MARK: - Monthly Check-in Card
// Calm 2.0: the check-in card is state-driven the app's core ritual is THE
// protagonist while pending (progress + one CTA) and collapses to a quiet
// confirmation row once the month is done. Streak lives here, not as a
// separate badge competing for attention.
struct MonthlyCheckInCard: View {
let lastUpdated: String
var pendingCount: Int = 0
var totalSources: Int = 0
var streak: Int = 0
@State private var startDestinationActive = false
@State private var showingGuidedFlow = false
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \JournalEntry.completionTime, ascending: false)],
predicate: NSPredicate(format: "completionTime != nil"),
animation: .none
) private var latestJournalEntry: FetchedResults<JournalEntry>
private var effectiveLastCheckInDate: Date? {
latestJournalEntry.first?.completionTime
}
private var checkInProgress: Double {
guard let last = effectiveLastCheckInDate,
let next = nextCheckInDate else { return 1 }
let totalDays = Double(max(1, last.startOfDay.daysBetween(next.startOfDay)))
guard totalDays > 0 else { return 1 }
let elapsedDays = Double(last.startOfDay.daysBetween(Date()))
return min(max(elapsedDays / totalDays, 0), 1)
}
private var nextCheckInDate: Date? {
guard let last = effectiveLastCheckInDate else { return nil }
let effective = MonthlyCheckInStore.effectiveMonth(for: last, relativeTo: last)
return effective.adding(months: 1).endOfMonth
}
private var isOverdue: Bool {
guard let next = nextCheckInDate else { return false }
return Date() > next
}
private var daysUntilDeadline: Int? {
guard let next = nextCheckInDate else { return nil }
return Calendar.current.dateComponents([.day], from: Date().startOfDay, to: next.startOfDay).day
}
private var progressBarTint: Color {
if isOverdue { return .red }
if let days = daysUntilDeadline, days <= 3 { return .orange }
return .appSecondary
}
private var reminderDate: Date? {
guard let nextCheckInDate else { return nil }
let settings = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext)
let reminderTime = settings.defaultNotificationTime ?? defaultReminderTime
let timeComponents = Calendar.current.dateComponents([.hour, .minute], from: reminderTime)
return Calendar.current.date(
bySettingHour: timeComponents.hour ?? 9,
minute: timeComponents.minute ?? 0,
second: 0,
of: nextCheckInDate
)
}
/// True during the first half of the month (before mid-month threshold) when the previous
/// period's check-in should be offered for update instead of starting a new one.
private var isBeforeMidMonth: Bool {
guard effectiveLastCheckInDate != nil else { return false }
let cal = Calendar.current
let day = cal.component(.day, from: Date())
let daysInMonth = cal.range(of: .day, in: .month, for: Date())?.count ?? 30
return day < daysInMonth / 2
}
/// Reference date to pass to MonthlyCheckInView depending on the current phase.
private var navigationReferenceDate: Date {
if isBeforeMidMonth {
return Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()
}
// Use day 25 so effectiveMonth always resolves to the current calendar month
var comps = Calendar.current.dateComponents([.year, .month], from: Date())
comps.day = 25
return Calendar.current.date(from: comps) ?? Date()
}
private var buttonLabel: String {
if isBeforeMidMonth {
let formatter = DateFormatter()
formatter.dateFormat = "MMMM"
formatter.locale = .current
let prev = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()
return String(format: NSLocalizedString("checkin_update_month", comment: ""), formatter.string(from: prev))
}
return NSLocalizedString("checkin_start_new", comment: "")
}
private var currentMonthLabel: String {
let formatter = DateFormatter()
formatter.dateFormat = "LLLL yyyy"
if isBeforeMidMonth {
let prev = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()
return formatter.string(from: prev)
}
return formatter.string(from: Date())
}
/// All sources recorded this month the ritual is done, be quiet about it.
private var isMonthComplete: Bool {
totalSources > 0 && pendingCount == 0
}
private var updatedCount: Int {
max(0, totalSources - pendingCount)
}
var body: some View {
Group {
if isMonthComplete {
completedRow
} else {
pendingCard
}
}
.padding(isMonthComplete ? 14 : 20)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.background(
NavigationLink(isActive: $startDestinationActive) {
MonthlyCheckInView(referenceDate: navigationReferenceDate)
} label: {
EmptyView()
}
.opacity(0)
)
// Calm 2.0 Fase 2: the ritual runs as a guided full-screen flow one
// source per step, reflection, closing summary.
.fullScreenCover(isPresented: $showingGuidedFlow) {
GuidedCheckInView(referenceDate: navigationReferenceDate)
.environment(\.managedObjectContext, CoreDataStack.shared.viewContext)
}
}
/// Done state: one quiet, reassuring line.
private var completedRow: some View {
HStack(spacing: 10) {
Image(systemName: "checkmark.seal.fill")
.font(.title3)
.foregroundColor(.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)
}
}
Spacer()
if streak >= 2 {
HStack(spacing: 4) {
Image(systemName: "flame.fill")
.font(.caption)
Text("\(streak)")
.font(.caption.weight(.bold))
}
.foregroundColor(.orange)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Color.orange.opacity(0.12))
.clipShape(Capsule())
}
Button {
startDestinationActive = true
} label: {
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundColor(.secondary)
}
}
.contentShape(Rectangle())
.onTapGesture { startDestinationActive = true }
}
/// Pending state: the ritual front and center progress and one CTA.
private var pendingCard: some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Monthly Check-in")
.font(.headline)
Text(currentMonthLabel)
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
if streak >= 2 {
HStack(spacing: 4) {
Image(systemName: "flame.fill").font(.caption)
Text("\(streak)").font(.caption.weight(.bold))
}
.foregroundColor(.orange)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Color.orange.opacity(0.12))
.clipShape(Capsule())
}
if let reminderDate {
Button {
let title = String(
format: NSLocalizedString("calendar_event_title", comment: ""),
appDisplayName
)
let notes = String(
format: NSLocalizedString("calendar_event_notes", comment: ""),
appDisplayName
)
ShareService.shared.shareCalendarEvent(
title: title,
notes: notes,
startDate: reminderDate
)
} label: {
Image(systemName: "calendar.badge.plus")
.foregroundColor(.secondary)
}
.font(.subheadline.weight(.semibold))
}
}
if totalSources > 0 {
VStack(alignment: .leading, spacing: 6) {
ProgressView(value: Double(updatedCount), total: Double(max(1, totalSources)))
.tint(progressBarTint)
Text(String(format: String(localized: "checkin_sources_progress"), updatedCount, totalSources))
.font(.caption)
.foregroundColor(.secondary)
}
} else {
ProgressView(value: checkInProgress)
.tint(progressBarTint)
}
if let nextDate = nextCheckInDate {
Text("Next check-in: \(nextDate.mediumDateString)")
.font(.caption)
.foregroundColor(isOverdue ? .red : .secondary)
}
Button {
showingGuidedFlow = true
} label: {
Text(buttonLabel)
.font(.headline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Color.appPrimary)
.foregroundColor(.white)
.cornerRadius(AppConstants.UI.cornerRadius)
}
.accessibilityIdentifier("checkin_start_cta")
}
}
private var defaultReminderTime: Date {
var components = DateComponents()
components.hour = 9
components.minute = 0
return Calendar.current.date(from: components) ?? Date()
}
private var appDisplayName: String {
if let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String {
return name
}
if let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String {
return name
}
return "Portfolio Journal"
}
}
// MARK: - Momentum & Streaks Card
struct MomentumStreaksCard: View {
/// In narrow containers (the Journal sidebar on iPad) the 3-across stat tiles
/// and achievement detail don't fit stack tiles in a grid and drop detail.
var compact = false
@State private var stats: MonthlyCheckInStats = .empty
@State private var showingAchievements = false
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .firstTextBaseline) {
Text("Momentum & Streaks")
.font(.headline)
Spacer()
if stats.totalCheckIns > 0 {
Text(String(format: NSLocalizedString("on_time_rate", comment: ""), onTimeRateText))
.font(.subheadline.weight(.semibold))
.foregroundColor(.appSecondary)
} else {
Text("Log a check-in to start a streak")
.font(.subheadline)
.foregroundColor(.secondary)
}
}
if compact {
HStack(spacing: 8) {
compactStatTile(title: "Streak", value: "\(stats.currentStreak)x")
compactStatTile(title: "Best", value: "\(stats.bestStreak)x")
compactStatTile(title: "Avg early", value: formattedDaysText(for: stats.averageDaysBeforeDeadline))
}
} else {
HStack(spacing: 12) {
statTile(
title: "Streak",
value: "\(stats.currentStreak)x",
subtitle: "On-time in a row"
)
statTile(
title: "Best",
value: "\(stats.bestStreak)x",
subtitle: "Personal best"
)
statTile(
title: "Avg early",
value: formattedDaysText(for: stats.averageDaysBeforeDeadline),
subtitle: "vs deadline"
)
}
}
ProgressView(value: stats.onTimeRate, total: 1)
.tint(.appSecondary)
HStack {
Text("On-time score")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Text(
String(
format: NSLocalizedString("on_time_count", comment: ""),
stats.onTimeCount,
stats.totalCheckIns
)
)
.font(.caption)
.foregroundColor(.secondary)
}
if let closest = stats.closestCutoffDays {
Text(
String(
format: NSLocalizedString("tightest_finish", comment: ""),
formattedDaysLabel(for: closest)
)
)
.font(.caption)
.foregroundColor(.secondary)
}
if !stats.achievements.isEmpty && !compact {
VStack(alignment: .leading, spacing: 8) {
Text(String(localized: "achievements_title"))
.font(.subheadline.weight(.semibold))
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(stats.achievements) { achievement in
achievementBadge(achievement)
}
}
}
}
}
// Sheet instead of NavigationLink: this card lives in several
// navigation contexts (dashboard column, Journal sidebar) where an
// inline push was unreliable the tap did nothing on iPad.
Button {
showingAchievements = true
} label: {
HStack {
Text(String(localized: "achievements_view_all"))
.font(.subheadline.weight(.semibold))
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.vertical, 8)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
.onAppear(perform: refreshStats)
.onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange)) { _ in
refreshStats()
}
.sheet(isPresented: $showingAchievements) {
NavigationStack {
AchievementsView(referenceDate: Date())
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button(String(localized: "cancel")) { showingAchievements = false }
}
}
}
}
}
private var onTimeRateText: String {
String(format: "%.0f%%", stats.onTimeRate * 100)
}
private func refreshStats() {
stats = MonthlyCheckInStore.stats(referenceDate: Date())
}
@ViewBuilder
private func compactStatTile(title: String, value: String) -> some View {
VStack(spacing: 2) {
Text(value)
.font(.headline)
.minimumScaleFactor(0.7)
.lineLimit(1)
Text(title)
.font(.caption2)
.foregroundColor(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(Color.gray.opacity(0.08))
.cornerRadius(AppConstants.UI.smallCornerRadius)
}
@ViewBuilder
private func statTile(title: String, value: String, subtitle: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.caption)
.foregroundColor(.secondary)
Text(value)
.font(.title3.weight(.semibold))
Text(subtitle)
.font(.caption2)
.foregroundColor(.secondary)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.gray.opacity(0.08))
.cornerRadius(AppConstants.UI.smallCornerRadius)
}
@ViewBuilder
private func achievementBadge(_ achievement: MonthlyCheckInAchievement) -> some View {
HStack(alignment: .top, spacing: 8) {
Image(systemName: achievement.icon)
.font(.headline)
.foregroundColor(.appSecondary)
VStack(alignment: .leading, spacing: 2) {
Text(achievement.title)
.font(.caption.weight(.semibold))
Text(achievement.detail)
.font(.caption2)
.foregroundColor(.secondary)
}
}
.padding(10)
.background(Color.appSecondary.opacity(0.12))
.cornerRadius(AppConstants.UI.smallCornerRadius)
}
private func formattedDaysText(for days: Double?) -> String {
guard let days, days >= 0 else { return "" }
return days >= 1 ? String(format: "%.1fd", days) : String(format: "%.0fh", days * 24)
}
private func formattedDaysLabel(for days: Double) -> String {
if days >= 1 {
return String(format: "%.1f days", days)
}
let hours = max(0, days * 24)
return String(format: "%.0f hours", hours)
}
}
struct MomentumStreaksCompactCard: View {
@State private var stats: MonthlyCheckInStats = .empty
var body: some View {
CompactCard(
title: "Momentum & Streaks",
subtitle: "Streak: \(stats.currentStreak)x • Best: \(stats.bestStreak)x"
)
.onAppear(perform: refreshStats)
.onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange)) { _ in
refreshStats()
}
}
private func refreshStats() {
stats = MonthlyCheckInStore.stats(referenceDate: Date())
}
}
// MARK: - Monthly Summary Card
struct MonthlySummaryCard: View {
let summary: MonthlySummary
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Cashflow vs Growth")
.font(.headline)
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Contributions")
.font(.caption)
.foregroundColor(.secondary)
Text(summary.formattedContributions)
.font(.subheadline.weight(.semibold))
}
Spacer()
VStack(alignment: .trailing, spacing: 4) {
Text("Net Performance")
.font(.caption)
.foregroundColor(.secondary)
Text("\(summary.formattedNetPerformance) (\(summary.formattedNetPerformancePercentage))")
.font(.subheadline.weight(.semibold))
.foregroundColor(summary.netPerformance >= 0 ? .positiveGreen : .negativeRed)
}
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
// MARK: - Dashboard Customization
struct DashboardCustomizeView: View {
@Environment(\.dismiss) private var dismiss
@Binding var configs: [DashboardSectionConfig]
var body: some View {
NavigationStack {
List {
ForEach($configs) { $config in
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(DashboardSection(rawValue: config.id)?.title ?? config.id)
.font(.subheadline.weight(.semibold))
Text(config.isCollapsed ? "Compact" : "Expanded")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Toggle("Show", isOn: $config.isVisible)
.labelsHidden()
}
.swipeActions(edge: .trailing) {
Button {
config.isCollapsed.toggle()
} label: {
Label(config.isCollapsed ? "Expand" : "Compact", systemImage: "arrow.up.left.and.arrow.down.right")
}
.tint(.appSecondary)
}
}
.onMove { indices, newOffset in
configs.move(fromOffsets: indices, toOffset: newOffset)
}
}
.navigationTitle("Customize Dashboard")
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
EditButton()
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
DashboardLayoutStore.save(configs)
dismiss()
}
.fontWeight(.semibold)
}
}
}
.onDisappear {
DashboardLayoutStore.save(configs)
}
}
}
struct CompactCard: View {
let title: String
let subtitle: String
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.headline)
Text(subtitle)
.font(.subheadline)
.foregroundColor(.secondary)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
struct EvolutionCompactCard: View {
let data: [(date: Date, value: Decimal)]
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Portfolio Evolution")
.font(.headline)
if let last = data.last {
Text(last.value.compactCurrencyString)
.font(.subheadline.weight(.semibold))
.foregroundColor(.secondary)
}
SparklineView(data: data, color: .appPrimary)
.frame(height: 40)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
// MARK: - Period Returns Card
struct PeriodReturnsCard: View {
let monthChange: String
let yearChange: String
let allTimeChange: String
let isMonthPositive: Bool
let isYearPositive: Bool
let isAllTimePositive: Bool
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Returns")
.font(.headline)
HStack(spacing: 16) {
ReturnPeriodView(
period: "1M",
change: monthChange,
isPositive: isMonthPositive
)
Divider()
ReturnPeriodView(
period: "1Y",
change: yearChange,
isPositive: isYearPositive
)
Divider()
ReturnPeriodView(
period: "All",
change: allTimeChange,
isPositive: isAllTimePositive
)
}
.frame(maxWidth: .infinity)
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
struct ReturnPeriodView: View {
let period: String
let change: String
let isPositive: Bool
var body: some View {
VStack(spacing: 4) {
Text(period)
.font(.caption)
.foregroundColor(.secondary)
Text(change)
.font(.subheadline.weight(.semibold))
.foregroundColor(isPositive ? .positiveGreen : .negativeRed)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity)
}
}
// MARK: - Empty Dashboard View
struct EmptyDashboardView: View {
let onAddSource: () -> Void
let onImport: () -> Void
let onLoadSample: () -> Void
var body: some View {
VStack(spacing: 20) {
Image(systemName: "chart.pie")
.font(.system(size: 60))
.foregroundColor(.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)
.multilineTextAlignment(.center)
Button {
onAddSource()
} label: {
Label("Add Investment Source", systemImage: "plus")
.font(.headline)
.foregroundColor(.white)
.padding()
.frame(maxWidth: .infinity)
.background(Color.appPrimary)
.cornerRadius(AppConstants.UI.cornerRadius)
}
HStack(spacing: 12) {
Button {
onImport()
} label: {
Label("Import", systemImage: "square.and.arrow.down")
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding()
.background(Color.appPrimary.opacity(0.1))
.foregroundColor(.appPrimary)
.cornerRadius(AppConstants.UI.cornerRadius)
}
Button {
onLoadSample()
} label: {
Label("Load Sample", systemImage: "sparkles")
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding()
.background(Color.appSecondary.opacity(0.1))
.foregroundColor(.appSecondary)
.cornerRadius(AppConstants.UI.cornerRadius)
}
}
}
.padding()
}
}
// MARK: - Pending Updates Alert Banner
struct PendingUpdatesAlertBanner: View {
let count: Int
let onDismiss: () -> Void
var body: some View {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.white)
Text("\(count) source\(count == 1 ? "" : "s") pending update")
.font(.subheadline.weight(.semibold))
.foregroundColor(.white)
Spacer()
Button(action: onDismiss) {
Image(systemName: "xmark")
.font(.caption.weight(.bold))
.foregroundColor(.white.opacity(0.8))
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color.appWarning)
.cornerRadius(AppConstants.UI.cornerRadius)
}
}
// MARK: - Pending Updates Card
struct PendingUpdatesCard: View {
@EnvironmentObject var iapService: IAPService
let sources: [InvestmentSource]
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "bell.badge.fill")
.foregroundColor(.appWarning)
Text("Pending Updates")
.font(.headline)
Spacer()
Text("\(sources.count)")
.font(.subheadline)
.foregroundColor(.secondary)
}
ForEach(sources.prefix(3), id: \.objectID) { source in
NavigationLink(destination: SourceDetailView(source: source, iapService: iapService)) {
HStack {
Circle()
.fill(source.category?.color ?? .gray)
.frame(width: 8, height: 8)
Text(source.name)
.font(.subheadline)
Spacer()
Text(source.latestSnapshot?.date.relativeDescription ?? "Never")
.font(.caption)
.foregroundColor(.secondary)
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
}
.buttonStyle(.plain)
}
if sources.count > 3 {
Text("+ \(sources.count - 3) more")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
// MARK: - Goals Summary Card
struct GoalsSummaryCard: View {
let goals: [Goal]
let progressProvider: (Goal) -> Double
let currentValueProvider: (Goal) -> Decimal
let paceStatusProvider: (Goal) -> GoalPaceStatus?
let etaProvider: (Goal) -> String?
var body: some View {
if goals.isEmpty {
EmptyGoalsCard()
} else {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Goals")
.font(.headline)
Spacer()
NavigationLink("View All") {
GoalsView()
}
.font(.caption.weight(.semibold))
}
ForEach(goals.prefix(2)) { goal in
let currentValue = currentValueProvider(goal)
let paceStatus = paceStatusProvider(goal)
let isAchieved = GoalsViewModel.isAchieved(progress: progressProvider(goal))
let targetUrgency = GoalsViewModel.urgencyLevel(
targetDate: goal.targetDate,
isBehind: paceStatus?.isBehind ?? false,
isAchieved: isAchieved
)
HStack(spacing: 14) {
ProgressRing(
progress: progressProvider(goal),
tint: isAchieved ? .appSuccess : (paceStatus?.isBehind == true ? .appWarning : .appPrimary),
size: 48,
lineWidth: 5
)
VStack(alignment: .leading, spacing: 3) {
Text(goal.name)
.font(.subheadline.weight(.semibold))
HStack(spacing: 4) {
Text(currentValue.compactCurrencyString)
.font(.caption.weight(.semibold))
Text("of \(goal.targetDecimal.compactCurrencyString)")
.font(.caption)
.foregroundColor(.secondary)
}
if let targetDate = goal.targetDate {
Text("Target: \(targetDate.mediumDateString)")
.font(.caption2.weight(.semibold))
.foregroundColor(targetUrgency == .critical ? .negativeRed : (targetUrgency == .warning ? .appWarning : .secondary))
}
if let etaText = etaProvider(goal) {
Text(etaText)
.font(.caption2)
.foregroundColor(.secondary)
}
if let paceStatus {
Text(paceStatus.statusText)
.font(.caption2.weight(.semibold))
.foregroundColor(paceStatus.isBehind ? .appWarning : .positiveGreen)
}
}
Spacer()
Button {
GoalShareService.shared.shareGoal(
name: goal.name,
progress: progressProvider(goal),
currentValue: currentValue,
targetValue: goal.targetDecimal
)
} label: {
Image(systemName: "square.and.arrow.up")
.font(.caption)
.foregroundColor(.appPrimary)
}
}
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
}
struct EmptyGoalsCard: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Goals")
.font(.headline)
Spacer()
NavigationLink("Set Goal") {
GoalsView()
}
.font(.caption.weight(.semibold))
}
Text("Add a milestone like 1M and track your progress each month.")
.font(.subheadline)
.foregroundColor(.secondary)
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
// MARK: - Contributions vs Returns Card
struct ContributionsVsReturnsCard: View {
let totalContributions: Decimal
let totalReturns: Decimal
private var total: Decimal { totalContributions + totalReturns }
private var contributionFraction: Double {
guard total > 0 else { return 0.5 }
return NSDecimalNumber(decimal: totalContributions / total).doubleValue
}
private var returnFraction: Double { 1 - contributionFraction }
private var isReturnPositive: Bool { totalReturns >= 0 }
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(String(localized: "contributions_vs_returns_title"))
.font(.headline)
// Bar
GeometryReader { geo in
HStack(spacing: 2) {
RoundedRectangle(cornerRadius: 4)
.fill(Color.appSecondary)
.frame(width: max(4, geo.size.width * CGFloat(contributionFraction)))
RoundedRectangle(cornerRadius: 4)
.fill(isReturnPositive ? Color.positiveGreen : Color.negativeRed)
.frame(maxWidth: .infinity)
}
.frame(height: 16)
.clipShape(RoundedRectangle(cornerRadius: 6))
}
.frame(height: 16)
HStack {
HStack(spacing: 6) {
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)
Text(totalContributions.currencyString)
.font(.subheadline.weight(.semibold))
}
}
Spacer()
HStack(spacing: 6) {
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)
let prefix = totalReturns >= 0 ? "+" : ""
Text("\(prefix)\(totalReturns.currencyString)")
.font(.subheadline.weight(.semibold))
.foregroundColor(isReturnPositive ? .positiveGreen : .negativeRed)
}
}
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
}
#Preview {
DashboardView()
.environmentObject(IAPService())
.environmentObject(AccountStore(iapService: IAPService()))
}