1042 lines
36 KiB
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()))
|
|
}
|