import SwiftUI import Charts 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, lastUpdatedDate: viewModel.portfolioSummary.lastUpdated ) } 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 let lastUpdatedDate: Date? @State private var startDestinationActive = false @State private var cachedCompletionDate: Date? private var effectiveLastCheckInDate: Date? { cachedCompletionDate ?? lastUpdatedDate } 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) .onAppear { cachedCompletionDate = MonthlyCheckInStore.latestCompletionDate() } .onChange(of: startDestinationActive) { _, isActive in if !isActive { cachedCompletionDate = MonthlyCheckInStore.latestCompletionDate() } } } 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: UserDefaults.didChangeNotification)) { _ 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: UserDefaults.didChangeNotification)) { _ 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())) }