Files
InvestmentTrackerApp/PortfolioJournal/Views/Dashboard/DashboardView.swift
T
alexandrev-tibco 270edeb536 Migrate journal entries to CoreData for iCloud sync (build 33)
- Add JournalEntry CoreData entity (syncable=YES): id, monthKey, note, moodRaw, rating, completionTime, createdAt
- Rewrite MonthlyCheckInStore: CoreData as primary storage, same public API
- One-time migration from UserDefaults triggered after store loads
- MonthlyCheckInCard: @FetchRequest on JournalEntry — reactive to iCloud sync
- MonthlyCheckInView: onReceive NSManagedObjectContextObjectsDidChange to refresh @State vars on sync
- Stats cards: observe NSManagedObjectContextObjectsDidChange instead of UserDefaults
- ChartsViewModel.completedMonthKeys: remove MonthlyCheckInStore dependency (data-driven)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 15:05:53 +02:00

1512 lines
57 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 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")
}
}
}
.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 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()))
}