7a18dd8360
- 151: contribución editable por snapshot en cualquier modo (quitado gate detailed) con diálogo de propagación al editar: solo este / adelante / atrás / todos (SnapshotRepository.propagateContribution, SnapshotFormViewModel.contributionChanged) - 148: Period vs Period mostraba mal el último mes del period B — la agrupación usaba chartMonth(for:) que aplicaba el grace-period del check-in a snapshots históricos; ahora agrupa por mes calendario crudo - 152: iPad Sources no saltaba entre fuentes — añadido .id() al SourceDetailView para recrear el StateObject al cambiar de selección - 146/147: Year vs Year con forecast del año en curso (asterisco de estimado) y KPIs arriba / detalle debajo - 145: card de contribución mensual en SourceDetailView - Nuevas claves snapshot_contribution_propagate_* en los 7 idiomas Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
1537 lines
58 KiB
Swift
1537 lines
58 KiB
Swift
import SwiftUI
|
|
import Charts
|
|
import CoreData
|
|
|
|
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("calmModeEnabled") private var calmModeEnabled = true
|
|
@AppStorage("showForecast") private var showForecast = true
|
|
@State private var pendingAlertDismissed = false
|
|
@State private var fullScreenSection: DashboardSection? = nil
|
|
@State private var dragTargetID: String? = nil
|
|
@State private var showingQuickUpdate = false
|
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
|
|
|
init() {
|
|
_viewModel = StateObject(wrappedValue: DashboardViewModel())
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ZStack {
|
|
AppBackground()
|
|
|
|
ScrollView {
|
|
VStack(spacing: 20) {
|
|
if viewModel.updateStreak >= 2 {
|
|
streakBadge
|
|
}
|
|
|
|
if viewModel.hasData {
|
|
if !pendingAlertDismissed && !viewModel.sourcesNeedingUpdate.isEmpty {
|
|
PendingUpdatesAlertBanner(
|
|
count: viewModel.sourcesNeedingUpdate.count,
|
|
onDismiss: {
|
|
withAnimation {
|
|
pendingAlertDismissed = true
|
|
}
|
|
}
|
|
)
|
|
.transition(.move(edge: .top).combined(with: .opacity))
|
|
}
|
|
|
|
if !viewModel.insights.isEmpty {
|
|
InsightsRow(insights: viewModel.insights)
|
|
}
|
|
|
|
if horizontalSizeClass == .regular {
|
|
iPadDashboardLayout
|
|
} else {
|
|
ForEach(visibleSections) { config in
|
|
sectionView(for: config)
|
|
}
|
|
}
|
|
} else {
|
|
EmptyDashboardView(
|
|
onAddSource: { showingAddSource = true },
|
|
onImport: { showingImport = true },
|
|
onLoadSample: { SampleDataService.shared.seedSampleData() }
|
|
)
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
|
|
if horizontalSizeClass == .regular, let section = fullScreenSection {
|
|
iPadFullScreenOverlay(for: section)
|
|
.transition(.opacity.combined(with: .scale(scale: 0.98)))
|
|
.zIndex(10)
|
|
}
|
|
}
|
|
.animation(.easeInOut(duration: 0.25), value: fullScreenSection)
|
|
.navigationTitle("Home")
|
|
.refreshable {
|
|
viewModel.refreshData()
|
|
}
|
|
.overlay {
|
|
if viewModel.isLoading && !viewModel.hasData {
|
|
ProgressView()
|
|
}
|
|
}
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button {
|
|
showingQuickUpdate = true
|
|
} label: {
|
|
Image(systemName: "plus.circle.fill")
|
|
}
|
|
}
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
accountFilterMenu
|
|
}
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button {
|
|
showingCustomize = true
|
|
} label: {
|
|
Image(systemName: "slider.horizontal.3")
|
|
}
|
|
}
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
dashboardFocusModeButton
|
|
}
|
|
}
|
|
.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
|
|
}
|
|
}
|
|
}
|
|
|
|
private var dashboardFocusModeButton: some View {
|
|
Button {
|
|
calmModeEnabled.toggle()
|
|
} label: {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: calmModeEnabled ? "moon.stars.fill" : "moon.stars")
|
|
Text(calmModeEnabled ? "Focus" : "Full")
|
|
.font(.caption.weight(.semibold))
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 5)
|
|
.background(calmModeEnabled ? Color.appPrimary.opacity(0.12) : Color.gray.opacity(0.1))
|
|
.foregroundColor(calmModeEnabled ? .appPrimary : .secondary)
|
|
.cornerRadius(14)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
private var streakBadge: some View {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "flame.fill")
|
|
.font(.caption.weight(.semibold))
|
|
Text(String(format: String(localized: "streak_badge"), viewModel.updateStreak))
|
|
.font(.caption.weight(.semibold))
|
|
}
|
|
.foregroundColor(.orange)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(Color.orange.opacity(0.15))
|
|
.cornerRadius(20)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
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
|
|
|
|
private var iPadGridSections: [DashboardSectionConfig] {
|
|
visibleSections.filter { $0.id != DashboardSection.totalValue.id }
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var iPadDashboardLayout: some View {
|
|
// Total Portfolio Value — always full width, never in the grid
|
|
let totalConfig = sectionConfigs.first(where: { $0.id == DashboardSection.totalValue.id })
|
|
?? DashboardSectionConfig(id: DashboardSection.totalValue.id, isVisible: true, isCollapsed: false)
|
|
sectionView(for: DashboardSectionConfig(id: totalConfig.id, isVisible: true, isCollapsed: totalConfig.isCollapsed))
|
|
|
|
// Other cards — 2-column grid with drag-and-drop
|
|
if !iPadGridSections.isEmpty {
|
|
LazyVGrid(
|
|
columns: [GridItem(.flexible()), GridItem(.flexible())],
|
|
spacing: 16
|
|
) {
|
|
ForEach(iPadGridSections) { config in
|
|
sectionView(for: config)
|
|
.gridCellColumns(config.columnSpan)
|
|
.overlay {
|
|
if dragTargetID == config.id {
|
|
RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)
|
|
.stroke(Color.appPrimary, lineWidth: 2)
|
|
}
|
|
}
|
|
.draggable(config.id)
|
|
.dropDestination(for: String.self) { items, _ in
|
|
guard let fromID = items.first else { return false }
|
|
return handleIPadDrop(fromID: fromID, toID: config.id)
|
|
} isTargeted: { isTargeted in
|
|
dragTargetID = isTargeted ? config.id : nil
|
|
}
|
|
.contextMenu {
|
|
Button {
|
|
toggleColumnSpan(for: config.id)
|
|
} label: {
|
|
Label(
|
|
config.columnSpan == 2 ? "Normal width" : "Expand to full width",
|
|
systemImage: config.columnSpan == 2 ? "rectangle" : "rectangle.expand.diagonal"
|
|
)
|
|
}
|
|
Button {
|
|
withAnimation(.easeInOut(duration: 0.25)) {
|
|
fullScreenSection = DashboardSection(rawValue: config.id)
|
|
}
|
|
} label: {
|
|
Label("View full screen", systemImage: "arrow.up.left.and.arrow.down.right")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func iPadFullScreenOverlay(for section: DashboardSection) -> some View {
|
|
ZStack {
|
|
Color(.systemBackground)
|
|
.opacity(0.97)
|
|
.ignoresSafeArea()
|
|
|
|
VStack(spacing: 0) {
|
|
HStack {
|
|
Text(section.title)
|
|
.font(.title3.weight(.semibold))
|
|
Spacer()
|
|
Button {
|
|
withAnimation(.easeInOut(duration: 0.25)) {
|
|
fullScreenSection = nil
|
|
}
|
|
} label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.font(.title2)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
.padding()
|
|
|
|
Divider()
|
|
|
|
ScrollView {
|
|
VStack(spacing: 20) {
|
|
let totalConfig = sectionConfigs.first(where: { $0.id == DashboardSection.totalValue.id })
|
|
?? DashboardSectionConfig(id: DashboardSection.totalValue.id, isVisible: true, isCollapsed: false)
|
|
sectionView(for: DashboardSectionConfig(id: totalConfig.id, isVisible: true, isCollapsed: totalConfig.isCollapsed))
|
|
|
|
if let config = sectionConfigs.first(where: { $0.id == section.id }) {
|
|
sectionView(for: DashboardSectionConfig(id: config.id, isVisible: true, isCollapsed: false))
|
|
}
|
|
}
|
|
.padding()
|
|
.frame(maxWidth: 800)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleIPadDrop(fromID: String, toID: String) -> Bool {
|
|
guard fromID != toID,
|
|
let fromIndex = sectionConfigs.firstIndex(where: { $0.id == fromID }),
|
|
let toIndex = sectionConfigs.firstIndex(where: { $0.id == toID }) else { return false }
|
|
withAnimation {
|
|
sectionConfigs.move(fromOffsets: IndexSet(integer: fromIndex), toOffset: toIndex > fromIndex ? toIndex + 1 : toIndex)
|
|
}
|
|
DashboardLayoutStore.save(sectionConfigs)
|
|
dragTargetID = nil
|
|
return true
|
|
}
|
|
|
|
private func toggleColumnSpan(for id: String) {
|
|
guard let index = sectionConfigs.firstIndex(where: { $0.id == id }) else { return }
|
|
withAnimation {
|
|
sectionConfigs[index].columnSpan = sectionConfigs[index].columnSpan == 2 ? 1 : 2
|
|
}
|
|
DashboardLayoutStore.save(sectionConfigs)
|
|
}
|
|
|
|
@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: calmModeEnabled
|
|
? "\(viewModel.latestPortfolioChange.formattedAbsolute) (\(viewModel.latestPortfolioChange.formattedPercentage))"
|
|
: viewModel.portfolioSummary.formattedDayChange,
|
|
changeLabel: calmModeEnabled ? "since last check-in" : "today",
|
|
isPositive: calmModeEnabled
|
|
? viewModel.latestPortfolioChange.absolute >= 0
|
|
: viewModel.isDayChangePositive,
|
|
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: calmModeEnabled
|
|
? "\(viewModel.latestPortfolioChange.formattedAbsolute) (\(viewModel.latestPortfolioChange.formattedPercentage))"
|
|
: viewModel.portfolioSummary.formattedDayChange,
|
|
changeLabel: calmModeEnabled ? "since last check-in" : "today",
|
|
yearChange: viewModel.portfolioSummary.formattedYearChange,
|
|
sinceInceptionChange: viewModel.portfolioSummary.formattedAllTimeReturn
|
|
)
|
|
}
|
|
)
|
|
}
|
|
case .monthlyCheckIn:
|
|
if config.isCollapsed {
|
|
CompactCard(title: "Monthly Check-in", subtitle: "Last update: \(viewModel.formattedLastUpdate)")
|
|
} else {
|
|
MonthlyCheckInCard(lastUpdated: viewModel.formattedLastUpdate)
|
|
}
|
|
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 !calmModeEnabled {
|
|
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
|
|
|
|
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 body: some View {
|
|
VStack(spacing: 8) {
|
|
HStack {
|
|
Spacer()
|
|
Text("Total Portfolio Value")
|
|
.font(.title3.weight(.bold))
|
|
.foregroundColor(.white.opacity(0.85))
|
|
Spacer()
|
|
}
|
|
.overlay(alignment: .trailing) {
|
|
if let onShareTap {
|
|
Button(action: onShareTap) {
|
|
Image(systemName: "square.and.arrow.up")
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundColor(.white.opacity(0.9))
|
|
}
|
|
.padding(.trailing, 16)
|
|
}
|
|
}
|
|
|
|
Text(totalValue)
|
|
.font(.system(size: 42, weight: .bold, design: .rounded))
|
|
.foregroundColor(.white)
|
|
|
|
HStack(spacing: 4) {
|
|
Image(systemName: isPositive ? "arrow.up.right" : "arrow.down.right")
|
|
.font(.caption)
|
|
Text(changeText)
|
|
.font(.subheadline.weight(.medium))
|
|
Text(changeLabel)
|
|
.font(.subheadline)
|
|
.foregroundColor(.white.opacity(0.8))
|
|
}
|
|
.foregroundColor(.white)
|
|
|
|
// YoY and Since Inception returns
|
|
if yearChange != nil || sinceInceptionChange != nil {
|
|
HStack(spacing: 16) {
|
|
if let yearChange = yearChange {
|
|
VStack(spacing: 2) {
|
|
Text("YoY")
|
|
.font(.caption2)
|
|
.foregroundColor(.white.opacity(0.7))
|
|
Text(yearChange)
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundColor(.white)
|
|
}
|
|
}
|
|
|
|
if let sinceInceptionChange = sinceInceptionChange {
|
|
VStack(spacing: 2) {
|
|
Text("Since inception")
|
|
.font(.caption2)
|
|
.foregroundColor(.white.opacity(0.7))
|
|
Text(sinceInceptionChange)
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundColor(.white)
|
|
}
|
|
}
|
|
}
|
|
.padding(.top, 4)
|
|
}
|
|
|
|
// Forecast section
|
|
if let forecast = forecast {
|
|
Divider()
|
|
.background(Color.white.opacity(0.3))
|
|
.padding(.vertical, 8)
|
|
|
|
if isPremium {
|
|
VStack(spacing: 4) {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "wand.and.stars")
|
|
.font(.caption)
|
|
Text("12-month forecast")
|
|
.font(.caption)
|
|
}
|
|
.foregroundColor(.white.opacity(0.85))
|
|
|
|
Text(forecast.formattedForecastValue)
|
|
.font(.title2.weight(.bold))
|
|
.foregroundColor(.white)
|
|
|
|
Text(forecast.formattedGrowthPercentage)
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundColor(forecast.isPositiveGrowth ? .white : .white.opacity(0.8))
|
|
}
|
|
} else {
|
|
Button {
|
|
onUnlockTap?()
|
|
} label: {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "lock.fill")
|
|
.font(.caption)
|
|
Text("Unlock 12-month forecast")
|
|
.font(.caption.weight(.semibold))
|
|
}
|
|
.foregroundColor(.white)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(Color.white.opacity(0.2))
|
|
.cornerRadius(16)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 24)
|
|
.background(LinearGradient.appPrimaryGradient)
|
|
.cornerRadius(AppConstants.UI.cornerRadius)
|
|
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
|
}
|
|
}
|
|
|
|
// MARK: - Monthly Check-in Card
|
|
|
|
struct MonthlyCheckInCard: View {
|
|
let lastUpdated: String
|
|
@State private var startDestinationActive = 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())
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("Monthly Check-in")
|
|
.font(.headline)
|
|
|
|
Text(currentMonthLabel)
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
|
|
HStack {
|
|
Text("Last update: \(lastUpdated)")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
|
|
Spacer()
|
|
|
|
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")
|
|
}
|
|
.font(.subheadline.weight(.semibold))
|
|
}
|
|
}
|
|
|
|
ProgressView(value: checkInProgress)
|
|
.tint(progressBarTint)
|
|
|
|
if let nextDate = nextCheckInDate {
|
|
Text("Next check-in: \(nextDate.mediumDateString)")
|
|
.font(.caption)
|
|
.foregroundColor(isOverdue ? .red : .secondary)
|
|
}
|
|
|
|
Button {
|
|
startDestinationActive = true
|
|
} label: {
|
|
Text(buttonLabel)
|
|
.font(.headline.weight(.semibold))
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 12)
|
|
.background(Color.appPrimary)
|
|
.foregroundColor(.white)
|
|
.cornerRadius(AppConstants.UI.cornerRadius)
|
|
}
|
|
|
|
NavigationLink(
|
|
isActive: $startDestinationActive
|
|
) {
|
|
MonthlyCheckInView(referenceDate: navigationReferenceDate)
|
|
} label: {
|
|
EmptyView()
|
|
}
|
|
}
|
|
.padding()
|
|
.background(Color(.systemBackground))
|
|
.cornerRadius(AppConstants.UI.cornerRadius)
|
|
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
|
}
|
|
|
|
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 {
|
|
@State private var stats: MonthlyCheckInStats = .empty
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
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 {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
NavigationLink {
|
|
AchievementsView(referenceDate: Date())
|
|
} label: {
|
|
HStack {
|
|
Text(String(localized: "achievements_view_all"))
|
|
.font(.subheadline.weight(.semibold))
|
|
Spacer()
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.padding(.vertical, 8)
|
|
}
|
|
.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()
|
|
}
|
|
}
|
|
|
|
private var onTimeRateText: String {
|
|
String(format: "%.0f%%", stats.onTimeRate * 100)
|
|
}
|
|
|
|
private func refreshStats() {
|
|
stats = MonthlyCheckInStore.stats(referenceDate: Date())
|
|
}
|
|
|
|
@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
|
|
)
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack {
|
|
Text(goal.name)
|
|
.font(.subheadline.weight(.semibold))
|
|
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)
|
|
}
|
|
}
|
|
|
|
GoalProgressBar(progress: progressProvider(goal), tint: .appSecondary, iconColor: .appSecondary)
|
|
|
|
HStack {
|
|
Text(currentValue.compactCurrencyString)
|
|
.font(.caption.weight(.semibold))
|
|
Spacer()
|
|
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 ? .negativeRed : .positiveGreen)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.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()))
|
|
}
|