InvestmentTrackerApp/PortfolioJournal/Views/Dashboard/DashboardView.swift

1042 lines
36 KiB
Swift

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 && !viewModel.hasData {
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()))
}