86c95a9e73
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
1595 lines
61 KiB
Swift
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()))
|
|
}
|