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 init() { _viewModel = StateObject(wrappedValue: DashboardViewModel()) } var body: some View { NavigationStack { ZStack { AppBackground() ScrollView { VStack(spacing: 20) { if viewModel.hasData { ForEach(visibleSections) { config in sectionView(for: config) } } else { EmptyDashboardView( onAddSource: { showingAddSource = true }, onImport: { showingImport = true }, onLoadSample: { SampleDataService.shared.seedSampleData() } ) } } .padding() } } .navigationTitle("Home") .refreshable { viewModel.refreshData() } .overlay { if viewModel.isLoading { ProgressView() } } .toolbar { 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) } } } private var accountFilterMenu: some View { Menu { Button { accountStore.selectAllAccounts() } label: { HStack { Text("All Accounts") if accountStore.showAllAccounts { Image(systemName: "checkmark") } } } Divider() ForEach(accountStore.accounts) { account in Button { accountStore.selectAccount(account) } label: { HStack { Text(account.name) if accountStore.selectedAccount?.id == account.id && !accountStore.showAllAccounts { Image(systemName: "checkmark") } } } } } label: { Image(systemName: "person.2.circle") } } private var visibleSections: [DashboardSectionConfig] { sectionConfigs.filter { $0.isVisible } } @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 update" : "today", isPositive: calmModeEnabled ? viewModel.latestPortfolioChange.absolute >= 0 : viewModel.isDayChangePositive ) } 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: if config.isCollapsed { CompactCard(title: "Goals", subtitle: "\(goalsViewModel.goals.count) active") } else { GoalsSummaryCard( goals: goalsViewModel.goals, 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 ) } } } } else { EmptyView() } } } // MARK: - Total Value Card struct TotalValueCard: View { let totalValue: String let changeText: String let changeLabel: String let isPositive: Bool var body: some View { VStack(spacing: 8) { Text("Total Portfolio Value") .font(.subheadline) .foregroundColor(.white.opacity(0.85)) 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) } .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 showingStartOptions = false @State private var startDestinationActive = false @State private var shouldDuplicatePrevious = false private var effectiveLastCheckInDate: Date? { MonthlyCheckInStore.latestCompletionDate() ?? 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 } return last.adding(months: 1) } 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 ) } var body: some View { VStack(alignment: .leading, spacing: 12) { Text("Monthly Check-in") .font(.headline) Text("Keep a calm, deliberate rhythm. Update your sources and add a short note.") .font(.subheadline) .foregroundColor(.secondary) HStack { Text("Last update: \(lastUpdated)") .font(.caption) .foregroundColor(.secondary) Spacer() NavigationLink { AchievementsView(referenceDate: Date()) } label: { Image(systemName: "trophy.fill") } .font(.subheadline.weight(.semibold)) 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)) } Button("Start") { showingStartOptions = true } .font(.subheadline.weight(.semibold)) } ProgressView(value: checkInProgress) .tint(.appSecondary) if let nextDate = nextCheckInDate { Text("Next check-in: \(nextDate.mediumDateString)") .font(.caption) .foregroundColor(.secondary) } NavigationLink( isActive: $startDestinationActive ) { MonthlyCheckInView(duplicatePrevious: shouldDuplicatePrevious) } label: { EmptyView() } } .padding() .background(Color(.systemBackground)) .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) .confirmationDialog( "Start Monthly Check-in", isPresented: $showingStartOptions, titleVisibility: .visible ) { Button("Start from scratch") { shouldDuplicatePrevious = false startDestinationActive = true } Button("Duplicate previous month") { shouldDuplicatePrevious = true startDestinationActive = true } Button("Cancel", role: .cancel) {} } } 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 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)) { 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) 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 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) } } #Preview { DashboardView() .environmentObject(IAPService()) .environmentObject(AccountStore(iapService: IAPService())) }