initial version
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AccountEditorView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject private var accountStore: AccountStore
|
||||
let account: Account?
|
||||
|
||||
@State private var name = ""
|
||||
@State private var currencyCode = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
|
||||
@State private var inputMode: InputMode = .simple
|
||||
@State private var notificationFrequency: NotificationFrequency = .monthly
|
||||
@State private var customFrequencyMonths = 1
|
||||
|
||||
private let accountRepository = AccountRepository()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField("Account name", text: $name)
|
||||
Picker("Currency", selection: $currencyCode) {
|
||||
ForEach(CurrencyPicker.commonCodes, id: \.self) { code in
|
||||
Text(code).tag(code)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Account")
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Input Mode", selection: $inputMode) {
|
||||
ForEach(InputMode.allCases) { mode in
|
||||
Text(mode.title).tag(mode)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Input")
|
||||
} footer: {
|
||||
Text(inputMode.description)
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Reminder Frequency", selection: $notificationFrequency) {
|
||||
ForEach(NotificationFrequency.allCases) { frequency in
|
||||
Text(frequency.displayName).tag(frequency)
|
||||
}
|
||||
}
|
||||
|
||||
if notificationFrequency == .custom {
|
||||
Stepper(
|
||||
"Every \(customFrequencyMonths) month\(customFrequencyMonths > 1 ? "s" : "")",
|
||||
value: $customFrequencyMonths,
|
||||
in: 1...24
|
||||
)
|
||||
}
|
||||
} header: {
|
||||
Text("Account Reminders")
|
||||
} footer: {
|
||||
Text("Reminders apply to the whole account.")
|
||||
}
|
||||
}
|
||||
.navigationTitle(account == nil ? "New Account" : "Edit Account")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") { saveAccount() }
|
||||
.disabled(!isValid)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
loadAccount()
|
||||
}
|
||||
}
|
||||
.presentationDetents([.large])
|
||||
}
|
||||
|
||||
private var isValid: Bool {
|
||||
!name.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
}
|
||||
|
||||
private func loadAccount() {
|
||||
guard let account else { return }
|
||||
name = account.name
|
||||
currencyCode = account.currencyCode ?? currencyCode
|
||||
inputMode = InputMode(rawValue: account.inputMode) ?? .simple
|
||||
notificationFrequency = account.frequency
|
||||
customFrequencyMonths = Int(account.customFrequencyMonths)
|
||||
}
|
||||
|
||||
private func saveAccount() {
|
||||
if let account {
|
||||
accountRepository.updateAccount(
|
||||
account,
|
||||
name: name.trimmingCharacters(in: .whitespaces),
|
||||
currency: currencyCode,
|
||||
inputMode: inputMode,
|
||||
notificationFrequency: notificationFrequency,
|
||||
customFrequencyMonths: customFrequencyMonths
|
||||
)
|
||||
} else {
|
||||
_ = accountRepository.createAccount(
|
||||
name: name.trimmingCharacters(in: .whitespaces),
|
||||
currency: currencyCode,
|
||||
inputMode: inputMode,
|
||||
notificationFrequency: notificationFrequency,
|
||||
customFrequencyMonths: customFrequencyMonths
|
||||
)
|
||||
}
|
||||
|
||||
accountStore.persistSelection()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AccountEditorView(account: nil)
|
||||
.environmentObject(AccountStore(iapService: IAPService()))
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AccountsView: View {
|
||||
@EnvironmentObject private var iapService: IAPService
|
||||
@EnvironmentObject private var accountStore: AccountStore
|
||||
@StateObject private var accountRepository = AccountRepository()
|
||||
@State private var showingAddAccount = false
|
||||
@State private var selectedAccount: Account?
|
||||
@State private var showingPaywall = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AppBackground()
|
||||
|
||||
List {
|
||||
Section {
|
||||
ForEach(accountRepository.accounts) { account in
|
||||
Button {
|
||||
selectedAccount = account
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(account.name)
|
||||
.font(.headline)
|
||||
Text(account.currencyCode ?? AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if accountStore.selectedAccount?.id == account.id && !accountStore.showAllAccounts {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.appPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Accounts")
|
||||
} footer: {
|
||||
Text(iapService.isPremium ? "Create multiple accounts for business, family, or goals." : "Free users can create one account.")
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
.navigationTitle("Accounts")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
if accountStore.canAddAccount() {
|
||||
showingAddAccount = true
|
||||
} else {
|
||||
showingPaywall = true
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingAddAccount) {
|
||||
AccountEditorView(account: nil)
|
||||
}
|
||||
.sheet(isPresented: $showingPaywall) {
|
||||
PaywallView()
|
||||
}
|
||||
.sheet(item: $selectedAccount) { account in
|
||||
AccountEditorView(account: account)
|
||||
}
|
||||
.onAppear {
|
||||
accountRepository.fetchAccounts()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AccountsView()
|
||||
.environmentObject(IAPService())
|
||||
.environmentObject(AccountStore(iapService: IAPService()))
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
struct AllocationPieChart: View {
|
||||
let data: [(category: String, value: Decimal, color: String)]
|
||||
|
||||
@State private var selectedSlice: String?
|
||||
|
||||
var total: Decimal {
|
||||
data.reduce(Decimal.zero) { $0 + $1.value }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Asset Allocation")
|
||||
.font(.headline)
|
||||
|
||||
if !data.isEmpty {
|
||||
HStack(alignment: .top, spacing: 20) {
|
||||
// Pie Chart
|
||||
Chart(data, id: \.category) { item in
|
||||
SectorMark(
|
||||
angle: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue),
|
||||
innerRadius: .ratio(0.6),
|
||||
angularInset: 1.5
|
||||
)
|
||||
.foregroundStyle(Color(hex: item.color) ?? .gray)
|
||||
.cornerRadius(4)
|
||||
.opacity(selectedSlice == nil || selectedSlice == item.category ? 1 : 0.5)
|
||||
}
|
||||
.chartLegend(.hidden)
|
||||
.frame(width: 180, height: 180)
|
||||
.overlay {
|
||||
// Center content
|
||||
VStack(spacing: 2) {
|
||||
if let selected = selectedSlice,
|
||||
let item = data.first(where: { $0.category == selected }) {
|
||||
Text(selected)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(item.value.compactCurrencyString)
|
||||
.font(.headline)
|
||||
let percentage = total > 0
|
||||
? NSDecimalNumber(decimal: item.value / total).doubleValue * 100
|
||||
: 0
|
||||
Text(String(format: "%.1f%%", percentage))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Text("Total")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(total.compactCurrencyString)
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
}
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
// Simple tap detection
|
||||
}
|
||||
)
|
||||
|
||||
// Legend
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(data, id: \.category) { item in
|
||||
Button {
|
||||
if selectedSlice == item.category {
|
||||
selectedSlice = nil
|
||||
} else {
|
||||
selectedSlice = item.category
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(Color(hex: item.color) ?? .gray)
|
||||
.frame(width: 10, height: 10)
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(item.category)
|
||||
.font(.caption)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
let percentage = total > 0
|
||||
? NSDecimalNumber(decimal: item.value / total).doubleValue * 100
|
||||
: 0
|
||||
Text(String(format: "%.1f%%", percentage))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.opacity(selectedSlice == nil || selectedSlice == item.category ? 1 : 0.5)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
AllocationTargetsComparisonChart(data: data)
|
||||
} else {
|
||||
Text("No allocation data available")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 200)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Allocation Targets Comparison
|
||||
|
||||
struct AllocationTargetsComparisonChart: View {
|
||||
let data: [(category: String, value: Decimal, color: String)]
|
||||
@StateObject private var categoryRepository = CategoryRepository()
|
||||
|
||||
private var total: Decimal {
|
||||
data.reduce(Decimal.zero) { $0 + $1.value }
|
||||
}
|
||||
|
||||
private var targetData: [(category: String, actual: Double, target: Double, color: Color)] {
|
||||
data.map { item in
|
||||
let actual = total > 0
|
||||
? NSDecimalNumber(decimal: item.value / total).doubleValue * 100
|
||||
: 0
|
||||
let target = AllocationTargetStore.target(for: categoryId(for: item.category)) ?? 0
|
||||
let color = Color(hex: item.color) ?? .gray
|
||||
return (item.category, actual, target, color)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Targets vs Actual")
|
||||
.font(.headline)
|
||||
|
||||
if targetData.allSatisfy({ $0.target == 0 }) {
|
||||
Text("Set allocation targets to compare your portfolio against your plan.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Chart {
|
||||
ForEach(targetData, id: \.category) { item in
|
||||
BarMark(
|
||||
x: .value("Category", item.category),
|
||||
y: .value("Actual", item.actual)
|
||||
)
|
||||
.foregroundStyle(item.color)
|
||||
|
||||
BarMark(
|
||||
x: .value("Category", item.category),
|
||||
y: .value("Target", item.target)
|
||||
)
|
||||
.foregroundStyle(Color.gray.opacity(0.35))
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) { value in
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(String(format: "%.0f%%", doubleValue))
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks { value in
|
||||
AxisValueLabel()
|
||||
}
|
||||
}
|
||||
.frame(height: 220)
|
||||
|
||||
ForEach(targetData, id: \.category) { item in
|
||||
let drift = item.actual - item.target
|
||||
let prefix = drift >= 0 ? "+" : ""
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(item.color)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(item.category)
|
||||
.font(.caption)
|
||||
Spacer()
|
||||
Text("Actual \(String(format: "%.1f%%", item.actual))")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
Text("Target \(String(format: "%.0f%%", item.target))")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
Text("\(prefix)\(String(format: "%.1f%%", drift))")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundColor(drift >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private func categoryId(for name: String) -> UUID {
|
||||
if let category = categoryRepository.categories.first(where: { $0.name == name }) {
|
||||
return category.id
|
||||
}
|
||||
return UUID()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Allocation List View (Alternative)
|
||||
|
||||
struct AllocationListView: View {
|
||||
let data: [(category: String, value: Decimal, color: String)]
|
||||
|
||||
var total: Decimal {
|
||||
data.reduce(Decimal.zero) { $0 + $1.value }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Asset Allocation")
|
||||
.font(.headline)
|
||||
|
||||
ForEach(data, id: \.category) { item in
|
||||
VStack(spacing: 4) {
|
||||
HStack {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(Color(hex: item.color) ?? .gray)
|
||||
.frame(width: 10, height: 10)
|
||||
|
||||
Text(item.category)
|
||||
.font(.subheadline)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
let percentage = total > 0
|
||||
? NSDecimalNumber(decimal: item.value / total).doubleValue * 100
|
||||
: 0
|
||||
|
||||
Text(String(format: "%.1f%%", percentage))
|
||||
.font(.subheadline.weight(.medium))
|
||||
|
||||
Text(item.value.compactCurrencyString)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: 70, alignment: .trailing)
|
||||
}
|
||||
|
||||
// Progress bar
|
||||
GeometryReader { geometry in
|
||||
let percentage = total > 0
|
||||
? NSDecimalNumber(decimal: item.value / total).doubleValue
|
||||
: 0
|
||||
|
||||
ZStack(alignment: .leading) {
|
||||
Rectangle()
|
||||
.fill(Color.gray.opacity(0.1))
|
||||
.frame(height: 6)
|
||||
.cornerRadius(3)
|
||||
|
||||
Rectangle()
|
||||
.fill(Color(hex: item.color) ?? .gray)
|
||||
.frame(width: geometry.size.width * percentage, height: 6)
|
||||
.cornerRadius(3)
|
||||
}
|
||||
}
|
||||
.frame(height: 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let sampleData: [(category: String, value: Decimal, color: String)] = [
|
||||
("Stocks", 50000, "#10B981"),
|
||||
("Bonds", 25000, "#3B82F6"),
|
||||
("Real Estate", 15000, "#F59E0B"),
|
||||
("Crypto", 10000, "#8B5CF6")
|
||||
]
|
||||
|
||||
return VStack(spacing: 20) {
|
||||
AllocationPieChart(data: sampleData)
|
||||
AllocationListView(data: sampleData)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
@@ -0,0 +1,811 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
struct ChartsContainerView: View {
|
||||
@EnvironmentObject var accountStore: AccountStore
|
||||
@StateObject private var viewModel: ChartsViewModel
|
||||
@StateObject private var goalsViewModel = GoalsViewModel()
|
||||
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
|
||||
|
||||
init(iapService: IAPService) {
|
||||
_viewModel = StateObject(wrappedValue: ChartsViewModel(iapService: iapService))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
AppBackground()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
// Chart Type Selector
|
||||
chartTypeSelector
|
||||
|
||||
// Time Range Selector
|
||||
if viewModel.selectedChartType != .allocation &&
|
||||
viewModel.selectedChartType != .performance &&
|
||||
viewModel.selectedChartType != .riskReturn {
|
||||
timeRangeSelector
|
||||
}
|
||||
|
||||
// Category Filter
|
||||
if viewModel.selectedChartType == .evolution ||
|
||||
viewModel.selectedChartType == .prediction {
|
||||
categoryFilter
|
||||
}
|
||||
|
||||
// Chart Content
|
||||
chartContent
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.navigationTitle("Charts")
|
||||
.sheet(isPresented: $viewModel.showingPaywall) {
|
||||
PaywallView()
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
accountFilterMenu
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.selectedAccount = accountStore.selectedAccount
|
||||
viewModel.showAllAccounts = accountStore.showAllAccounts
|
||||
viewModel.loadData()
|
||||
goalsViewModel.selectedAccount = accountStore.selectedAccount
|
||||
goalsViewModel.showAllAccounts = accountStore.showAllAccounts
|
||||
goalsViewModel.refresh()
|
||||
viewModel.updatePredictionTargetDate(goalsViewModel.goals)
|
||||
}
|
||||
// Performance: Use onChange instead of onReceive for cleaner state updates
|
||||
.onChange(of: accountStore.selectedAccount) { _, newAccount in
|
||||
viewModel.selectedAccount = newAccount
|
||||
goalsViewModel.selectedAccount = newAccount
|
||||
goalsViewModel.refresh()
|
||||
viewModel.updatePredictionTargetDate(goalsViewModel.goals)
|
||||
}
|
||||
.onChange(of: accountStore.showAllAccounts) { _, showAll in
|
||||
viewModel.showAllAccounts = showAll
|
||||
goalsViewModel.showAllAccounts = showAll
|
||||
goalsViewModel.refresh()
|
||||
viewModel.updatePredictionTargetDate(goalsViewModel.goals)
|
||||
}
|
||||
.onChange(of: goalsViewModel.goals) { _, goals in
|
||||
viewModel.updatePredictionTargetDate(goals)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chart Type Selector
|
||||
|
||||
private var chartTypeSelector: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(viewModel.availableChartTypes(calmModeEnabled: calmModeEnabled)) { chartType in
|
||||
ChartTypeButton(
|
||||
chartType: chartType,
|
||||
isSelected: viewModel.selectedChartType == chartType,
|
||||
isPremium: chartType.isPremium,
|
||||
userIsPremium: viewModel.isPremium
|
||||
) {
|
||||
viewModel.selectChart(chartType)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Time Range Selector
|
||||
|
||||
private var timeRangeSelector: some View {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(ChartsViewModel.TimeRange.allCases) { range in
|
||||
Button {
|
||||
viewModel.selectedTimeRange = range
|
||||
} label: {
|
||||
Text(range.rawValue)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
viewModel.selectedTimeRange == range
|
||||
? Color.appPrimary
|
||||
: Color.gray.opacity(0.1)
|
||||
)
|
||||
.foregroundColor(
|
||||
viewModel.selectedTimeRange == range
|
||||
? .white
|
||||
: .primary
|
||||
)
|
||||
.cornerRadius(20)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Category Filter
|
||||
|
||||
@ViewBuilder
|
||||
private var categoryFilter: some View {
|
||||
let availableCategories = viewModel.availableCategories(for: viewModel.selectedChartType)
|
||||
if !availableCategories.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
if availableCategories.count > 1 {
|
||||
Button {
|
||||
viewModel.selectedCategory = nil
|
||||
} label: {
|
||||
Text("All")
|
||||
.font(.caption.weight(.medium))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
viewModel.selectedCategory == nil
|
||||
? Color.appPrimary
|
||||
: Color.gray.opacity(0.1)
|
||||
)
|
||||
.foregroundColor(
|
||||
viewModel.selectedCategory == nil
|
||||
? .white
|
||||
: .primary
|
||||
)
|
||||
.cornerRadius(16)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(availableCategories) { category in
|
||||
Button {
|
||||
viewModel.selectedCategory = category
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(category.color)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(category.name)
|
||||
}
|
||||
.font(.caption.weight(.medium))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
viewModel.selectedCategory?.id == category.id
|
||||
? category.color
|
||||
: Color.gray.opacity(0.1)
|
||||
)
|
||||
.foregroundColor(
|
||||
viewModel.selectedCategory?.id == category.id
|
||||
? .white
|
||||
: .primary
|
||||
)
|
||||
.cornerRadius(16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chart Content
|
||||
|
||||
@ViewBuilder
|
||||
private var chartContent: some View {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.frame(height: 300)
|
||||
} else if !viewModel.hasData {
|
||||
emptyStateView
|
||||
} else {
|
||||
switch viewModel.selectedChartType {
|
||||
case .evolution:
|
||||
EvolutionChartView(
|
||||
data: viewModel.evolutionData,
|
||||
categoryData: viewModel.categoryEvolutionData,
|
||||
goals: goalsViewModel.goals
|
||||
)
|
||||
case .allocation:
|
||||
AllocationPieChart(data: viewModel.allocationData)
|
||||
case .performance:
|
||||
PerformanceBarChart(data: viewModel.performanceData)
|
||||
case .contributions:
|
||||
ContributionsChartView(data: viewModel.contributionsData)
|
||||
case .rollingReturn:
|
||||
RollingReturnChartView(data: viewModel.rollingReturnData)
|
||||
case .riskReturn:
|
||||
RiskReturnChartView(data: viewModel.riskReturnData)
|
||||
case .cashflow:
|
||||
CashflowStackedChartView(data: viewModel.cashflowData)
|
||||
case .drawdown:
|
||||
DrawdownChart(data: viewModel.drawdownData)
|
||||
case .volatility:
|
||||
VolatilityChartView(data: viewModel.volatilityData)
|
||||
case .prediction:
|
||||
PredictionChartView(
|
||||
predictions: viewModel.predictionData,
|
||||
historicalData: viewModel.evolutionData
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyStateView: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "chart.bar.xaxis")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("No Data Available")
|
||||
.font(.headline)
|
||||
|
||||
Text("Add some investment sources and snapshots to see charts.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(height: 300)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chart Type Button
|
||||
|
||||
struct ChartTypeButton: View {
|
||||
let chartType: ChartsViewModel.ChartType
|
||||
let isSelected: Bool
|
||||
let isPremium: Bool
|
||||
let userIsPremium: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
VStack(spacing: 8) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Image(systemName: chartType.icon)
|
||||
.font(.title2)
|
||||
|
||||
if isPremium && !userIsPremium {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.appWarning)
|
||||
.offset(x: 8, y: -4)
|
||||
}
|
||||
}
|
||||
|
||||
Text(chartType.rawValue)
|
||||
.font(.caption)
|
||||
}
|
||||
.frame(width: 80, height: 70)
|
||||
.background(
|
||||
isSelected
|
||||
? LinearGradient.appPrimaryGradient
|
||||
: LinearGradient(
|
||||
colors: [
|
||||
Color(.systemBackground).opacity(0.85),
|
||||
Color(.systemBackground).opacity(0.85)
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.foregroundColor(isSelected ? .white : .primary)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 4, y: 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Evolution Chart View
|
||||
|
||||
struct EvolutionChartView: View {
|
||||
let data: [(date: Date, value: Decimal)]
|
||||
let categoryData: [CategoryEvolutionPoint]
|
||||
let goals: [Goal]
|
||||
|
||||
@State private var selectedDataPoint: (date: Date, value: Decimal)?
|
||||
@State private var chartMode: ChartMode = .total
|
||||
@State private var showGoalLines = false
|
||||
|
||||
enum ChartMode: String, CaseIterable, Identifiable {
|
||||
case total = "Total"
|
||||
case byCategory = "By Category"
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
private var stackedCategoryData: [CategoryEvolutionPoint] {
|
||||
guard !categoryData.isEmpty else { return [] }
|
||||
|
||||
let totalsByCategory = categoryData.reduce(into: [String: Decimal]()) { result, point in
|
||||
result[point.categoryName, default: 0] += point.value
|
||||
}
|
||||
|
||||
let categoryOrder = totalsByCategory
|
||||
.sorted { $0.value > $1.value }
|
||||
.map { $0.key }
|
||||
let orderIndex = Dictionary(uniqueKeysWithValues: categoryOrder.enumerated().map { ($0.element, $0.offset) })
|
||||
|
||||
let groupedByDate = Dictionary(grouping: categoryData) { $0.date }
|
||||
let sortedDates = groupedByDate.keys.sorted()
|
||||
|
||||
var stacked: [CategoryEvolutionPoint] = []
|
||||
|
||||
for date in sortedDates {
|
||||
guard let points = groupedByDate[date] else { continue }
|
||||
let sortedPoints = points.sorted {
|
||||
(orderIndex[$0.categoryName] ?? Int.max) < (orderIndex[$1.categoryName] ?? Int.max)
|
||||
}
|
||||
|
||||
var running: Decimal = 0
|
||||
for point in sortedPoints {
|
||||
running += point.value
|
||||
stacked.append(CategoryEvolutionPoint(
|
||||
date: point.date,
|
||||
categoryName: point.categoryName,
|
||||
colorHex: point.colorHex,
|
||||
value: running
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return stacked
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
headerView
|
||||
modePicker
|
||||
chartSection
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private var headerView: some View {
|
||||
HStack {
|
||||
Text("Portfolio Evolution")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let selected = selectedDataPoint, chartMode == .total {
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(selected.value.compactCurrencyString)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(selected.date.monthYearString)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
showGoalLines.toggle()
|
||||
} label: {
|
||||
Image(systemName: showGoalLines ? "target" : "slash.circle")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.disabled(goals.isEmpty)
|
||||
.accessibilityLabel(showGoalLines ? "Hide goals" : "Show goals")
|
||||
}
|
||||
}
|
||||
|
||||
private var modePicker: some View {
|
||||
Picker("Evolution Mode", selection: $chartMode) {
|
||||
ForEach(ChartMode.allCases) { mode in
|
||||
Text(mode.rawValue).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var chartSection: some View {
|
||||
if data.count >= 2 {
|
||||
chartView
|
||||
} else {
|
||||
Text("Not enough data")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 300)
|
||||
}
|
||||
}
|
||||
|
||||
private var chartView: some View {
|
||||
Chart {
|
||||
chartMarks
|
||||
}
|
||||
.chartForegroundStyleScale(domain: chartCategoryNames, range: chartCategoryColors)
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .stride(by: .month, count: 2)) { value in
|
||||
AxisValueLabel(format: .dateTime.month(.abbreviated))
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) { value in
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(Decimal(doubleValue).shortCurrencyString)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartOverlay { proxy in
|
||||
if chartMode == .total {
|
||||
GeometryReader { geometry in
|
||||
Rectangle()
|
||||
.fill(.clear)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
guard let plotFrameAnchor = proxy.plotFrame else { return }
|
||||
let plotFrame = geometry[plotFrameAnchor]
|
||||
let x = value.location.x - plotFrame.origin.x
|
||||
guard let date: Date = proxy.value(atX: x) else { return }
|
||||
|
||||
if let closest = data.min(by: {
|
||||
abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date))
|
||||
}) {
|
||||
selectedDataPoint = closest
|
||||
}
|
||||
}
|
||||
.onEnded { _ in
|
||||
selectedDataPoint = nil
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 300)
|
||||
// Performance: Use GPU rendering for smoother scrolling on older devices
|
||||
.drawingGroup()
|
||||
}
|
||||
|
||||
@ChartContentBuilder
|
||||
private var chartMarks: some ChartContent {
|
||||
switch chartMode {
|
||||
case .total:
|
||||
ForEach(data, id: \.date) { item in
|
||||
LineMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.interpolationMethod(.catmullRom)
|
||||
|
||||
PointMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.symbolSize(30)
|
||||
|
||||
AreaMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary.opacity(0.3), Color.appPrimary.opacity(0.0)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.interpolationMethod(.catmullRom)
|
||||
}
|
||||
case .byCategory:
|
||||
ForEach(categoryData) { item in
|
||||
AreaMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(by: .value("Category", item.categoryName))
|
||||
.interpolationMethod(.catmullRom)
|
||||
.opacity(0.5)
|
||||
}
|
||||
|
||||
ForEach(stackedCategoryData) { item in
|
||||
LineMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(by: .value("Category", item.categoryName))
|
||||
.interpolationMethod(.catmullRom)
|
||||
|
||||
PointMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(by: .value("Category", item.categoryName))
|
||||
.symbolSize(18)
|
||||
}
|
||||
}
|
||||
|
||||
if showGoalLines {
|
||||
ForEach(goals) { goal in
|
||||
RuleMark(y: .value("Goal", NSDecimalNumber(decimal: goal.targetDecimal).doubleValue))
|
||||
.foregroundStyle(Color.appSecondary.opacity(0.4))
|
||||
.lineStyle(StrokeStyle(lineWidth: 1, dash: [6, 4]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var chartCategoryNames: [String] {
|
||||
let names = Array(Set(categoryData.map { $0.categoryName })).sorted()
|
||||
return names
|
||||
}
|
||||
|
||||
private var chartCategoryColors: [Color] {
|
||||
chartCategoryNames.map { name in
|
||||
if let hex = categoryData.first(where: { $0.categoryName == name })?.colorHex {
|
||||
return Color(hex: hex) ?? .gray
|
||||
}
|
||||
return .gray
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Contributions Chart
|
||||
|
||||
struct ContributionsChartView: View {
|
||||
let data: [(date: Date, amount: Decimal)]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Contributions")
|
||||
.font(.headline)
|
||||
|
||||
if data.isEmpty {
|
||||
Text("No contributions yet.")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 260)
|
||||
} else {
|
||||
Chart {
|
||||
ForEach(data, id: \.date) { item in
|
||||
BarMark(
|
||||
x: .value("Month", item.date),
|
||||
y: .value("Amount", NSDecimalNumber(decimal: item.amount).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appSecondary)
|
||||
.cornerRadius(6)
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .stride(by: .month, count: 2)) { value in
|
||||
AxisValueLabel(format: .dateTime.month(.abbreviated))
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) { value in
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(Decimal(doubleValue).shortCurrencyString)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 260)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Rolling 12-Month Return
|
||||
|
||||
struct RollingReturnChartView: View {
|
||||
let data: [(date: Date, value: Double)]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Rolling 12-Month Return")
|
||||
.font(.headline)
|
||||
|
||||
if data.isEmpty {
|
||||
Text("Not enough data for rolling returns.")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 260)
|
||||
} else {
|
||||
Chart {
|
||||
ForEach(data, id: \.date) { item in
|
||||
LineMark(
|
||||
x: .value("Month", item.date),
|
||||
y: .value("Return", item.value)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.interpolationMethod(.catmullRom)
|
||||
|
||||
PointMark(
|
||||
x: .value("Month", item.date),
|
||||
y: .value("Return", item.value)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.symbolSize(24)
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .stride(by: .month, count: 2)) { value in
|
||||
AxisValueLabel(format: .dateTime.month(.abbreviated))
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) { value in
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(String(format: "%.1f%%", doubleValue))
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 260)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Risk vs Return
|
||||
|
||||
struct RiskReturnChartView: View {
|
||||
let data: [(category: String, cagr: Double, volatility: Double, color: String)]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Risk vs Return")
|
||||
.font(.headline)
|
||||
|
||||
if data.isEmpty {
|
||||
Text("Not enough data to compare categories.")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 260)
|
||||
} else {
|
||||
Chart {
|
||||
ForEach(data, id: \.category) { item in
|
||||
PointMark(
|
||||
x: .value("Volatility", item.volatility),
|
||||
y: .value("CAGR", item.cagr)
|
||||
)
|
||||
.foregroundStyle(Color(hex: item.color) ?? .gray)
|
||||
.symbolSize(60)
|
||||
.annotation(position: .top) {
|
||||
Text(item.category)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks(position: .bottom) { value in
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(String(format: "%.1f%%", doubleValue))
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) { value in
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(String(format: "%.1f%%", doubleValue))
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 260)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Net Performance vs Contributions
|
||||
|
||||
struct CashflowStackedChartView: View {
|
||||
let data: [(date: Date, contributions: Decimal, netPerformance: Decimal)]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Net Performance vs Contributions")
|
||||
.font(.headline)
|
||||
|
||||
if data.isEmpty {
|
||||
Text("Not enough data to compare cashflow.")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 260)
|
||||
} else {
|
||||
Chart {
|
||||
ForEach(data, id: \.date) { item in
|
||||
let contributionValue = NSDecimalNumber(decimal: item.contributions).doubleValue
|
||||
let netValue = NSDecimalNumber(decimal: item.netPerformance).doubleValue
|
||||
let stackedEnd = contributionValue + netValue
|
||||
|
||||
BarMark(
|
||||
x: .value("Month", item.date),
|
||||
yStart: .value("Start", 0),
|
||||
yEnd: .value("Contributions", contributionValue)
|
||||
)
|
||||
.foregroundStyle(Color.appSecondary.opacity(0.8))
|
||||
|
||||
BarMark(
|
||||
x: .value("Month", item.date),
|
||||
yStart: .value("Start", contributionValue),
|
||||
yEnd: .value("Net", stackedEnd)
|
||||
)
|
||||
.foregroundStyle(netValue >= 0 ? Color.positiveGreen : Color.negativeRed)
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .stride(by: .month, count: 2)) { value in
|
||||
AxisValueLabel(format: .dateTime.month(.abbreviated))
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) { value in
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(Decimal(doubleValue).shortCurrencyString)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 260)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ChartsContainerView(iapService: IAPService())
|
||||
.environmentObject(AccountStore(iapService: IAPService()))
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
struct DrawdownChart: View {
|
||||
let data: [(date: Date, drawdown: Double)]
|
||||
|
||||
var maxDrawdown: Double {
|
||||
abs(data.map { $0.drawdown }.min() ?? 0)
|
||||
}
|
||||
|
||||
var currentDrawdown: Double {
|
||||
abs(data.last?.drawdown ?? 0)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
Text("Drawdown Analysis")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text("Max Drawdown")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(String(format: "%.1f%%", maxDrawdown))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.negativeRed)
|
||||
}
|
||||
}
|
||||
|
||||
Text("Shows percentage decline from peak values")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if data.count >= 2 {
|
||||
Chart(data, id: \.date) { item in
|
||||
AreaMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Drawdown", item.drawdown)
|
||||
)
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [Color.negativeRed.opacity(0.5), Color.negativeRed.opacity(0.1)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.interpolationMethod(.catmullRom)
|
||||
|
||||
LineMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Drawdown", item.drawdown)
|
||||
)
|
||||
.foregroundStyle(Color.negativeRed)
|
||||
.interpolationMethod(.catmullRom)
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .stride(by: .month, count: 2)) { value in
|
||||
AxisValueLabel(format: .dateTime.month(.abbreviated))
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks { value in
|
||||
AxisGridLine()
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(String(format: "%.0f%%", doubleValue))
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartYScale(domain: (data.map { $0.drawdown }.min() ?? -50)...0)
|
||||
.frame(height: 250)
|
||||
|
||||
// Statistics
|
||||
HStack(spacing: 20) {
|
||||
DrawdownStatView(
|
||||
title: "Current",
|
||||
value: String(format: "%.1f%%", currentDrawdown),
|
||||
isHighlighted: currentDrawdown > maxDrawdown * 0.8
|
||||
)
|
||||
|
||||
DrawdownStatView(
|
||||
title: "Maximum",
|
||||
value: String(format: "%.1f%%", maxDrawdown),
|
||||
isHighlighted: true
|
||||
)
|
||||
|
||||
DrawdownStatView(
|
||||
title: "Average",
|
||||
value: String(format: "%.1f%%", averageDrawdown),
|
||||
isHighlighted: false
|
||||
)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
} else {
|
||||
Text("Not enough data for drawdown analysis")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 250)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private var averageDrawdown: Double {
|
||||
guard !data.isEmpty else { return 0 }
|
||||
let sum = data.reduce(0.0) { $0 + abs($1.drawdown) }
|
||||
return sum / Double(data.count)
|
||||
}
|
||||
}
|
||||
|
||||
struct DrawdownStatView: View {
|
||||
let title: String
|
||||
let value: String
|
||||
let isHighlighted: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(isHighlighted ? .negativeRed : .primary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Volatility Chart View
|
||||
|
||||
struct VolatilityChartView: View {
|
||||
let data: [(date: Date, volatility: Double)]
|
||||
|
||||
var currentVolatility: Double {
|
||||
data.last?.volatility ?? 0
|
||||
}
|
||||
|
||||
var averageVolatility: Double {
|
||||
guard !data.isEmpty else { return 0 }
|
||||
return data.reduce(0.0) { $0 + $1.volatility } / Double(data.count)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
Text("Volatility")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text("Current")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(String(format: "%.1f%%", currentVolatility))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(volatilityColor(currentVolatility))
|
||||
}
|
||||
}
|
||||
|
||||
Text("Measures price variability over time")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if data.count >= 2 {
|
||||
Chart(data, id: \.date) { item in
|
||||
LineMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Volatility", item.volatility)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.interpolationMethod(.catmullRom)
|
||||
|
||||
AreaMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Volatility", item.volatility)
|
||||
)
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary.opacity(0.3), Color.appPrimary.opacity(0.0)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.interpolationMethod(.catmullRom)
|
||||
|
||||
// Average line
|
||||
RuleMark(y: .value("Average", averageVolatility))
|
||||
.foregroundStyle(Color.gray.opacity(0.5))
|
||||
.lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
|
||||
.annotation(position: .top, alignment: .leading) {
|
||||
Text("Avg: \(String(format: "%.1f%%", averageVolatility))")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .stride(by: .month, count: 2)) { value in
|
||||
AxisValueLabel(format: .dateTime.month(.abbreviated))
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks { value in
|
||||
AxisGridLine()
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(String(format: "%.0f%%", doubleValue))
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 250)
|
||||
|
||||
// Volatility interpretation
|
||||
HStack(spacing: 16) {
|
||||
VolatilityLevelView(level: "Low", range: "0-10%", color: .positiveGreen)
|
||||
VolatilityLevelView(level: "Medium", range: "10-20%", color: .appWarning)
|
||||
VolatilityLevelView(level: "High", range: "20%+", color: .negativeRed)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
} else {
|
||||
Text("Not enough data for volatility analysis")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 250)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private func volatilityColor(_ volatility: Double) -> Color {
|
||||
switch volatility {
|
||||
case 0..<10:
|
||||
return .positiveGreen
|
||||
case 10..<20:
|
||||
return .appWarning
|
||||
default:
|
||||
return .negativeRed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct VolatilityLevelView: View {
|
||||
let level: String
|
||||
let range: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 8, height: 8)
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(level)
|
||||
.font(.caption2)
|
||||
Text(range)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let drawdownData: [(date: Date, drawdown: Double)] = [
|
||||
(Date().adding(months: -6), -5),
|
||||
(Date().adding(months: -5), -8),
|
||||
(Date().adding(months: -4), -3),
|
||||
(Date().adding(months: -3), -15),
|
||||
(Date().adding(months: -2), -10),
|
||||
(Date().adding(months: -1), -7),
|
||||
(Date(), -4)
|
||||
]
|
||||
|
||||
let volatilityData: [(date: Date, volatility: Double)] = [
|
||||
(Date().adding(months: -6), 12),
|
||||
(Date().adding(months: -5), 15),
|
||||
(Date().adding(months: -4), 10),
|
||||
(Date().adding(months: -3), 22),
|
||||
(Date().adding(months: -2), 18),
|
||||
(Date().adding(months: -1), 14),
|
||||
(Date(), 11)
|
||||
]
|
||||
|
||||
return VStack(spacing: 20) {
|
||||
DrawdownChart(data: drawdownData)
|
||||
VolatilityChartView(data: volatilityData)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
struct PerformanceBarChart: View {
|
||||
let data: [(category: String, cagr: Double, color: String)]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Performance by Category")
|
||||
.font(.headline)
|
||||
|
||||
Text("Compound Annual Growth Rate (CAGR)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if !data.isEmpty {
|
||||
Chart(data, id: \.category) { item in
|
||||
BarMark(
|
||||
x: .value("Category", item.category),
|
||||
y: .value("CAGR", item.cagr)
|
||||
)
|
||||
.foregroundStyle(Color(hex: item.color) ?? .gray)
|
||||
.cornerRadius(4)
|
||||
.annotation(position: item.cagr >= 0 ? .top : .bottom) {
|
||||
Text(String(format: "%.1f%%", item.cagr))
|
||||
.font(.caption2)
|
||||
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks { value in
|
||||
AxisValueLabel {
|
||||
if let category = value.as(String.self) {
|
||||
Text(category)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks { value in
|
||||
AxisGridLine()
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(String(format: "%.0f%%", doubleValue))
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 250)
|
||||
|
||||
// Legend / Details
|
||||
VStack(spacing: 8) {
|
||||
ForEach(data.sorted(by: { $0.cagr > $1.cagr }), id: \.category) { item in
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color(hex: item.color) ?? .gray)
|
||||
.frame(width: 10, height: 10)
|
||||
|
||||
Text(item.category)
|
||||
.font(.subheadline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(String(format: "%.2f%%", item.cagr))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
} else {
|
||||
Text("No performance data available")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 250)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Horizontal Bar Version
|
||||
|
||||
struct HorizontalPerformanceChart: View {
|
||||
let data: [(category: String, cagr: Double, color: String)]
|
||||
|
||||
var sortedData: [(category: String, cagr: Double, color: String)] {
|
||||
data.sorted { $0.cagr > $1.cagr }
|
||||
}
|
||||
|
||||
var maxValue: Double {
|
||||
max(abs(data.map { $0.cagr }.max() ?? 0), abs(data.map { $0.cagr }.min() ?? 0))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Performance by Category")
|
||||
.font(.headline)
|
||||
|
||||
ForEach(sortedData, id: \.category) { item in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(item.category)
|
||||
.font(.subheadline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(String(format: "%.2f%%", item.cagr))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
|
||||
GeometryReader { geometry in
|
||||
let normalizedValue = maxValue > 0 ? abs(item.cagr) / maxValue : 0
|
||||
let barWidth = geometry.size.width * normalizedValue
|
||||
|
||||
ZStack(alignment: item.cagr >= 0 ? .leading : .trailing) {
|
||||
Rectangle()
|
||||
.fill(Color.gray.opacity(0.1))
|
||||
.frame(height: 8)
|
||||
.cornerRadius(4)
|
||||
|
||||
Rectangle()
|
||||
.fill(item.cagr >= 0 ? Color.positiveGreen : Color.negativeRed)
|
||||
.frame(width: barWidth, height: 8)
|
||||
.cornerRadius(4)
|
||||
}
|
||||
}
|
||||
.frame(height: 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let sampleData: [(category: String, cagr: Double, color: String)] = [
|
||||
("Stocks", 12.5, "#10B981"),
|
||||
("Bonds", 4.2, "#3B82F6"),
|
||||
("Real Estate", 8.1, "#F59E0B"),
|
||||
("Crypto", -5.3, "#8B5CF6")
|
||||
]
|
||||
|
||||
return VStack(spacing: 20) {
|
||||
PerformanceBarChart(data: sampleData)
|
||||
HorizontalPerformanceChart(data: sampleData)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
struct PredictionChartView: View {
|
||||
let predictions: [Prediction]
|
||||
let historicalData: [(date: Date, value: Decimal)]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
Text(predictions.isEmpty ? "Prediction" : "\(predictions.count)-Month Prediction")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let lastPrediction = predictions.last {
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text("Forecast")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(lastPrediction.formattedValue)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.appPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let algorithm = predictions.first?.algorithm {
|
||||
Text("Algorithm: \(algorithm.displayName)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if !predictions.isEmpty && historicalData.count >= 2 {
|
||||
Chart {
|
||||
// Historical data
|
||||
ForEach(historicalData, id: \.date) { item in
|
||||
LineMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.interpolationMethod(.catmullRom)
|
||||
|
||||
PointMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.symbolSize(24)
|
||||
}
|
||||
|
||||
// Confidence interval area
|
||||
ForEach(predictions) { prediction in
|
||||
AreaMark(
|
||||
x: .value("Date", prediction.date),
|
||||
yStart: .value("Lower", NSDecimalNumber(decimal: prediction.confidenceInterval.lower).doubleValue),
|
||||
yEnd: .value("Upper", NSDecimalNumber(decimal: prediction.confidenceInterval.upper).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appSecondary.opacity(0.2))
|
||||
}
|
||||
|
||||
// Prediction line
|
||||
ForEach(predictions) { prediction in
|
||||
LineMark(
|
||||
x: .value("Date", prediction.date),
|
||||
y: .value("Predicted", NSDecimalNumber(decimal: prediction.predictedValue).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appSecondary)
|
||||
.lineStyle(StrokeStyle(lineWidth: 2, dash: [5, 5]))
|
||||
|
||||
PointMark(
|
||||
x: .value("Date", prediction.date),
|
||||
y: .value("Predicted", NSDecimalNumber(decimal: prediction.predictedValue).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appSecondary)
|
||||
.symbolSize(24)
|
||||
}
|
||||
|
||||
// Connect historical to prediction
|
||||
if let lastHistorical = historicalData.last,
|
||||
let firstPrediction = predictions.first {
|
||||
LineMark(
|
||||
x: .value("Date", lastHistorical.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: lastHistorical.value).doubleValue),
|
||||
series: .value("Connection", "connect")
|
||||
)
|
||||
.foregroundStyle(Color.appSecondary)
|
||||
.lineStyle(StrokeStyle(lineWidth: 2, dash: [5, 5]))
|
||||
|
||||
LineMark(
|
||||
x: .value("Date", firstPrediction.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: firstPrediction.predictedValue).doubleValue),
|
||||
series: .value("Connection", "connect")
|
||||
)
|
||||
.foregroundStyle(Color.appSecondary)
|
||||
.lineStyle(StrokeStyle(lineWidth: 2, dash: [5, 5]))
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .stride(by: .month, count: 3)) { value in
|
||||
AxisValueLabel(format: .dateTime.month(.abbreviated).year(.twoDigits))
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) { value in
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(Decimal(doubleValue).shortCurrencyString)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 280)
|
||||
|
||||
// Legend
|
||||
HStack(spacing: 20) {
|
||||
HStack(spacing: 6) {
|
||||
Rectangle()
|
||||
.fill(Color.appPrimary)
|
||||
.frame(width: 20, height: 3)
|
||||
Text("Historical")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Rectangle()
|
||||
.fill(Color.appSecondary)
|
||||
.frame(width: 20, height: 3)
|
||||
.mask(
|
||||
HStack(spacing: 2) {
|
||||
ForEach(0..<5, id: \.self) { _ in
|
||||
Rectangle()
|
||||
.frame(width: 3)
|
||||
}
|
||||
}
|
||||
)
|
||||
Text("Prediction")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Rectangle()
|
||||
.fill(Color.appSecondary.opacity(0.3))
|
||||
.frame(width: 20, height: 10)
|
||||
Text("Confidence")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// Prediction details
|
||||
if let lastPrediction = predictions.last {
|
||||
Divider()
|
||||
.padding(.vertical, 8)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
HStack {
|
||||
Text("12-Month Forecast")
|
||||
.font(.subheadline)
|
||||
Spacer()
|
||||
Text(lastPrediction.formattedValue)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Confidence Range")
|
||||
.font(.subheadline)
|
||||
Spacer()
|
||||
Text(lastPrediction.formattedConfidenceRange)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if let currentValue = historicalData.last?.value {
|
||||
let change = lastPrediction.predictedValue - currentValue
|
||||
let changePercent = currentValue > 0
|
||||
? NSDecimalNumber(decimal: change / currentValue).doubleValue * 100
|
||||
: 0
|
||||
|
||||
HStack {
|
||||
Text("Expected Change")
|
||||
.font(.subheadline)
|
||||
Spacer()
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: change >= 0 ? "arrow.up.right" : "arrow.down.right")
|
||||
.font(.caption)
|
||||
Text(String(format: "%+.1f%%", changePercent))
|
||||
.font(.subheadline.weight(.medium))
|
||||
}
|
||||
.foregroundColor(change >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "wand.and.stars")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("Not enough data for predictions")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("Add at least 3 snapshots to generate predictions")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(height: 280)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let historicalData: [(date: Date, value: Decimal)] = [
|
||||
(Date().adding(months: -6), 10000),
|
||||
(Date().adding(months: -5), 10500),
|
||||
(Date().adding(months: -4), 10200),
|
||||
(Date().adding(months: -3), 11000),
|
||||
(Date().adding(months: -2), 11500),
|
||||
(Date().adding(months: -1), 11200),
|
||||
(Date(), 12000)
|
||||
]
|
||||
|
||||
let predictions = [
|
||||
Prediction(
|
||||
date: Date().adding(months: 3),
|
||||
predictedValue: 13000,
|
||||
algorithm: .linear,
|
||||
confidenceInterval: Prediction.ConfidenceInterval(lower: 12000, upper: 14000)
|
||||
),
|
||||
Prediction(
|
||||
date: Date().adding(months: 6),
|
||||
predictedValue: 14000,
|
||||
algorithm: .linear,
|
||||
confidenceInterval: Prediction.ConfidenceInterval(lower: 12500, upper: 15500)
|
||||
),
|
||||
Prediction(
|
||||
date: Date().adding(months: 9),
|
||||
predictedValue: 15000,
|
||||
algorithm: .linear,
|
||||
confidenceInterval: Prediction.ConfidenceInterval(lower: 13000, upper: 17000)
|
||||
),
|
||||
Prediction(
|
||||
date: Date().adding(months: 12),
|
||||
predictedValue: 16000,
|
||||
algorithm: .linear,
|
||||
confidenceInterval: Prediction.ConfidenceInterval(lower: 13500, upper: 18500)
|
||||
)
|
||||
]
|
||||
|
||||
return PredictionChartView(predictions: predictions, historicalData: historicalData)
|
||||
.padding()
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AppBackground: View {
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.appPrimary.opacity(0.15),
|
||||
Color.appSecondary.opacity(0.12),
|
||||
Color.appAccent.opacity(0.1)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
|
||||
Circle()
|
||||
.fill(Color.appPrimary.opacity(0.18))
|
||||
.frame(width: proxy.size.width * 0.7)
|
||||
.offset(x: -proxy.size.width * 0.35, y: -proxy.size.height * 0.35)
|
||||
|
||||
RoundedRectangle(cornerRadius: 120, style: .continuous)
|
||||
.fill(Color.appAccent.opacity(0.12))
|
||||
.frame(width: proxy.size.width * 0.8, height: proxy.size.height * 0.35)
|
||||
.rotationEffect(.degrees(-12))
|
||||
.offset(x: proxy.size.width * 0.2, y: proxy.size.height * 0.45)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LoadingView: View {
|
||||
var message: String = "Loading..."
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
.scaleEffect(1.5)
|
||||
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(.systemBackground))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Skeleton Loading
|
||||
|
||||
struct SkeletonView: View {
|
||||
@State private var isAnimating = false
|
||||
|
||||
var body: some View {
|
||||
Rectangle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.gray.opacity(0.1),
|
||||
Color.gray.opacity(0.2),
|
||||
Color.gray.opacity(0.1)
|
||||
],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.offset(x: isAnimating ? 200 : -200)
|
||||
.animation(
|
||||
Animation.linear(duration: 1.5)
|
||||
.repeatForever(autoreverses: false),
|
||||
value: isAnimating
|
||||
)
|
||||
.mask(Rectangle())
|
||||
.onAppear {
|
||||
isAnimating = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SkeletonCardView: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
SkeletonView()
|
||||
.frame(width: 100, height: 16)
|
||||
.cornerRadius(4)
|
||||
|
||||
SkeletonView()
|
||||
.frame(height: 32)
|
||||
.cornerRadius(4)
|
||||
|
||||
HStack {
|
||||
SkeletonView()
|
||||
.frame(width: 80, height: 14)
|
||||
.cornerRadius(4)
|
||||
|
||||
Spacer()
|
||||
|
||||
SkeletonView()
|
||||
.frame(width: 60, height: 14)
|
||||
.cornerRadius(4)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty State View
|
||||
|
||||
struct EmptyStateView: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let message: String
|
||||
var actionTitle: String?
|
||||
var action: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(title)
|
||||
.font(.title2.weight(.semibold))
|
||||
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
|
||||
if let actionTitle = actionTitle, let action = action {
|
||||
Button(action: action) {
|
||||
Text(actionTitle)
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
.padding()
|
||||
.frame(maxWidth: 200)
|
||||
.background(Color.appPrimary)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error View
|
||||
|
||||
struct ErrorView: View {
|
||||
let message: String
|
||||
var retryAction: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.appWarning)
|
||||
|
||||
Text("Something went wrong")
|
||||
.font(.headline)
|
||||
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
|
||||
if let retryAction = retryAction {
|
||||
Button(action: retryAction) {
|
||||
Label("Try Again", systemImage: "arrow.clockwise")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.appPrimary)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(20)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Success View
|
||||
|
||||
struct SuccessView: View {
|
||||
let title: String
|
||||
let message: String
|
||||
var action: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.positiveGreen.opacity(0.1))
|
||||
.frame(width: 100, height: 100)
|
||||
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.positiveGreen)
|
||||
}
|
||||
|
||||
Text(title)
|
||||
.font(.title2.weight(.bold))
|
||||
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
|
||||
if let action = action {
|
||||
Button(action: action) {
|
||||
Text("Continue")
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
.padding()
|
||||
.frame(maxWidth: 200)
|
||||
.background(Color.appPrimary)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Toast View
|
||||
|
||||
struct ToastView: View {
|
||||
enum ToastType {
|
||||
case success, error, info
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .success: return "checkmark.circle.fill"
|
||||
case .error: return "xmark.circle.fill"
|
||||
case .info: return "info.circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .success: return .positiveGreen
|
||||
case .error: return .negativeRed
|
||||
case .info: return .appPrimary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let message: String
|
||||
let type: ToastType
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: type.icon)
|
||||
.foregroundColor(type.color)
|
||||
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.1), radius: 10, y: 5)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack(spacing: 20) {
|
||||
LoadingView()
|
||||
.frame(height: 100)
|
||||
|
||||
SkeletonCardView()
|
||||
|
||||
EmptyStateView(
|
||||
icon: "tray",
|
||||
title: "No Data",
|
||||
message: "Start adding your investments to see them here.",
|
||||
actionTitle: "Get Started",
|
||||
action: {}
|
||||
)
|
||||
.frame(height: 300)
|
||||
|
||||
ToastView(message: "Successfully saved!", type: .success)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CategoryBreakdownCard: View {
|
||||
@EnvironmentObject private var iapService: IAPService
|
||||
let categories: [CategoryMetrics]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("By Category")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
NavigationLink {
|
||||
ChartsContainerView(iapService: iapService)
|
||||
} label: {
|
||||
Text("See All")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.appPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(categories) { category in
|
||||
CategoryRowView(category: category)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
struct CategoryRowView: View {
|
||||
let category: CategoryMetrics
|
||||
private var targetPercentage: Double? {
|
||||
AllocationTargetStore.target(for: category.id)
|
||||
}
|
||||
|
||||
private var driftText: String? {
|
||||
guard let target = targetPercentage else { return nil }
|
||||
let drift = category.percentageOfPortfolio - target
|
||||
let prefix = drift >= 0 ? "+" : ""
|
||||
return "\(prefix)\(String(format: "%.1f%%", drift))"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
// Icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill((Color(hex: category.colorHex) ?? .gray).opacity(0.2))
|
||||
.frame(width: 36, height: 36)
|
||||
|
||||
Image(systemName: category.icon)
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(Color(hex: category.colorHex) ?? .gray)
|
||||
}
|
||||
|
||||
// Name and percentage
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(category.categoryName)
|
||||
.font(.subheadline.weight(.medium))
|
||||
|
||||
Text(category.formattedPercentage)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if let target = targetPercentage {
|
||||
Text("Target \(String(format: "%.0f%%", target)) | Drift \(driftText ?? "")")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Value and return
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(category.formattedTotalValue)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Text("CAGR")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
Text(category.metrics.formattedCAGR)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(category.metrics.cagr >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Category Progress Bar
|
||||
|
||||
struct CategoryProgressBar: View {
|
||||
let category: CategoryMetrics
|
||||
let maxValue: Decimal
|
||||
|
||||
var progress: Double {
|
||||
guard maxValue > 0 else { return 0 }
|
||||
return NSDecimalNumber(decimal: category.totalValue / maxValue).doubleValue
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(Color(hex: category.colorHex) ?? .gray)
|
||||
.frame(width: 8, height: 8)
|
||||
|
||||
Text(category.categoryName)
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(category.formattedPercentage)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
GeometryReader { geometry in
|
||||
ZStack(alignment: .leading) {
|
||||
Rectangle()
|
||||
.fill(Color.gray.opacity(0.1))
|
||||
.frame(height: 6)
|
||||
.cornerRadius(3)
|
||||
|
||||
Rectangle()
|
||||
.fill(Color(hex: category.colorHex) ?? .gray)
|
||||
.frame(width: geometry.size.width * progress, height: 6)
|
||||
.cornerRadius(3)
|
||||
}
|
||||
}
|
||||
.frame(height: 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Simple Category List
|
||||
|
||||
struct SimpleCategoryList: View {
|
||||
let categories: [CategoryMetrics]
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
ForEach(categories) { category in
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color(hex: category.colorHex) ?? .gray)
|
||||
.frame(width: 10, height: 10)
|
||||
|
||||
Text(category.categoryName)
|
||||
.font(.subheadline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(category.formattedTotalValue)
|
||||
.font(.subheadline.weight(.medium))
|
||||
|
||||
Text("(\(category.formattedPercentage))")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let sampleCategories = [
|
||||
CategoryMetrics(
|
||||
id: UUID(),
|
||||
categoryName: "Stocks",
|
||||
colorHex: "#10B981",
|
||||
icon: "chart.line.uptrend.xyaxis",
|
||||
totalValue: 50000,
|
||||
percentageOfPortfolio: 50,
|
||||
metrics: .empty
|
||||
),
|
||||
CategoryMetrics(
|
||||
id: UUID(),
|
||||
categoryName: "Bonds",
|
||||
colorHex: "#3B82F6",
|
||||
icon: "building.columns.fill",
|
||||
totalValue: 30000,
|
||||
percentageOfPortfolio: 30,
|
||||
metrics: .empty
|
||||
),
|
||||
CategoryMetrics(
|
||||
id: UUID(),
|
||||
categoryName: "Real Estate",
|
||||
colorHex: "#F59E0B",
|
||||
icon: "house.fill",
|
||||
totalValue: 20000,
|
||||
percentageOfPortfolio: 20,
|
||||
metrics: .empty
|
||||
)
|
||||
]
|
||||
|
||||
return CategoryBreakdownCard(categories: sampleCategories)
|
||||
.padding()
|
||||
.environmentObject(IAPService())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,299 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
struct EvolutionChartCard: View {
|
||||
let data: [(date: Date, value: Decimal)]
|
||||
let categoryData: [CategoryEvolutionPoint]
|
||||
let goals: [Goal]
|
||||
|
||||
@State private var selectedDataPoint: (date: Date, value: Decimal)?
|
||||
@State private var chartMode: ChartMode = .total
|
||||
@State private var showGoalLines = true
|
||||
|
||||
enum ChartMode: String, CaseIterable, Identifiable {
|
||||
case total = "Total"
|
||||
case byCategory = "By Category"
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
headerView
|
||||
modePicker
|
||||
chartSection
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private var headerView: some View {
|
||||
HStack {
|
||||
Text("Portfolio Evolution")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
showGoalLines.toggle()
|
||||
} label: {
|
||||
Image(systemName: showGoalLines ? "target" : "slash.circle")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.accessibilityLabel(showGoalLines ? "Hide goals" : "Show goals")
|
||||
|
||||
if let selected = selectedDataPoint, chartMode == .total {
|
||||
VStack(alignment: .trailing) {
|
||||
Text(selected.value.compactCurrencyString)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(selected.date.monthYearString)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var modePicker: some View {
|
||||
Picker("Evolution Mode", selection: $chartMode) {
|
||||
ForEach(ChartMode.allCases) { mode in
|
||||
Text(mode.rawValue).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var chartSection: some View {
|
||||
if data.count >= 2 {
|
||||
chartView
|
||||
} else {
|
||||
Text("Not enough data to display chart")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 200)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private var chartView: some View {
|
||||
Chart {
|
||||
chartMarks
|
||||
}
|
||||
.chartForegroundStyleScale(domain: chartCategoryNames, range: chartCategoryColors)
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .stride(by: .month, count: 3)) { value in
|
||||
AxisValueLabel(format: .dateTime.month(.abbreviated))
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) { value in
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(Decimal(doubleValue).shortCurrencyString)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartOverlay { proxy in
|
||||
GeometryReader { geometry in
|
||||
Rectangle()
|
||||
.fill(.clear)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
guard let plotFrameAnchor = proxy.plotFrame else { return }
|
||||
let plotFrame = geometry[plotFrameAnchor]
|
||||
let x = value.location.x - plotFrame.origin.x
|
||||
guard let date: Date = proxy.value(atX: x) else { return }
|
||||
|
||||
if let closest = data.min(by: {
|
||||
abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date))
|
||||
}) {
|
||||
selectedDataPoint = closest
|
||||
}
|
||||
}
|
||||
.onEnded { _ in
|
||||
selectedDataPoint = nil
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(height: 200)
|
||||
// Performance: Use GPU rendering for smoother scrolling
|
||||
.drawingGroup()
|
||||
}
|
||||
|
||||
@ChartContentBuilder
|
||||
private var chartMarks: some ChartContent {
|
||||
switch chartMode {
|
||||
case .total:
|
||||
ForEach(data, id: \.date) { item in
|
||||
LineMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.interpolationMethod(.catmullRom)
|
||||
|
||||
PointMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.symbolSize(26)
|
||||
|
||||
AreaMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary.opacity(0.3), Color.appPrimary.opacity(0.0)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.interpolationMethod(.catmullRom)
|
||||
}
|
||||
case .byCategory:
|
||||
ForEach(stackedCategoryData) { item in
|
||||
AreaMark(
|
||||
x: .value("Date", item.date),
|
||||
yStart: .value("Start", NSDecimalNumber(decimal: item.start).doubleValue),
|
||||
yEnd: .value("End", NSDecimalNumber(decimal: item.end).doubleValue)
|
||||
)
|
||||
.foregroundStyle(by: .value("Category", item.categoryName))
|
||||
.interpolationMethod(.catmullRom)
|
||||
}
|
||||
}
|
||||
|
||||
if showGoalLines {
|
||||
ForEach(goals) { goal in
|
||||
RuleMark(y: .value("Goal", NSDecimalNumber(decimal: goal.targetDecimal).doubleValue))
|
||||
.foregroundStyle(Color.appSecondary.opacity(0.5))
|
||||
.lineStyle(StrokeStyle(lineWidth: 1, dash: [6, 4]))
|
||||
.annotation(position: .topTrailing) {
|
||||
Text(goal.name)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let selected = selectedDataPoint, chartMode == .total {
|
||||
RuleMark(x: .value("Selected", selected.date))
|
||||
.foregroundStyle(Color.gray.opacity(0.3))
|
||||
.lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
|
||||
|
||||
PointMark(
|
||||
x: .value("Date", selected.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: selected.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.symbolSize(100)
|
||||
}
|
||||
}
|
||||
|
||||
private var chartCategoryNames: [String] {
|
||||
let names = Array(Set(categoryData.map { $0.categoryName })).sorted()
|
||||
return names
|
||||
}
|
||||
|
||||
private struct StackedCategoryPoint: Identifiable {
|
||||
let date: Date
|
||||
let categoryName: String
|
||||
let colorHex: String
|
||||
let start: Decimal
|
||||
let end: Decimal
|
||||
|
||||
var id: String {
|
||||
"\(categoryName)-\(date.timeIntervalSince1970)"
|
||||
}
|
||||
}
|
||||
|
||||
private var stackedCategoryData: [StackedCategoryPoint] {
|
||||
let grouped = Dictionary(grouping: categoryData) { $0.date }
|
||||
let dates = grouped.keys.sorted()
|
||||
let categories = chartCategoryNames
|
||||
var stacked: [StackedCategoryPoint] = []
|
||||
|
||||
for date in dates {
|
||||
let points = grouped[date] ?? []
|
||||
var running: Decimal = 0
|
||||
|
||||
for category in categories {
|
||||
let value = points.first(where: { $0.categoryName == category })?.value ?? 0
|
||||
let start = running
|
||||
let end = running + value
|
||||
running = end
|
||||
|
||||
if let colorHex = points.first(where: { $0.categoryName == category })?.colorHex {
|
||||
stacked.append(StackedCategoryPoint(
|
||||
date: date,
|
||||
categoryName: category,
|
||||
colorHex: colorHex,
|
||||
start: start,
|
||||
end: end
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return stacked
|
||||
}
|
||||
|
||||
private var chartCategoryColors: [Color] {
|
||||
chartCategoryNames.map { name in
|
||||
if let hex = categoryData.first(where: { $0.categoryName == name })?.colorHex {
|
||||
return Color(hex: hex) ?? .gray
|
||||
}
|
||||
return .gray
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mini Sparkline
|
||||
|
||||
struct SparklineView: View {
|
||||
let data: [(date: Date, value: Decimal)]
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
if data.count >= 2 {
|
||||
Chart(data, id: \.date) { item in
|
||||
LineMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(color)
|
||||
.interpolationMethod(.catmullRom)
|
||||
}
|
||||
.chartXAxis(.hidden)
|
||||
.chartYAxis(.hidden)
|
||||
.chartLegend(.hidden)
|
||||
} else {
|
||||
Rectangle()
|
||||
.fill(Color.gray.opacity(0.1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let sampleData: [(date: Date, value: Decimal)] = [
|
||||
(Date().adding(months: -6), 10000),
|
||||
(Date().adding(months: -5), 10500),
|
||||
(Date().adding(months: -4), 10200),
|
||||
(Date().adding(months: -3), 11000),
|
||||
(Date().adding(months: -2), 11500),
|
||||
(Date().adding(months: -1), 11200),
|
||||
(Date(), 12000)
|
||||
]
|
||||
|
||||
return EvolutionChartCard(data: sampleData, categoryData: [], goals: [])
|
||||
.padding()
|
||||
}
|
||||
@@ -0,0 +1,629 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MonthlyCheckInView: View {
|
||||
@EnvironmentObject var accountStore: AccountStore
|
||||
@StateObject private var viewModel = MonthlyCheckInViewModel()
|
||||
let referenceDate: Date
|
||||
let duplicatePrevious: Bool
|
||||
|
||||
@State private var monthlyNote: String
|
||||
@State private var starRating: Int
|
||||
@State private var selectedMood: MonthlyCheckInMood?
|
||||
@FocusState private var noteFocused: Bool
|
||||
@State private var editingSnapshot: Snapshot?
|
||||
@State private var addingSource: InvestmentSource?
|
||||
@State private var didApplyDuplicate = false
|
||||
|
||||
init(referenceDate: Date = Date(), duplicatePrevious: Bool = false) {
|
||||
self.referenceDate = referenceDate
|
||||
self.duplicatePrevious = duplicatePrevious
|
||||
_monthlyNote = State(initialValue: MonthlyCheckInStore.note(for: referenceDate))
|
||||
_starRating = State(initialValue: MonthlyCheckInStore.rating(for: referenceDate) ?? 0)
|
||||
_selectedMood = State(initialValue: MonthlyCheckInStore.mood(for: referenceDate))
|
||||
}
|
||||
|
||||
private var lastCompletionDate: Date? {
|
||||
MonthlyCheckInStore.latestCompletionDate()
|
||||
}
|
||||
|
||||
private var checkInProgress: Double {
|
||||
guard let last = lastCompletionDate,
|
||||
let nextDate = nextCheckInDate else { return 1 }
|
||||
let totalDays = Double(max(1, last.startOfDay.daysBetween(nextDate.startOfDay)))
|
||||
guard totalDays > 0 else { return 1 }
|
||||
let elapsedDays = Double(last.startOfDay.daysBetween(Date()))
|
||||
return min(max(elapsedDays / totalDays, 0), 1)
|
||||
}
|
||||
|
||||
private var checkInIntervalMonths: Int {
|
||||
if accountStore.showAllAccounts || accountStore.selectedAccount == nil {
|
||||
return NotificationFrequency.monthly.months
|
||||
}
|
||||
let account = accountStore.selectedAccount
|
||||
let frequency = account?.frequency ?? .monthly
|
||||
if frequency == .custom {
|
||||
return max(1, Int(account?.customFrequencyMonths ?? 1))
|
||||
}
|
||||
if frequency == .never {
|
||||
return NotificationFrequency.monthly.months
|
||||
}
|
||||
return frequency.months
|
||||
}
|
||||
|
||||
private var nextCheckInDate: Date? {
|
||||
guard let last = lastCompletionDate else { return nil }
|
||||
return last.adding(months: checkInIntervalMonths)
|
||||
}
|
||||
|
||||
private var canAddNewCheckIn: Bool {
|
||||
lastCompletionDate == nil || checkInProgress >= 0.7
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
headerCard
|
||||
summaryCard
|
||||
reflectionCard
|
||||
sourcesCard
|
||||
notesCard
|
||||
journalCard
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("Monthly Check-in")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear {
|
||||
viewModel.selectedAccount = accountStore.selectedAccount
|
||||
viewModel.showAllAccounts = accountStore.showAllAccounts
|
||||
viewModel.selectedRange = DateRange.month(containing: referenceDate)
|
||||
if duplicatePrevious, !didApplyDuplicate {
|
||||
viewModel.duplicatePreviousMonthSnapshots(referenceDate: referenceDate)
|
||||
didApplyDuplicate = true
|
||||
}
|
||||
viewModel.refresh()
|
||||
monthlyNote = MonthlyCheckInStore.note(for: referenceDate)
|
||||
starRating = MonthlyCheckInStore.rating(for: referenceDate) ?? 0
|
||||
selectedMood = MonthlyCheckInStore.mood(for: referenceDate)
|
||||
}
|
||||
.onReceive(accountStore.$selectedAccount) { account in
|
||||
viewModel.selectedAccount = account
|
||||
viewModel.selectedRange = DateRange.month(containing: referenceDate)
|
||||
viewModel.refresh()
|
||||
}
|
||||
.onReceive(accountStore.$showAllAccounts) { showAll in
|
||||
viewModel.showAllAccounts = showAll
|
||||
viewModel.selectedRange = DateRange.month(containing: referenceDate)
|
||||
viewModel.refresh()
|
||||
}
|
||||
.sheet(item: $editingSnapshot) { snapshot in
|
||||
if let source = snapshot.source {
|
||||
AddSnapshotView(source: source, snapshot: snapshot)
|
||||
}
|
||||
}
|
||||
.sheet(item: $addingSource) { source in
|
||||
AddSnapshotView(source: source)
|
||||
}
|
||||
.onChange(of: starRating) { _, newValue in
|
||||
MonthlyCheckInStore.setRating(newValue == 0 ? nil : newValue, for: referenceDate)
|
||||
}
|
||||
.onChange(of: selectedMood) { _, newValue in
|
||||
MonthlyCheckInStore.setMood(newValue, for: referenceDate)
|
||||
}
|
||||
}
|
||||
|
||||
private var headerCard: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("This Month")
|
||||
.font(.headline)
|
||||
|
||||
if let date = lastCompletionDate {
|
||||
Text(
|
||||
String(
|
||||
format: NSLocalizedString("last_check_in", comment: ""),
|
||||
date.friendlyDescription
|
||||
)
|
||||
)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Text("No check-in yet this month")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ProgressView(value: checkInProgress)
|
||||
.tint(.appSecondary)
|
||||
|
||||
if let nextDate = nextCheckInDate {
|
||||
Text(
|
||||
String(
|
||||
format: NSLocalizedString("next_check_in", comment: ""),
|
||||
nextDate.mediumDateString
|
||||
)
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Text("Start your first check-in anytime.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
let now = Date()
|
||||
let completionDate = referenceDate.isSameMonth(as: now)
|
||||
? now
|
||||
: min(referenceDate.endOfMonth, now)
|
||||
MonthlyCheckInStore.setCompletionDate(completionDate, for: referenceDate)
|
||||
viewModel.refresh()
|
||||
} label: {
|
||||
Text("Mark Check-in Complete")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.appPrimary.opacity(0.1))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
.disabled(!canAddNewCheckIn)
|
||||
|
||||
if !canAddNewCheckIn {
|
||||
Text("Editing stays open. New check-ins unlock after 70% of the month.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private var reflectionCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Monthly Pulse")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Text("Optional")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Rate this month")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
HStack(spacing: 8) {
|
||||
ForEach(1...5, id: \.self) { value in
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: AppConstants.Animation.shortDuration)) {
|
||||
starRating = value == starRating ? 0 : value
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: value <= starRating ? "star.fill" : "star")
|
||||
.font(.title3)
|
||||
.foregroundColor(value <= starRating ? .appSecondary : .secondary)
|
||||
.padding(8)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(value <= starRating ? Color.appSecondary.opacity(0.12) : Color.gray.opacity(0.08))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: AppConstants.Animation.shortDuration)) {
|
||||
starRating = 0
|
||||
}
|
||||
} label: {
|
||||
Text("Skip")
|
||||
.font(.caption.weight(.semibold))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.gray.opacity(0.12))
|
||||
.cornerRadius(AppConstants.UI.smallCornerRadius)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("How did it feel?")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(MonthlyCheckInMood.allCases) { mood in
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: AppConstants.Animation.shortDuration)) {
|
||||
selectedMood = selectedMood == mood ? nil : mood
|
||||
}
|
||||
} label: {
|
||||
moodPill(for: mood)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private var summaryCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Monthly Summary")
|
||||
.font(.headline)
|
||||
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Starting")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(viewModel.monthlySummary.formattedStartingValue)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Text("Ending")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(viewModel.monthlySummary.formattedEndingValue)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Contributions")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(viewModel.monthlySummary.formattedContributions)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Text("Net Performance")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text("\(viewModel.monthlySummary.formattedNetPerformance) (\(viewModel.monthlySummary.formattedNetPerformancePercentage))")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(viewModel.monthlySummary.netPerformance >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private var sourcesCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Update Sources")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Text("\(viewModel.sources.count)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if viewModel.sources.isEmpty {
|
||||
Text("Add sources to start your monthly check-in.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
ForEach(viewModel.sources) { source in
|
||||
let latestSnapshot = source.latestSnapshot
|
||||
let updatedThisCycle = isSnapshotInCurrentCycle(latestSnapshot)
|
||||
Button {
|
||||
if updatedThisCycle, let snapshot = latestSnapshot {
|
||||
editingSnapshot = snapshot
|
||||
} else {
|
||||
addingSource = source
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(source.category?.color ?? .gray)
|
||||
.frame(width: 8, height: 8)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(source.name)
|
||||
.font(.subheadline.weight(.medium))
|
||||
Text(updatedThisCycle ? "Updated this cycle" : "Needs update")
|
||||
.font(.caption2)
|
||||
.foregroundColor(updatedThisCycle ? .positiveGreen : .secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(latestSnapshot?.date.relativeDescription ?? String(localized: "date_never"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private var notesCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Monthly Note")
|
||||
.font(.headline)
|
||||
|
||||
TextEditor(text: $monthlyNote)
|
||||
.frame(minHeight: 120)
|
||||
.padding(8)
|
||||
.background(Color.gray.opacity(0.08))
|
||||
.cornerRadius(12)
|
||||
.focused($noteFocused)
|
||||
.onChange(of: monthlyNote) { _, newValue in
|
||||
MonthlyCheckInStore.setNote(newValue, for: referenceDate)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
NavigationLink {
|
||||
MonthlyNoteEditorView(date: referenceDate, note: $monthlyNote)
|
||||
} label: {
|
||||
Text("Open Full Note")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.appSecondary.opacity(0.12))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button {
|
||||
viewModel.duplicatePreviousMonthSnapshots(referenceDate: referenceDate)
|
||||
viewModel.refresh()
|
||||
} label: {
|
||||
Text("Duplicate Previous")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.appPrimary.opacity(0.12))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private var journalCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Snapshot Notes")
|
||||
.font(.headline)
|
||||
|
||||
if viewModel.recentNotes.isEmpty {
|
||||
Text("No snapshot notes for this month.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
ForEach(viewModel.recentNotes) { snapshot in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(snapshot.source?.name ?? "Source")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(snapshot.notes ?? "")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
Text(snapshot.date.friendlyDescription)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if snapshot.id != viewModel.recentNotes.last?.id {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
struct AchievementsView: View {
|
||||
let referenceDate: Date
|
||||
|
||||
private var achievementStatuses: [MonthlyCheckInAchievementStatus] {
|
||||
MonthlyCheckInStore.achievementStatuses(referenceDate: referenceDate)
|
||||
}
|
||||
|
||||
private var unlockedAchievements: [MonthlyCheckInAchievementStatus] {
|
||||
achievementStatuses.filter { $0.isUnlocked }
|
||||
}
|
||||
|
||||
private var lockedAchievements: [MonthlyCheckInAchievementStatus] {
|
||||
achievementStatuses.filter { !$0.isUnlocked }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
headerCard
|
||||
|
||||
achievementSection(
|
||||
title: String(localized: "achievements_unlocked_title"),
|
||||
subtitle: unlockedAchievements.isEmpty
|
||||
? String(localized: "achievements_unlocked_empty")
|
||||
: nil,
|
||||
achievements: unlockedAchievements,
|
||||
isLocked: false
|
||||
)
|
||||
|
||||
achievementSection(
|
||||
title: String(localized: "achievements_locked_title"),
|
||||
subtitle: lockedAchievements.isEmpty
|
||||
? String(localized: "achievements_locked_empty")
|
||||
: nil,
|
||||
achievements: lockedAchievements,
|
||||
isLocked: true
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle(String(localized: "achievements_nav_title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private var headerCard: some View {
|
||||
let total = max(achievementStatuses.count, 1)
|
||||
let unlockedCount = unlockedAchievements.count
|
||||
let progress = Double(unlockedCount) / Double(total)
|
||||
|
||||
return VStack(alignment: .leading, spacing: 8) {
|
||||
Text(String(localized: "achievements_progress_title"))
|
||||
.font(.headline)
|
||||
ProgressView(value: progress)
|
||||
.tint(.appSecondary)
|
||||
Text(
|
||||
String(
|
||||
format: NSLocalizedString("achievements_unlocked_count", comment: ""),
|
||||
unlockedCount,
|
||||
achievementStatuses.count
|
||||
)
|
||||
)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private func achievementSection(
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
achievements: [MonthlyCheckInAchievementStatus],
|
||||
isLocked: Bool
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if achievements.isEmpty {
|
||||
EmptyView()
|
||||
} else {
|
||||
ForEach(achievements) { status in
|
||||
achievementRow(status, isLocked: isLocked)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private func achievementRow(_ status: MonthlyCheckInAchievementStatus, isLocked: Bool) -> some View {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(isLocked ? Color.gray.opacity(0.2) : Color.appSecondary.opacity(0.18))
|
||||
.frame(width: 42, height: 42)
|
||||
Image(systemName: status.achievement.icon)
|
||||
.font(.headline)
|
||||
.foregroundColor(isLocked ? .secondary : .appSecondary)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(status.achievement.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(status.achievement.detail)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if isLocked {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(isLocked ? Color.gray.opacity(0.08) : Color.appSecondary.opacity(0.12))
|
||||
.cornerRadius(AppConstants.UI.smallCornerRadius)
|
||||
}
|
||||
}
|
||||
|
||||
private extension MonthlyCheckInView {
|
||||
func isSnapshotInCurrentCycle(_ snapshot: Snapshot?) -> Bool {
|
||||
guard let snapshot, let last = lastCompletionDate else { return false }
|
||||
return snapshot.date >= Calendar.current.startOfDay(for: last)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func moodPill(for mood: MonthlyCheckInMood) -> some View {
|
||||
let isSelected = selectedMood == mood
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
Image(systemName: mood.iconName)
|
||||
.font(.body)
|
||||
.foregroundColor(isSelected ? moodColor(for: mood) : .secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(mood.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(mood.detail)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(isSelected ? moodColor(for: mood).opacity(0.16) : Color.gray.opacity(0.08))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: AppConstants.UI.smallCornerRadius)
|
||||
.stroke(isSelected ? moodColor(for: mood) : Color.clear, lineWidth: 1)
|
||||
)
|
||||
.cornerRadius(AppConstants.UI.smallCornerRadius)
|
||||
}
|
||||
|
||||
func moodColor(for mood: MonthlyCheckInMood) -> Color {
|
||||
switch mood {
|
||||
case .energized: return .appSecondary
|
||||
case .confident: return .appPrimary
|
||||
case .balanced: return .teal
|
||||
case .cautious: return .orange
|
||||
case .stressed: return .red
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
MonthlyCheckInView()
|
||||
.environmentObject(AccountStore(iapService: IAPService()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GoalEditorView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var name = ""
|
||||
@State private var targetAmount = ""
|
||||
@State private var targetDate = Date()
|
||||
@State private var includeTargetDate = false
|
||||
@State private var didLoadGoal = false
|
||||
|
||||
let account: Account?
|
||||
let goal: Goal?
|
||||
private let goalRepository = GoalRepository()
|
||||
|
||||
init(account: Account?, goal: Goal? = nil) {
|
||||
self.account = account
|
||||
self.goal = goal
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField("Goal name", text: $name)
|
||||
TextField("Target amount", text: $targetAmount)
|
||||
.keyboardType(.decimalPad)
|
||||
|
||||
Toggle("Add target date", isOn: $includeTargetDate)
|
||||
if includeTargetDate {
|
||||
DatePicker("Target date", selection: $targetDate, displayedComponents: .date)
|
||||
.datePickerStyle(.graphical)
|
||||
}
|
||||
} header: {
|
||||
Text("Goal Details")
|
||||
}
|
||||
}
|
||||
.navigationTitle(goal == nil ? "New Goal" : "Edit Goal")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") { saveGoal() }
|
||||
.disabled(!isValid)
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
.onAppear {
|
||||
guard let goal, !didLoadGoal else { return }
|
||||
name = goal.name ?? ""
|
||||
if let amount = goal.targetAmount?.decimalValue {
|
||||
targetAmount = NSDecimalNumber(decimal: amount).stringValue
|
||||
}
|
||||
if let target = goal.targetDate {
|
||||
includeTargetDate = true
|
||||
targetDate = target
|
||||
}
|
||||
didLoadGoal = true
|
||||
}
|
||||
}
|
||||
|
||||
private var isValid: Bool {
|
||||
!name.trimmingCharacters(in: .whitespaces).isEmpty && parseDecimal(targetAmount) != nil
|
||||
}
|
||||
|
||||
private func saveGoal() {
|
||||
guard let value = parseDecimal(targetAmount) else { return }
|
||||
if let goal {
|
||||
goalRepository.updateGoal(
|
||||
goal,
|
||||
name: name,
|
||||
targetAmount: value,
|
||||
targetDate: includeTargetDate ? targetDate : nil,
|
||||
clearTargetDate: !includeTargetDate
|
||||
)
|
||||
} else {
|
||||
goalRepository.createGoal(
|
||||
name: name,
|
||||
targetAmount: value,
|
||||
targetDate: includeTargetDate ? targetDate : nil,
|
||||
account: account
|
||||
)
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func parseDecimal(_ value: String) -> Decimal? {
|
||||
let cleaned = value
|
||||
.replacingOccurrences(of: ",", with: ".")
|
||||
.replacingOccurrences(of: " ", with: "")
|
||||
return Decimal(string: cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
GoalEditorView(account: nil)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GoalProgressBar: View {
|
||||
let progress: Double
|
||||
var tint: Color = .appSecondary
|
||||
var background: Color = Color.gray.opacity(0.15)
|
||||
var iconName: String = "flag.checkered"
|
||||
var iconColor: Color = .appSecondary
|
||||
|
||||
private var clampedProgress: CGFloat {
|
||||
CGFloat(min(max(progress, 0), 1))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let width = geometry.size.width
|
||||
let iconOffset = max(0, width * clampedProgress - 10)
|
||||
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(background)
|
||||
Capsule()
|
||||
.fill(tint)
|
||||
.frame(width: width * clampedProgress)
|
||||
}
|
||||
.overlay(alignment: .leading) {
|
||||
Image(systemName: iconName)
|
||||
.font(.caption2)
|
||||
.foregroundColor(iconColor)
|
||||
.offset(x: iconOffset)
|
||||
}
|
||||
}
|
||||
.frame(height: 8)
|
||||
.accessibilityLabel("Goal progress")
|
||||
.accessibilityValue("\(Int(progress * 100)) percent")
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
GoalProgressBar(progress: 0.45)
|
||||
.padding()
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GoalShareCardView: View {
|
||||
let name: String
|
||||
let progress: Double
|
||||
let currentValue: Decimal
|
||||
let targetValue: Decimal
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Goal Progress")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
Text(name)
|
||||
.font(.title2.weight(.bold))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "sparkles")
|
||||
.font(.title2)
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
}
|
||||
|
||||
GoalProgressBar(
|
||||
progress: progress,
|
||||
tint: .white.opacity(0.9),
|
||||
background: .white.opacity(0.2),
|
||||
iconName: "sparkles",
|
||||
iconColor: .white
|
||||
)
|
||||
.frame(height: 10)
|
||||
|
||||
HStack {
|
||||
Text(currentValue.currencyString)
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
Spacer()
|
||||
Text("of \(targetValue.currencyString)")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
}
|
||||
|
||||
HStack {
|
||||
Image(systemName: "arrow.up.right")
|
||||
Text("Track yours in Portfolio Journal")
|
||||
}
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.85))
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: 320, height: 220)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary, Color.appSecondary],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
GoalShareCardView(
|
||||
name: "1M Goal",
|
||||
progress: 0.42,
|
||||
currentValue: 420_000,
|
||||
targetValue: 1_000_000
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GoalsView: View {
|
||||
@EnvironmentObject private var accountStore: AccountStore
|
||||
@StateObject private var viewModel = GoalsViewModel()
|
||||
@State private var showingAddGoal = false
|
||||
@State private var editingGoal: Goal?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
AppBackground()
|
||||
|
||||
List {
|
||||
if viewModel.goals.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
Section {
|
||||
ForEach(viewModel.goals) { goal in
|
||||
GoalRowView(
|
||||
goal: goal,
|
||||
progress: viewModel.progress(for: goal),
|
||||
totalValue: viewModel.totalValue(for: goal),
|
||||
paceStatus: viewModel.paceStatus(for: goal)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
editingGoal = goal
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
viewModel.deleteGoal(goal)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: false) {
|
||||
Button {
|
||||
editingGoal = goal
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(.appSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
.navigationTitle("Goals")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
showingAddGoal = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingAddGoal) {
|
||||
GoalEditorView(account: accountStore.showAllAccounts ? nil : accountStore.selectedAccount)
|
||||
}
|
||||
.sheet(item: $editingGoal) { goal in
|
||||
GoalEditorView(account: goal.account, goal: goal)
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.selectedAccount = accountStore.selectedAccount
|
||||
viewModel.showAllAccounts = accountStore.showAllAccounts
|
||||
viewModel.refresh()
|
||||
}
|
||||
.onReceive(accountStore.$selectedAccount) { account in
|
||||
viewModel.selectedAccount = account
|
||||
viewModel.refresh()
|
||||
}
|
||||
.onReceive(accountStore.$showAllAccounts) { showAll in
|
||||
viewModel.showAllAccounts = showAll
|
||||
viewModel.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "target")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.secondary)
|
||||
Text("Set your first goal")
|
||||
.font(.headline)
|
||||
Text("Track progress toward milestones like \(AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currencySymbol)1M and share your wins.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 32)
|
||||
}
|
||||
}
|
||||
|
||||
struct GoalRowView: View {
|
||||
let goal: Goal
|
||||
let progress: Double
|
||||
let totalValue: Decimal
|
||||
let paceStatus: GoalPaceStatus?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text(goal.name)
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button {
|
||||
GoalShareService.shared.shareGoal(
|
||||
name: goal.name,
|
||||
progress: progress,
|
||||
currentValue: totalValue,
|
||||
targetValue: goal.targetDecimal
|
||||
)
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.foregroundColor(.appPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
GoalProgressBar(progress: progress, tint: .appSecondary, iconColor: .appSecondary)
|
||||
|
||||
HStack {
|
||||
Text(totalValue.currencyString)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Spacer()
|
||||
Text("of \(goal.targetDecimal.currencyString)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if let targetDate = goal.targetDate {
|
||||
Text("Target date: \(targetDate.mediumDateString)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if let paceStatus {
|
||||
Text(paceStatus.statusText)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(paceStatus.isBehind ? .negativeRed : .positiveGreen)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
GoalsView()
|
||||
.environmentObject(AccountStore(iapService: IAPService()))
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import SwiftUI
|
||||
|
||||
struct JournalView: View {
|
||||
@StateObject private var viewModel = JournalViewModel()
|
||||
@State private var searchText = ""
|
||||
@State private var scrubberActive = false
|
||||
@State private var scrubberLabel = ""
|
||||
@State private var scrubberOffset: CGFloat = 0
|
||||
@State private var currentVisibleMonth: Date?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollViewReader { proxy in
|
||||
ZStack {
|
||||
AppBackground()
|
||||
|
||||
List {
|
||||
Section("Monthly Check-ins") {
|
||||
if filteredMonthlyNotes.isEmpty {
|
||||
Text(searchText.isEmpty ? "No monthly notes yet." : "No matching notes.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
ForEach(filteredMonthlyNotes) { entry in
|
||||
NavigationLink {
|
||||
MonthlyCheckInView(referenceDate: entry.date)
|
||||
} label: {
|
||||
monthlyNoteRow(entry)
|
||||
}
|
||||
.id(entry.date)
|
||||
.onAppear {
|
||||
currentVisibleMonth = entry.date
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
.overlay(alignment: .trailing) {
|
||||
monthScrubber(proxy: proxy)
|
||||
}
|
||||
.navigationTitle("Journal")
|
||||
.searchable(text: $searchText, prompt: "Search monthly notes")
|
||||
.onAppear {
|
||||
viewModel.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var filteredMonthlyNotes: [MonthlyNoteItem] {
|
||||
let trimmedQuery = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let hasQuery = !trimmedQuery.isEmpty
|
||||
let query = trimmedQuery.lowercased()
|
||||
return viewModel.monthlyNotes.filter { entry in
|
||||
!hasQuery
|
||||
|| entry.note.lowercased().contains(query)
|
||||
|| entry.date.monthYearString.lowercased().contains(query)
|
||||
}
|
||||
}
|
||||
|
||||
private func monthlyNoteRow(_ entry: MonthlyNoteItem) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text(entry.date.monthYearString)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Spacer()
|
||||
if let mood = entry.mood {
|
||||
moodPill(mood)
|
||||
} else {
|
||||
Text("Mood not set")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 6) {
|
||||
starRow(rating: entry.rating)
|
||||
if entry.rating == nil {
|
||||
Text("No rating")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Text(entry.note.isEmpty ? "No note yet." : entry.note)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func starRow(rating: Int?) -> some View {
|
||||
let value = rating ?? 0
|
||||
return HStack(spacing: 4) {
|
||||
ForEach(1...5, id: \.self) { index in
|
||||
Image(systemName: index <= value ? "star.fill" : "star")
|
||||
.foregroundColor(index <= value ? .yellow : .secondary)
|
||||
}
|
||||
}
|
||||
.accessibilityLabel(
|
||||
Text(String(format: NSLocalizedString("rating_accessibility", comment: ""), value))
|
||||
)
|
||||
}
|
||||
|
||||
private func moodPill(_ mood: MonthlyCheckInMood) -> some View {
|
||||
Label(mood.title, systemImage: mood.iconName)
|
||||
.font(.caption.weight(.semibold))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.gray.opacity(0.15))
|
||||
.foregroundColor(.primary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
private func monthScrubber(proxy: ScrollViewProxy) -> some View {
|
||||
GeometryReader { geometry in
|
||||
let height = geometry.size.height
|
||||
let count = filteredMonthlyNotes.count
|
||||
let currentIndex = currentVisibleMonth.flatMap { month in
|
||||
filteredMonthlyNotes.firstIndex(where: { $0.date.isSameMonth(as: month) })
|
||||
}
|
||||
|
||||
ZStack(alignment: .trailing) {
|
||||
if let currentVisibleMonth, !scrubberActive {
|
||||
Text(currentVisibleMonth.monthYearString)
|
||||
.font(.caption.weight(.semibold))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(.systemBackground).opacity(0.95))
|
||||
.cornerRadius(12)
|
||||
.shadow(color: .black.opacity(0.12), radius: 6, y: 2)
|
||||
.offset(x: -16)
|
||||
}
|
||||
|
||||
if scrubberActive {
|
||||
Text(scrubberLabel)
|
||||
.font(.caption.weight(.semibold))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(.systemBackground).opacity(0.95))
|
||||
.cornerRadius(12)
|
||||
.shadow(color: .black.opacity(0.12), radius: 6, y: 2)
|
||||
.offset(x: -16, y: scrubberOffset - height / 2)
|
||||
}
|
||||
|
||||
if let currentIndex, count > 1 {
|
||||
let progress = CGFloat(currentIndex) / CGFloat(max(count - 1, 1))
|
||||
Circle()
|
||||
.fill(Color.appSecondary.opacity(0.9))
|
||||
.frame(width: 12, height: 12)
|
||||
.offset(y: progress * height - height / 2)
|
||||
}
|
||||
|
||||
Capsule()
|
||||
.fill(Color.secondary.opacity(0.35))
|
||||
.frame(width: 6)
|
||||
.frame(maxHeight: .infinity, alignment: .trailing)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing)
|
||||
.padding(.trailing, 8)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
guard count > 0 else { return }
|
||||
scrubberActive = true
|
||||
let clampedY = min(max(value.location.y, 0), height)
|
||||
scrubberOffset = clampedY
|
||||
let ratio = clampedY / max(height, 1)
|
||||
let index = min(max(Int(round(ratio * CGFloat(count - 1))), 0), count - 1)
|
||||
let entry = filteredMonthlyNotes[index]
|
||||
scrubberLabel = entry.date.monthYearString
|
||||
proxy.scrollTo(entry.id, anchor: .top)
|
||||
}
|
||||
.onEnded { _ in
|
||||
scrubberActive = false
|
||||
}
|
||||
)
|
||||
.allowsHitTesting(count > 0)
|
||||
}
|
||||
.frame(width: 72)
|
||||
}
|
||||
}
|
||||
|
||||
struct MonthlyNoteEditorView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let date: Date
|
||||
@Binding var note: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(date.monthYearString)
|
||||
.font(.headline)
|
||||
|
||||
TextEditor(text: $note)
|
||||
.frame(minHeight: 200)
|
||||
.padding(8)
|
||||
.background(Color.gray.opacity(0.08))
|
||||
.cornerRadius(12)
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("Monthly Note")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
MonthlyCheckInStore.setNote(note, for: date)
|
||||
dismiss()
|
||||
}
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
JournalView()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OnboardingView: View {
|
||||
@Binding var onboardingCompleted: Bool
|
||||
|
||||
@State private var currentPage = 0
|
||||
@State private var selectedCurrency = Locale.current.currency?.identifier ?? "EUR"
|
||||
@State private var useSampleData = true
|
||||
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
|
||||
@AppStorage("cloudSyncEnabled") private var cloudSyncEnabled = false
|
||||
@State private var showingImportSheet = false
|
||||
@State private var showingAddSource = false
|
||||
|
||||
private let pages: [OnboardingPage] = [
|
||||
OnboardingPage(
|
||||
icon: "chart.pie.fill",
|
||||
title: "Long-Term Tracking",
|
||||
description: "A calm, offline-first portfolio tracker for investors who update monthly.",
|
||||
color: .appPrimary
|
||||
),
|
||||
OnboardingPage(
|
||||
icon: "calendar.circle.fill",
|
||||
title: "Monthly Check-ins",
|
||||
description: "Build a deliberate habit. Update sources, log contributions, and add a short note.",
|
||||
color: .positiveGreen
|
||||
),
|
||||
OnboardingPage(
|
||||
icon: "bell.badge.fill",
|
||||
title: "Gentle Reminders",
|
||||
description: "Get a monthly nudge to review your portfolio without realtime noise.",
|
||||
color: .appWarning
|
||||
),
|
||||
OnboardingPage(
|
||||
icon: "leaf.fill",
|
||||
title: "Calm Mode",
|
||||
description: "Hide short-term swings and focus on contributions and long-term growth.",
|
||||
color: .appSecondary
|
||||
)
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
// Pages
|
||||
TabView(selection: $currentPage) {
|
||||
ForEach(0..<pages.count, id: \.self) { index in
|
||||
OnboardingPageView(page: pages[index])
|
||||
.tag(index)
|
||||
}
|
||||
|
||||
OnboardingQuickStartView(
|
||||
selectedCurrency: $selectedCurrency,
|
||||
useSampleData: $useSampleData,
|
||||
calmModeEnabled: $calmModeEnabled,
|
||||
cloudSyncEnabled: $cloudSyncEnabled,
|
||||
onImport: { showingImportSheet = true },
|
||||
onAddSource: { showingAddSource = true }
|
||||
)
|
||||
.tag(pages.count)
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.animation(.easeInOut, value: currentPage)
|
||||
|
||||
// Page indicators
|
||||
HStack(spacing: 8) {
|
||||
ForEach(0..<(pages.count + 1), id: \.self) { index in
|
||||
Circle()
|
||||
.fill(currentPage == index ? Color.appPrimary : Color.gray.opacity(0.3))
|
||||
.frame(width: 8, height: 8)
|
||||
.animation(.easeInOut, value: currentPage)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 20)
|
||||
|
||||
// Buttons
|
||||
VStack(spacing: 12) {
|
||||
if currentPage < pages.count {
|
||||
Button {
|
||||
withAnimation {
|
||||
currentPage += 1
|
||||
}
|
||||
} label: {
|
||||
Text("Continue")
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.appPrimary)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
|
||||
Button {
|
||||
completeOnboarding()
|
||||
} label: {
|
||||
Text("Skip")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
completeOnboarding()
|
||||
} label: {
|
||||
Text("Get Started")
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.appPrimary)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
.background(AppBackground())
|
||||
.onAppear {
|
||||
selectedCurrency = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
|
||||
}
|
||||
.sheet(isPresented: $showingImportSheet) {
|
||||
ImportDataView()
|
||||
}
|
||||
.sheet(isPresented: $showingAddSource) {
|
||||
AddSourceView()
|
||||
}
|
||||
}
|
||||
|
||||
private func completeOnboarding() {
|
||||
// Create default categories
|
||||
let categoryRepository = CategoryRepository()
|
||||
categoryRepository.createDefaultCategoriesIfNeeded()
|
||||
|
||||
// Create default account if needed
|
||||
AccountRepository().createDefaultAccountIfNeeded()
|
||||
|
||||
// Mark onboarding as complete
|
||||
let context = CoreDataStack.shared.viewContext
|
||||
let settings = AppSettings.getOrCreate(in: context)
|
||||
settings.currency = selectedCurrency
|
||||
settings.onboardingCompleted = true
|
||||
CoreDataStack.shared.save()
|
||||
|
||||
if useSampleData {
|
||||
SampleDataService.shared.seedSampleData()
|
||||
}
|
||||
|
||||
// Log analytics
|
||||
FirebaseService.shared.logOnboardingCompleted(stepCount: pages.count + 1)
|
||||
|
||||
// Request notification permission
|
||||
Task {
|
||||
await NotificationService.shared.requestAuthorization()
|
||||
}
|
||||
|
||||
withAnimation {
|
||||
onboardingCompleted = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Onboarding Page Model
|
||||
|
||||
struct OnboardingPage {
|
||||
let icon: String
|
||||
let title: String
|
||||
let description: String
|
||||
let color: Color
|
||||
}
|
||||
|
||||
// MARK: - Onboarding Page View
|
||||
|
||||
struct OnboardingPageView: View {
|
||||
let page: OnboardingPage
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 32) {
|
||||
Spacer()
|
||||
|
||||
// Icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(page.color.opacity(0.15))
|
||||
.frame(width: 140, height: 140)
|
||||
|
||||
Circle()
|
||||
.fill(page.color.opacity(0.3))
|
||||
.frame(width: 100, height: 100)
|
||||
|
||||
Image(systemName: page.icon)
|
||||
.font(.system(size: 50))
|
||||
.foregroundColor(page.color)
|
||||
}
|
||||
|
||||
// Content
|
||||
VStack(spacing: 16) {
|
||||
Text(page.title)
|
||||
.font(.title.weight(.bold))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text(page.description)
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Quick Start
|
||||
|
||||
struct OnboardingQuickStartView: View {
|
||||
@Binding var selectedCurrency: String
|
||||
@Binding var useSampleData: Bool
|
||||
@Binding var calmModeEnabled: Bool
|
||||
@Binding var cloudSyncEnabled: Bool
|
||||
let onImport: () -> Void
|
||||
let onAddSource: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Image("BrandMark")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 72, height: 72)
|
||||
|
||||
Text("Quick Start")
|
||||
.font(.title.weight(.bold))
|
||||
|
||||
Text("Pick your currency and start with sample data or import your own.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 30)
|
||||
}
|
||||
|
||||
VStack(spacing: 12) {
|
||||
HStack {
|
||||
Text("Currency")
|
||||
Spacer()
|
||||
Picker("Currency", selection: $selectedCurrency) {
|
||||
ForEach(CurrencyPicker.commonCodes, id: \.self) { code in
|
||||
Text(code).tag(code)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
|
||||
Toggle("Load sample portfolio", isOn: $useSampleData)
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
|
||||
Toggle("Enable Calm Mode (recommended)", isOn: $calmModeEnabled)
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
|
||||
Toggle("Sync with iCloud (optional)", isOn: $cloudSyncEnabled)
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
|
||||
if cloudSyncEnabled {
|
||||
Text("iCloud sync starts after you restart the app.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
onImport()
|
||||
} label: {
|
||||
Label("Import", systemImage: "square.and.arrow.down")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.padding()
|
||||
.background(Color.appPrimary.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
|
||||
Button {
|
||||
onAddSource()
|
||||
} label: {
|
||||
Label("Add Source", systemImage: "plus")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.padding()
|
||||
.background(Color.appSecondary.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - First Launch Welcome
|
||||
|
||||
struct WelcomeView: View {
|
||||
@Binding var showWelcome: Bool
|
||||
let userName: String?
|
||||
@AppStorage("cloudSyncEnabled") private var cloudSyncEnabled = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "hand.wave.fill")
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.appPrimary)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
if let name = userName {
|
||||
Text("Welcome back, \(name)!")
|
||||
.font(.title.weight(.bold))
|
||||
} else {
|
||||
Text("Welcome!")
|
||||
.font(.title.weight(.bold))
|
||||
}
|
||||
|
||||
Text(cloudSyncEnabled
|
||||
? "Your investment data has been synced from iCloud."
|
||||
: "Your investment data stays on this device.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
withAnimation {
|
||||
showWelcome = false
|
||||
}
|
||||
} label: {
|
||||
Text("Continue")
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.appPrimary)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
.background(Color(.systemBackground))
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
OnboardingView(onboardingCompleted: .constant(false))
|
||||
.environmentObject(AccountStore(iapService: IAPService()))
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PaywallView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject var iapService: IAPService
|
||||
|
||||
@State private var isPurchasing = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var showingError = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
AppBackground()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
// Header
|
||||
headerSection
|
||||
|
||||
// Features List
|
||||
featuresSection
|
||||
|
||||
// Price Card
|
||||
priceCard
|
||||
|
||||
// Purchase Button
|
||||
purchaseButton
|
||||
|
||||
// Restore Button
|
||||
restoreButton
|
||||
|
||||
// Legal
|
||||
legalSection
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.navigationTitle("Upgrade to Premium")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Error", isPresented: $showingError) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(errorMessage ?? "An error occurred")
|
||||
}
|
||||
.onChange(of: iapService.isPremium) { _, isPremium in
|
||||
if isPremium {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header Section
|
||||
|
||||
private var headerSection: some View {
|
||||
VStack(spacing: 16) {
|
||||
// Crown icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Color.yellow.opacity(0.3), Color.orange.opacity(0.3)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.frame(width: 80, height: 80)
|
||||
|
||||
Image(systemName: "crown.fill")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [.yellow, .orange],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Text("Unlock Full Potential")
|
||||
.font(.title.weight(.bold))
|
||||
|
||||
Text("Get unlimited access to all features with a one-time purchase")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Features Section
|
||||
|
||||
private var featuresSection: some View {
|
||||
VStack(spacing: 12) {
|
||||
ForEach(IAPService.premiumFeatures, id: \.title) { feature in
|
||||
FeatureRow(
|
||||
icon: feature.icon,
|
||||
title: feature.title,
|
||||
description: feature.description
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
// MARK: - Price Card
|
||||
|
||||
private var priceCard: some View {
|
||||
VStack(spacing: 8) {
|
||||
HStack(alignment: .top, spacing: 4) {
|
||||
Text("€")
|
||||
.font(.title2.weight(.semibold))
|
||||
.foregroundColor(.appPrimary)
|
||||
|
||||
Text("4.69")
|
||||
.font(.system(size: 48, weight: .bold, design: .rounded))
|
||||
.foregroundColor(.appPrimary)
|
||||
}
|
||||
|
||||
Text("One-time purchase")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "person.2.fill")
|
||||
.font(.caption)
|
||||
Text("Includes Family Sharing")
|
||||
.font(.caption)
|
||||
}
|
||||
.foregroundColor(.appSecondary)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 24)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)
|
||||
.fill(Color.appPrimary.opacity(0.1))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)
|
||||
.stroke(Color.appPrimary, lineWidth: 2)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Purchase Button
|
||||
|
||||
private var purchaseButton: some View {
|
||||
Button {
|
||||
purchase()
|
||||
} label: {
|
||||
HStack {
|
||||
if isPurchasing {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
} else {
|
||||
Text("Upgrade Now")
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.appPrimary)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
.disabled(isPurchasing)
|
||||
}
|
||||
|
||||
// MARK: - Restore Button
|
||||
|
||||
private var restoreButton: some View {
|
||||
Button {
|
||||
restore()
|
||||
} label: {
|
||||
Text("Restore Purchases")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.appPrimary)
|
||||
}
|
||||
.disabled(isPurchasing)
|
||||
}
|
||||
|
||||
// MARK: - Legal Section
|
||||
|
||||
private var legalSection: some View {
|
||||
VStack(spacing: 8) {
|
||||
Text("Payment will be charged to your Apple ID account. By purchasing, you agree to our Terms of Service and Privacy Policy.")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
HStack(spacing: 16) {
|
||||
Link("Terms", destination: URL(string: AppConstants.URLs.termsOfService)!)
|
||||
.font(.caption)
|
||||
|
||||
Link("Privacy", destination: URL(string: AppConstants.URLs.privacyPolicy)!)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func purchase() {
|
||||
isPurchasing = true
|
||||
FirebaseService.shared.logPurchaseAttempt(productId: IAPService.premiumProductID)
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await iapService.purchase()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
showingError = true
|
||||
}
|
||||
isPurchasing = false
|
||||
}
|
||||
}
|
||||
|
||||
private func restore() {
|
||||
isPurchasing = true
|
||||
|
||||
Task {
|
||||
await iapService.restorePurchases()
|
||||
|
||||
if !iapService.isPremium {
|
||||
errorMessage = "No purchases found to restore"
|
||||
showingError = true
|
||||
}
|
||||
isPurchasing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Feature Row
|
||||
|
||||
struct FeatureRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let description: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 16) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.appPrimary.opacity(0.1))
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 18))
|
||||
.foregroundColor(.appPrimary)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
Text(description)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.positiveGreen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Compact Paywall (for inline use)
|
||||
|
||||
struct CompactPaywallBanner: View {
|
||||
@Binding var showingPaywall: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "crown.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [.yellow, .orange],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Unlock Premium")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
Text("Get unlimited access to all features")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
showingPaywall = true
|
||||
} label: {
|
||||
Text("€4.69")
|
||||
.font(.caption.weight(.semibold))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.appPrimary)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(16)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Premium Lock Overlay
|
||||
|
||||
struct PremiumLockOverlay: View {
|
||||
let feature: String
|
||||
@Binding var showingPaywall: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 32))
|
||||
.foregroundColor(.appWarning)
|
||||
|
||||
Text("Premium Feature")
|
||||
.font(.headline)
|
||||
|
||||
Text(feature)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Button {
|
||||
showingPaywall = true
|
||||
} label: {
|
||||
Label("Unlock", systemImage: "crown.fill")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.appPrimary)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(20)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(.systemBackground).opacity(0.95))
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
PaywallView()
|
||||
.environmentObject(IAPService())
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AppLockView: View {
|
||||
@Binding var isUnlocked: Bool
|
||||
@AppStorage("faceIdEnabled") private var faceIdEnabled = false
|
||||
@AppStorage("pinEnabled") private var pinEnabled = false
|
||||
|
||||
@State private var pin = ""
|
||||
@State private var errorMessage: String?
|
||||
@State private var didAttemptBiometrics = false
|
||||
@FocusState private var pinFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(.systemBackground)
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 20) {
|
||||
Spacer()
|
||||
|
||||
Image("BrandMark")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 64, height: 64)
|
||||
|
||||
Text("Locked")
|
||||
.font(.title.weight(.bold))
|
||||
|
||||
Text("Unlock Portfolio Journal to view your data.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 30)
|
||||
|
||||
if faceIdEnabled {
|
||||
Button {
|
||||
authenticateWithBiometrics()
|
||||
} label: {
|
||||
Label("Unlock with Face ID", systemImage: "faceid")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.appPrimary)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
|
||||
if pinEnabled {
|
||||
VStack(spacing: 10) {
|
||||
SecureField("4-digit PIN", text: $pin)
|
||||
.keyboardType(.numberPad)
|
||||
.textContentType(.oneTimeCode)
|
||||
.multilineTextAlignment(.center)
|
||||
.font(.title3.weight(.semibold))
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
.focused($pinFocused)
|
||||
.onChange(of: pin) { _, newValue in
|
||||
pin = String(newValue.filter(\.isNumber).prefix(4))
|
||||
errorMessage = nil
|
||||
if pin.count == 4 {
|
||||
validatePin()
|
||||
}
|
||||
}
|
||||
|
||||
Button("Unlock") {
|
||||
validatePin()
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.disabled(pin.count < 4)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
|
||||
if let errorMessage {
|
||||
Text(errorMessage)
|
||||
.font(.caption)
|
||||
.foregroundColor(.negativeRed)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if faceIdEnabled && !didAttemptBiometrics {
|
||||
didAttemptBiometrics = true
|
||||
authenticateWithBiometrics()
|
||||
} else if pinEnabled {
|
||||
pinFocused = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func authenticateWithBiometrics() {
|
||||
AppLockService.authenticate(reason: "Unlock your portfolio") { success in
|
||||
if success {
|
||||
isUnlocked = true
|
||||
} else if pinEnabled {
|
||||
pinFocused = true
|
||||
} else {
|
||||
errorMessage = "Face ID failed. Please try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func validatePin() {
|
||||
guard let storedPin = KeychainService.readPin() else {
|
||||
errorMessage = "PIN not set."
|
||||
pin = ""
|
||||
return
|
||||
}
|
||||
if pin == storedPin {
|
||||
isUnlocked = true
|
||||
pin = ""
|
||||
errorMessage = nil
|
||||
} else {
|
||||
errorMessage = "Incorrect PIN."
|
||||
pin = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AllocationTargetsView: View {
|
||||
@StateObject private var categoryRepository = CategoryRepository()
|
||||
@State private var targetValues: [UUID: String] = [:]
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
HStack {
|
||||
Text("Total Targets")
|
||||
Spacer()
|
||||
Text(String(format: "%.0f%%", totalTargets))
|
||||
.foregroundColor(totalTargets == 100 ? .positiveGreen : .secondary)
|
||||
}
|
||||
|
||||
if totalTargets != 100 {
|
||||
Text("Targets don't need to be perfect, but aiming for 100% keeps the drift view accurate.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(categoryRepository.categories) { category in
|
||||
let categoryId = category.id
|
||||
HStack {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(category.color)
|
||||
.frame(width: 10, height: 10)
|
||||
Text(category.name)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
TextField("0", text: targetBinding(for: categoryId))
|
||||
.keyboardType(.decimalPad)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(width: 60)
|
||||
|
||||
Text("%")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Targets by Category")
|
||||
} footer: {
|
||||
Text("Set your desired allocation percentages to track drift over time.")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Allocation Targets")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear {
|
||||
preloadTargets()
|
||||
}
|
||||
}
|
||||
|
||||
private var totalTargets: Double {
|
||||
let ids = categoryRepository.categories.compactMap { $0.id }
|
||||
return AllocationTargetStore.totalTargetPercentage(for: ids)
|
||||
}
|
||||
|
||||
private func preloadTargets() {
|
||||
targetValues = categoryRepository.categories.reduce(into: [:]) { result, category in
|
||||
let id = category.id
|
||||
if let target = AllocationTargetStore.target(for: id) {
|
||||
result[id] = String(format: "%.0f", target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func targetBinding(for categoryId: UUID) -> Binding<String> {
|
||||
Binding(
|
||||
get: { targetValues[categoryId] ?? "" },
|
||||
set: { newValue in
|
||||
targetValues[categoryId] = newValue
|
||||
let sanitized = newValue.replacingOccurrences(of: ",", with: ".")
|
||||
let value = Double(sanitized) ?? 0
|
||||
AllocationTargetStore.setTarget(value, for: categoryId)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
AllocationTargetsView()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
import UIKit
|
||||
|
||||
struct ImportDataView: View {
|
||||
@EnvironmentObject private var iapService: IAPService
|
||||
@EnvironmentObject private var accountStore: AccountStore
|
||||
@EnvironmentObject private var tabSelection: TabSelectionStore
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var selectedFormat: ImportService.ImportFormat = .csv
|
||||
@State private var showingImporter = false
|
||||
@State private var resultMessage: String?
|
||||
@State private var errorMessage: String?
|
||||
@State private var isImporting = false
|
||||
@State private var importProgress: Double = 0
|
||||
@State private var importStatus = "Preparing import"
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
Picker("Format", selection: $selectedFormat) {
|
||||
Text("CSV").tag(ImportService.ImportFormat.csv)
|
||||
Text("JSON").tag(ImportService.ImportFormat.json)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.disabled(isImporting)
|
||||
|
||||
Button {
|
||||
showingImporter = true
|
||||
} label: {
|
||||
Label("Choose File", systemImage: "doc")
|
||||
}
|
||||
.disabled(isImporting)
|
||||
|
||||
Button {
|
||||
importFromClipboard()
|
||||
} label: {
|
||||
Label("Paste from Clipboard", systemImage: "doc.on.clipboard")
|
||||
}
|
||||
.disabled(isImporting)
|
||||
|
||||
if isImporting {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProgressView(value: importProgress)
|
||||
Text(importStatus)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
} header: {
|
||||
Text("Import")
|
||||
} footer: {
|
||||
Text("Your data will be merged with existing categories and sources.")
|
||||
}
|
||||
|
||||
Section {
|
||||
if selectedFormat == .csv {
|
||||
csvDocs
|
||||
} else {
|
||||
jsonDocs
|
||||
}
|
||||
} header: {
|
||||
Text("Format Guide")
|
||||
} footer: {
|
||||
Text(iapService.isPremium ? "Accounts are imported as provided." : "Free users import into the Personal account.")
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
shareSampleFile()
|
||||
} label: {
|
||||
Label("Share Sample \(selectedFormat == .csv ? "CSV" : "JSON")", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
} footer: {
|
||||
Text("Use this sample file to email yourself a template.")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Import Data")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
.disabled(isImporting)
|
||||
}
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $showingImporter,
|
||||
allowedContentTypes: selectedFormat == .csv
|
||||
? [.commaSeparatedText, .plainText, .text]
|
||||
: [.json, .plainText, .text],
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
handleImport(result)
|
||||
}
|
||||
.alert(
|
||||
"Import Complete",
|
||||
isPresented: Binding(
|
||||
get: { resultMessage != nil },
|
||||
set: { if !$0 { resultMessage = nil } }
|
||||
)
|
||||
) {
|
||||
Button("OK") { resultMessage = nil }
|
||||
} message: {
|
||||
Text(resultMessage ?? "")
|
||||
}
|
||||
.alert(
|
||||
"Import Error",
|
||||
isPresented: Binding(
|
||||
get: { errorMessage != nil },
|
||||
set: { if !$0 { errorMessage = nil } }
|
||||
)
|
||||
) {
|
||||
Button("OK") { errorMessage = nil }
|
||||
} message: {
|
||||
Text(errorMessage ?? "")
|
||||
}
|
||||
}
|
||||
.presentationDetents([.large])
|
||||
}
|
||||
|
||||
private var csvDocs: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Headers")
|
||||
.font(.headline)
|
||||
Text("Account,Category,Source,Date,Value,Contribution,Notes")
|
||||
.font(.caption.monospaced())
|
||||
|
||||
Text("Example")
|
||||
.font(.headline)
|
||||
Text("""
|
||||
Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
|
||||
,Crypto,BTC,01/15/2024 14:30,3200,,Cold storage
|
||||
""")
|
||||
.font(.caption.monospaced())
|
||||
Text("Account, Contribution, and Notes are optional. Dates accept / or - and 24h/12h time.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var jsonDocs: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Top-level keys")
|
||||
.font(.headline)
|
||||
Text("version, currency, accounts")
|
||||
.font(.caption.monospaced())
|
||||
|
||||
Text("Example")
|
||||
.font(.headline)
|
||||
Text("""
|
||||
{
|
||||
"version": 2,
|
||||
"currency": "EUR",
|
||||
"accounts": [{
|
||||
"name": "Personal",
|
||||
"inputMode": "simple",
|
||||
"notificationFrequency": "monthly",
|
||||
"categories": [{
|
||||
"name": "Stocks",
|
||||
"color": "#3B82F6",
|
||||
"icon": "chart.line.uptrend.xyaxis",
|
||||
"sources": [{
|
||||
"name": "Index Fund",
|
||||
"snapshots": [{
|
||||
"date": "2024-01-01T00:00:00Z",
|
||||
"value": 15000,
|
||||
"contribution": 12000
|
||||
}]
|
||||
}]
|
||||
}]
|
||||
}]
|
||||
}
|
||||
""")
|
||||
.font(.caption.monospaced())
|
||||
}
|
||||
}
|
||||
|
||||
private func handleImport(_ result: Result<[URL], Error>) {
|
||||
do {
|
||||
let urls = try result.get()
|
||||
guard let url = urls.first else { return }
|
||||
let content = try readFileContents(from: url)
|
||||
handleImportContent(content)
|
||||
} catch {
|
||||
errorMessage = "Could not read the selected file. \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private func readFileContents(from url: URL) throws -> String {
|
||||
let accessing = url.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if accessing {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
|
||||
var coordinatorError: NSError?
|
||||
var contentError: NSError?
|
||||
var content = ""
|
||||
let coordinator = NSFileCoordinator()
|
||||
coordinator.coordinate(readingItemAt: url, options: [], error: &coordinatorError) { fileURL in
|
||||
do {
|
||||
if let isUbiquitous = try? fileURL.resourceValues(forKeys: [.isUbiquitousItemKey]).isUbiquitousItem,
|
||||
isUbiquitous {
|
||||
try? FileManager.default.startDownloadingUbiquitousItem(at: fileURL)
|
||||
}
|
||||
let data = try Data(contentsOf: fileURL)
|
||||
if let decoded = String(data: data, encoding: .utf8)
|
||||
?? String(data: data, encoding: .utf16)
|
||||
?? String(data: data, encoding: .isoLatin1) {
|
||||
content = decoded
|
||||
} else {
|
||||
throw NSError(
|
||||
domain: "ImportDataView",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Unsupported text encoding."]
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
contentError = error as NSError
|
||||
}
|
||||
}
|
||||
|
||||
if let coordinatorError {
|
||||
throw coordinatorError
|
||||
}
|
||||
if let contentError {
|
||||
throw contentError
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
private func importFromClipboard() {
|
||||
guard let content = UIPasteboard.general.string,
|
||||
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
errorMessage = "Clipboard is empty."
|
||||
return
|
||||
}
|
||||
handleImportContent(content)
|
||||
}
|
||||
|
||||
private func handleImportContent(_ content: String) {
|
||||
let allowMultipleAccounts = iapService.isPremium
|
||||
let defaultAccountName = accountStore.selectedAccount?.name
|
||||
?? accountStore.accounts.first?.name
|
||||
isImporting = true
|
||||
importProgress = 0
|
||||
importStatus = "Parsing file"
|
||||
|
||||
Task {
|
||||
let importResult = await ImportService.shared.importDataAsync(
|
||||
content: content,
|
||||
format: selectedFormat,
|
||||
allowMultipleAccounts: allowMultipleAccounts,
|
||||
defaultAccountName: defaultAccountName
|
||||
) { progress in
|
||||
importProgress = progress.fraction
|
||||
importStatus = progress.message
|
||||
}
|
||||
|
||||
isImporting = false
|
||||
|
||||
if importResult.errors.isEmpty {
|
||||
resultMessage = "Imported \(importResult.sourcesCreated) sources and \(importResult.snapshotsCreated) snapshots."
|
||||
tabSelection.selectedTab = 0
|
||||
dismiss()
|
||||
} else {
|
||||
errorMessage = importResult.errors.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func shareSampleFile() {
|
||||
if selectedFormat == .csv {
|
||||
ShareService.shared.shareTextFile(
|
||||
content: ImportService.sampleCSV(),
|
||||
fileName: "investment_tracker_sample.csv"
|
||||
)
|
||||
} else {
|
||||
ShareService.shared.shareTextFile(
|
||||
content: ImportService.sampleJSON(),
|
||||
fileName: "investment_tracker_sample.json"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ImportDataView()
|
||||
.environmentObject(IAPService())
|
||||
.environmentObject(AccountStore(iapService: IAPService()))
|
||||
}
|
||||
@@ -0,0 +1,711 @@
|
||||
import SwiftUI
|
||||
import StoreKit
|
||||
|
||||
struct SettingsView: View {
|
||||
@EnvironmentObject var iapService: IAPService
|
||||
@StateObject private var viewModel: SettingsViewModel
|
||||
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
|
||||
@AppStorage("cloudSyncEnabled") private var cloudSyncEnabled = false
|
||||
@AppStorage("faceIdEnabled") private var faceIdEnabled = false
|
||||
@AppStorage("pinEnabled") private var pinEnabled = false
|
||||
@AppStorage("lockOnLaunch") private var lockOnLaunch = true
|
||||
@AppStorage("lockOnBackground") private var lockOnBackground = false
|
||||
|
||||
@State private var showingPinSetup = false
|
||||
@State private var showingPinChange = false
|
||||
@State private var showingBiometricAlert = false
|
||||
@State private var showingPinRequiredAlert = false
|
||||
@State private var showingPinDisableAlert = false
|
||||
@State private var showingRestartAlert = false
|
||||
@State private var didLoadCloudSync = false
|
||||
|
||||
init() {
|
||||
_viewModel = StateObject(wrappedValue: SettingsViewModel(iapService: IAPService()))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
AppBackground()
|
||||
|
||||
List {
|
||||
brandSection
|
||||
// Premium Section
|
||||
premiumSection
|
||||
|
||||
// Notifications Section
|
||||
notificationsSection
|
||||
|
||||
// Data Section
|
||||
dataSection
|
||||
|
||||
// Security Section
|
||||
securitySection
|
||||
|
||||
// Preferences Section
|
||||
preferencesSection
|
||||
|
||||
// Long-Term Focus
|
||||
longTermSection
|
||||
|
||||
// Accounts Section
|
||||
accountsSection
|
||||
|
||||
// About Section
|
||||
aboutSection
|
||||
|
||||
// Danger Zone
|
||||
dangerZoneSection
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.sheet(isPresented: $viewModel.showingPaywall) {
|
||||
PaywallView()
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showingExportOptions) {
|
||||
ExportOptionsSheet(viewModel: viewModel)
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showingImportSheet) {
|
||||
ImportDataView()
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Reset All Data",
|
||||
isPresented: $viewModel.showingResetConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Reset Everything", role: .destructive) {
|
||||
viewModel.resetAllData()
|
||||
}
|
||||
} message: {
|
||||
Text("This will permanently delete all your investment data. This action cannot be undone.")
|
||||
}
|
||||
.alert("Success", isPresented: .constant(viewModel.successMessage != nil)) {
|
||||
Button("OK") {
|
||||
viewModel.successMessage = nil
|
||||
}
|
||||
} message: {
|
||||
Text(viewModel.successMessage ?? "")
|
||||
}
|
||||
.alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
|
||||
Button("OK") {
|
||||
viewModel.errorMessage = nil
|
||||
}
|
||||
} message: {
|
||||
Text(viewModel.errorMessage ?? "")
|
||||
}
|
||||
.alert("Restart Required", isPresented: $showingRestartAlert) {
|
||||
Button("OK") {}
|
||||
} message: {
|
||||
Text("Restart the app to apply iCloud sync changes.")
|
||||
}
|
||||
.alert("Face ID Unavailable", isPresented: $showingBiometricAlert) {
|
||||
Button("OK") {}
|
||||
} message: {
|
||||
Text("Face ID isn't available on this device.")
|
||||
}
|
||||
.alert("PIN Required", isPresented: $showingPinRequiredAlert) {
|
||||
Button("OK") {}
|
||||
} message: {
|
||||
Text("Enable a PIN before turning on Face ID.")
|
||||
}
|
||||
.alert("Disable Face ID First", isPresented: $showingPinDisableAlert) {
|
||||
Button("OK") {}
|
||||
} message: {
|
||||
Text("Turn off Face ID before disabling your PIN.")
|
||||
}
|
||||
.sheet(isPresented: $showingPinSetup) {
|
||||
PinSetupView(title: "Set PIN") { pin in
|
||||
if KeychainService.savePin(pin) {
|
||||
pinEnabled = true
|
||||
} else {
|
||||
pinEnabled = false
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
if KeychainService.readPin() == nil {
|
||||
pinEnabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingPinChange) {
|
||||
PinSetupView(title: "Change PIN") { pin in
|
||||
_ = KeychainService.savePin(pin)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
didLoadCloudSync = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Brand Section
|
||||
|
||||
private var brandSection: some View {
|
||||
Section {
|
||||
HStack(spacing: 12) {
|
||||
Image("BrandMark")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 36, height: 36)
|
||||
.padding(6)
|
||||
.background(Color.appPrimary.opacity(0.08))
|
||||
.cornerRadius(10)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(appDisplayName)
|
||||
.font(.headline)
|
||||
Text("Long-term portfolio tracker")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Premium Section
|
||||
|
||||
private var premiumSection: some View {
|
||||
Section {
|
||||
if viewModel.isPremium {
|
||||
HStack {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.yellow.opacity(0.2))
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
Image(systemName: "crown.fill")
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [.yellow, .orange],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Premium Active")
|
||||
.font(.headline)
|
||||
|
||||
if viewModel.isFamilyShared {
|
||||
Text("Family Sharing")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.foregroundColor(.positiveGreen)
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
viewModel.upgradeToPremium()
|
||||
} label: {
|
||||
HStack {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.appPrimary.opacity(0.1))
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
Image(systemName: "crown.fill")
|
||||
.foregroundColor(.appPrimary)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Upgrade to Premium")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text("Unlock all features for €4.69")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
Task {
|
||||
await viewModel.restorePurchases()
|
||||
}
|
||||
} label: {
|
||||
Text("Restore Purchases")
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Subscription")
|
||||
} footer: {
|
||||
if !viewModel.isPremium {
|
||||
Text("Free: \(viewModel.sourceLimitText) • \(viewModel.historyLimitText)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notifications Section
|
||||
|
||||
private var notificationsSection: some View {
|
||||
Section {
|
||||
HStack {
|
||||
Text("Notifications")
|
||||
Spacer()
|
||||
Text(viewModel.notificationsEnabled ? "Enabled" : "Disabled")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if !viewModel.notificationsEnabled {
|
||||
Task {
|
||||
await viewModel.requestNotificationPermission()
|
||||
}
|
||||
} else {
|
||||
viewModel.openSystemSettings()
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.notificationsEnabled {
|
||||
DatePicker(
|
||||
"Default Reminder Time",
|
||||
selection: $viewModel.defaultNotificationTime,
|
||||
displayedComponents: .hourAndMinute
|
||||
)
|
||||
.onChange(of: viewModel.defaultNotificationTime) { _, newTime in
|
||||
viewModel.updateNotificationTime(newTime)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Notifications")
|
||||
} footer: {
|
||||
Text("Set when you'd like to receive investment update reminders.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Data Section
|
||||
|
||||
private var dataSection: some View {
|
||||
Section {
|
||||
Toggle("Sync with iCloud", isOn: $cloudSyncEnabled)
|
||||
.onChange(of: cloudSyncEnabled) { _, _ in
|
||||
if didLoadCloudSync {
|
||||
showingRestartAlert = true
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
if viewModel.canExport {
|
||||
viewModel.showingExportOptions = true
|
||||
} else {
|
||||
viewModel.showingPaywall = true
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Label("Export Data", systemImage: "square.and.arrow.up")
|
||||
|
||||
Spacer()
|
||||
|
||||
if !viewModel.canExport {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.caption)
|
||||
.foregroundColor(.appWarning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
viewModel.showingImportSheet = true
|
||||
} label: {
|
||||
HStack {
|
||||
Label("Import Data", systemImage: "square.and.arrow.down")
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Total Sources")
|
||||
Spacer()
|
||||
Text("\(viewModel.totalSources)")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Total Snapshots")
|
||||
Spacer()
|
||||
Text("\(viewModel.totalSnapshots)")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Storage Used")
|
||||
Spacer()
|
||||
Text(viewModel.storageUsedText)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} header: {
|
||||
Text("Data")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Security Section
|
||||
|
||||
private var securitySection: some View {
|
||||
Section {
|
||||
Toggle("Require PIN", isOn: $pinEnabled)
|
||||
.onChange(of: pinEnabled) { _, enabled in
|
||||
if enabled {
|
||||
if KeychainService.readPin() == nil {
|
||||
showingPinSetup = true
|
||||
}
|
||||
} else {
|
||||
if faceIdEnabled {
|
||||
pinEnabled = true
|
||||
showingPinDisableAlert = true
|
||||
} else {
|
||||
KeychainService.deletePin()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Toggle("Enable Face ID", isOn: $faceIdEnabled)
|
||||
.onChange(of: faceIdEnabled) { _, enabled in
|
||||
if enabled {
|
||||
guard AppLockService.canUseBiometrics() else {
|
||||
faceIdEnabled = false
|
||||
showingBiometricAlert = true
|
||||
return
|
||||
}
|
||||
if !pinEnabled {
|
||||
faceIdEnabled = false
|
||||
showingPinRequiredAlert = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if pinEnabled {
|
||||
Button("Change PIN") {
|
||||
showingPinChange = true
|
||||
}
|
||||
}
|
||||
|
||||
if faceIdEnabled || pinEnabled {
|
||||
Toggle("Lock on App Launch", isOn: $lockOnLaunch)
|
||||
Toggle("Lock When Backgrounded", isOn: $lockOnBackground)
|
||||
}
|
||||
} header: {
|
||||
Text("App Lock")
|
||||
} footer: {
|
||||
Text("Use Face ID or a 4-digit PIN to protect your data.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preferences Section
|
||||
|
||||
private var preferencesSection: some View {
|
||||
Section {
|
||||
Picker("Currency", selection: $viewModel.currencyCode) {
|
||||
ForEach(CurrencyPicker.commonCodes, id: \.self) { code in
|
||||
Text(code).tag(code)
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.currencyCode) { _, newValue in
|
||||
viewModel.updateCurrency(newValue)
|
||||
}
|
||||
|
||||
Picker("Input Mode", selection: $viewModel.inputMode) {
|
||||
ForEach(InputMode.allCases) { mode in
|
||||
Text(mode.title).tag(mode)
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.inputMode) { _, newValue in
|
||||
viewModel.updateInputMode(newValue)
|
||||
}
|
||||
} header: {
|
||||
Text("Preferences")
|
||||
} footer: {
|
||||
Text("Currency and input mode apply globally unless overridden per account.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Long-Term Focus Section
|
||||
|
||||
private var longTermSection: some View {
|
||||
Section {
|
||||
Toggle("Calm Mode", isOn: $calmModeEnabled)
|
||||
|
||||
NavigationLink {
|
||||
AllocationTargetsView()
|
||||
} label: {
|
||||
HStack {
|
||||
Label("Allocation Targets", systemImage: "target")
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Long-Term Focus")
|
||||
} footer: {
|
||||
Text("Calm Mode hides short-term noise and advanced charts, keeping the app focused on monthly check-ins.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Accounts Section
|
||||
|
||||
private var accountsSection: some View {
|
||||
Section {
|
||||
NavigationLink {
|
||||
AccountsView()
|
||||
} label: {
|
||||
HStack {
|
||||
Label("Manage Accounts", systemImage: "person.2")
|
||||
Spacer()
|
||||
if !viewModel.isPremium {
|
||||
Text("Premium")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Accounts")
|
||||
} footer: {
|
||||
Text(viewModel.isPremium ? "Create multiple accounts and switch between them." : "Free users can use one account.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - About Section
|
||||
|
||||
private var aboutSection: some View {
|
||||
Section {
|
||||
HStack {
|
||||
Text("Version")
|
||||
Spacer()
|
||||
Text(viewModel.appVersion)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Link(destination: URL(string: AppConstants.URLs.privacyPolicy)!) {
|
||||
HStack {
|
||||
Text("Privacy Policy")
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Link(destination: URL(string: AppConstants.URLs.termsOfService)!) {
|
||||
HStack {
|
||||
Text("Terms of Service")
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Link(destination: URL(string: AppConstants.URLs.support)!) {
|
||||
HStack {
|
||||
Text("Support")
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
requestAppReview()
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Rate App")
|
||||
Spacer()
|
||||
Image(systemName: "star.fill")
|
||||
.foregroundColor(.yellow)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("About")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Danger Zone Section
|
||||
|
||||
private var dangerZoneSection: some View {
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
viewModel.showingResetConfirmation = true
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "trash")
|
||||
Text("Reset All Data")
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Danger Zone")
|
||||
} footer: {
|
||||
Text("This will permanently delete all your investment sources, snapshots, and settings.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func requestAppReview() {
|
||||
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return }
|
||||
if #available(iOS 18.0, *) {
|
||||
AppStore.requestReview(in: scene)
|
||||
} else {
|
||||
SKStoreReviewController.requestReview(in: scene)
|
||||
}
|
||||
}
|
||||
|
||||
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: - Export Options Sheet
|
||||
|
||||
struct ExportOptionsSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@ObservedObject var viewModel: SettingsViewModel
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
Button {
|
||||
viewModel.exportData(format: .csv)
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "tablecells")
|
||||
.foregroundColor(.positiveGreen)
|
||||
.frame(width: 30)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text("CSV")
|
||||
.font(.headline)
|
||||
Text("Compatible with Excel, Google Sheets")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
viewModel.exportData(format: .json)
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "doc.text")
|
||||
.foregroundColor(.appPrimary)
|
||||
.frame(width: 30)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text("JSON")
|
||||
.font(.headline)
|
||||
Text("Full data structure for backup")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Select Format")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Export Data")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PIN Setup View
|
||||
|
||||
struct PinSetupView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let title: String
|
||||
let onSave: (String) -> Void
|
||||
|
||||
@State private var pin = ""
|
||||
@State private var confirmPin = ""
|
||||
@State private var errorMessage: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
SecureField("New PIN", text: $pin)
|
||||
.keyboardType(.numberPad)
|
||||
.onChange(of: pin) { _, newValue in
|
||||
pin = String(newValue.filter(\.isNumber).prefix(4))
|
||||
}
|
||||
|
||||
SecureField("Confirm PIN", text: $confirmPin)
|
||||
.keyboardType(.numberPad)
|
||||
.onChange(of: confirmPin) { _, newValue in
|
||||
confirmPin = String(newValue.filter(\.isNumber).prefix(4))
|
||||
}
|
||||
} header: {
|
||||
Text("4-Digit PIN")
|
||||
}
|
||||
|
||||
if let errorMessage {
|
||||
Text(errorMessage)
|
||||
.font(.caption)
|
||||
.foregroundColor(.negativeRed)
|
||||
}
|
||||
}
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") { savePin() }
|
||||
.disabled(pin.count < 4 || confirmPin.count < 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
|
||||
private func savePin() {
|
||||
guard pin.count == 4, pin == confirmPin else {
|
||||
errorMessage = "PINs do not match."
|
||||
confirmPin = ""
|
||||
return
|
||||
}
|
||||
onSave(pin)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SettingsView()
|
||||
.environmentObject(IAPService())
|
||||
.environmentObject(AccountStore(iapService: IAPService()))
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AddSourceView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject var iapService: IAPService
|
||||
@EnvironmentObject var accountStore: AccountStore
|
||||
|
||||
@State private var name = ""
|
||||
@State private var selectedCategory: Category?
|
||||
@State private var initialValue = ""
|
||||
@State private var showingCategoryPicker = false
|
||||
@State private var selectedAccountId: UUID?
|
||||
|
||||
@StateObject private var categoryRepository = CategoryRepository()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
if accountStore.accounts.count > 1 {
|
||||
Section {
|
||||
Picker("Account", selection: $selectedAccountId) {
|
||||
ForEach(accountStore.accounts) { account in
|
||||
Text(account.name).tag(Optional(account.id))
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Account")
|
||||
}
|
||||
}
|
||||
|
||||
// Source Info
|
||||
Section {
|
||||
TextField("Source Name", text: $name)
|
||||
.textContentType(.organizationName)
|
||||
|
||||
Button {
|
||||
showingCategoryPicker = true
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Category")
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let category = selectedCategory {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: category.icon)
|
||||
.foregroundColor(category.color)
|
||||
Text(category.name)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} else {
|
||||
Text("Select")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Source Information")
|
||||
}
|
||||
|
||||
// Initial Value (Optional)
|
||||
Section {
|
||||
HStack {
|
||||
Text(currencySymbol)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
TextField("0.00", text: $initialValue)
|
||||
.keyboardType(.decimalPad)
|
||||
}
|
||||
} header: {
|
||||
Text("Initial Value (Optional)")
|
||||
} footer: {
|
||||
Text("You can add snapshots later if you prefer.")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Add Source")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Add") {
|
||||
saveSource()
|
||||
}
|
||||
.disabled(!isValid)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingCategoryPicker) {
|
||||
CategoryPickerView(
|
||||
selectedCategory: $selectedCategory,
|
||||
categories: categoryRepository.categories
|
||||
)
|
||||
}
|
||||
.onAppear {
|
||||
categoryRepository.createDefaultCategoriesIfNeeded()
|
||||
if selectedCategory == nil {
|
||||
selectedCategory = categoryRepository.categories.first
|
||||
}
|
||||
if selectedAccountId == nil {
|
||||
selectedAccountId = accountStore.selectedAccount?.id ?? accountStore.accounts.first?.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Validation
|
||||
|
||||
private var isValid: Bool {
|
||||
!name.trimmingCharacters(in: .whitespaces).isEmpty && selectedCategory != nil
|
||||
}
|
||||
|
||||
// MARK: - Save
|
||||
|
||||
private func saveSource() {
|
||||
guard let category = selectedCategory else { return }
|
||||
|
||||
let repository = InvestmentSourceRepository()
|
||||
let source = repository.createSource(
|
||||
name: name.trimmingCharacters(in: .whitespaces),
|
||||
category: category,
|
||||
account: selectedAccount
|
||||
)
|
||||
|
||||
// Add initial snapshot if provided
|
||||
if let value = parseDecimal(initialValue), value > 0 {
|
||||
let snapshotRepository = SnapshotRepository()
|
||||
snapshotRepository.createSnapshot(
|
||||
for: source,
|
||||
date: Date(),
|
||||
value: value
|
||||
)
|
||||
}
|
||||
|
||||
// Schedule notification
|
||||
NotificationService.shared.scheduleReminder(for: source)
|
||||
|
||||
// Log analytics
|
||||
FirebaseService.shared.logSourceAdded(
|
||||
categoryName: category.name,
|
||||
sourceCount: repository.sourceCount
|
||||
)
|
||||
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func parseDecimal(_ string: String) -> Decimal? {
|
||||
let cleaned = string
|
||||
.replacingOccurrences(of: currencySymbol, with: "")
|
||||
.replacingOccurrences(of: ",", with: ".")
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
guard !cleaned.isEmpty else { return nil }
|
||||
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
formatter.locale = Locale(identifier: "en_US")
|
||||
|
||||
return formatter.number(from: cleaned)?.decimalValue
|
||||
}
|
||||
|
||||
private var currencySymbol: String {
|
||||
if let account = selectedAccount, let code = account.currencyCode {
|
||||
return CurrencyFormatter.symbol(for: code)
|
||||
}
|
||||
return AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currencySymbol
|
||||
}
|
||||
|
||||
private var selectedAccount: Account? {
|
||||
guard let id = selectedAccountId else { return nil }
|
||||
return accountStore.accounts.first { $0.id == id }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Category Picker View
|
||||
|
||||
struct CategoryPickerView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Binding var selectedCategory: Category?
|
||||
let categories: [Category]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List(categories) { category in
|
||||
Button {
|
||||
selectedCategory = category
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(category.color.opacity(0.2))
|
||||
.frame(width: 40, height: 40)
|
||||
|
||||
Image(systemName: category.icon)
|
||||
.foregroundColor(category.color)
|
||||
}
|
||||
|
||||
Text(category.name)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if selectedCategory?.id == category.id {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(.appPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Select Category")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Edit Source View
|
||||
|
||||
struct EditSourceView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject var accountStore: AccountStore
|
||||
let source: InvestmentSource
|
||||
|
||||
@State private var name: String
|
||||
@State private var selectedCategory: Category?
|
||||
@State private var showingCategoryPicker = false
|
||||
@State private var selectedAccountId: UUID?
|
||||
|
||||
@StateObject private var categoryRepository = CategoryRepository()
|
||||
|
||||
init(source: InvestmentSource) {
|
||||
self.source = source
|
||||
_name = State(initialValue: source.name)
|
||||
_selectedCategory = State(initialValue: source.category)
|
||||
_selectedAccountId = State(initialValue: source.account?.id)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
if accountStore.accounts.count > 1 {
|
||||
Section {
|
||||
Picker("Account", selection: $selectedAccountId) {
|
||||
ForEach(accountStore.accounts) { account in
|
||||
Text(account.name).tag(Optional(account.id))
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Account")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("Source Name", text: $name)
|
||||
|
||||
Button {
|
||||
showingCategoryPicker = true
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Category")
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let category = selectedCategory {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: category.icon)
|
||||
.foregroundColor(category.color)
|
||||
Text(category.name)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Edit Source")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
saveChanges()
|
||||
}
|
||||
.disabled(!isValid)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingCategoryPicker) {
|
||||
CategoryPickerView(
|
||||
selectedCategory: $selectedCategory,
|
||||
categories: categoryRepository.categories
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var isValid: Bool {
|
||||
!name.trimmingCharacters(in: .whitespaces).isEmpty && selectedCategory != nil
|
||||
}
|
||||
|
||||
private func saveChanges() {
|
||||
guard let category = selectedCategory else { return }
|
||||
|
||||
let repository = InvestmentSourceRepository()
|
||||
repository.updateSource(
|
||||
source,
|
||||
name: name.trimmingCharacters(in: .whitespaces),
|
||||
category: category,
|
||||
account: selectedAccount
|
||||
)
|
||||
|
||||
// Reschedule notification
|
||||
NotificationService.shared.scheduleReminder(for: source)
|
||||
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private var selectedAccount: Account? {
|
||||
guard let id = selectedAccountId else { return nil }
|
||||
return accountStore.accounts.first { $0.id == id }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Add Snapshot View
|
||||
|
||||
struct AddSnapshotView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let source: InvestmentSource
|
||||
let snapshot: Snapshot?
|
||||
|
||||
@StateObject private var viewModel: SnapshotFormViewModel
|
||||
|
||||
init(source: InvestmentSource, snapshot: Snapshot? = nil) {
|
||||
self.source = source
|
||||
self.snapshot = snapshot
|
||||
if let snapshot = snapshot {
|
||||
_viewModel = StateObject(wrappedValue: SnapshotFormViewModel(source: source, mode: .edit(snapshot)))
|
||||
} else {
|
||||
_viewModel = StateObject(wrappedValue: SnapshotFormViewModel(source: source))
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
if snapshot == nil && viewModel.previousValue != nil {
|
||||
Button {
|
||||
viewModel.prefillFromPreviousSnapshot()
|
||||
} label: {
|
||||
Label("Duplicate Previous Snapshot", systemImage: "doc.on.doc")
|
||||
}
|
||||
}
|
||||
|
||||
DatePicker(
|
||||
"Date",
|
||||
selection: $viewModel.date,
|
||||
in: ...Date(),
|
||||
displayedComponents: .date
|
||||
)
|
||||
|
||||
HStack {
|
||||
Text(viewModel.currencySymbol)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
TextField("Value", text: $viewModel.valueString)
|
||||
.keyboardType(.decimalPad)
|
||||
}
|
||||
|
||||
if viewModel.previousValue != nil {
|
||||
Text(viewModel.previousValueString)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} header: {
|
||||
Text("Snapshot Details")
|
||||
}
|
||||
|
||||
// Change preview
|
||||
if let change = viewModel.formattedChange,
|
||||
let percentage = viewModel.formattedChangePercentage {
|
||||
Section {
|
||||
HStack {
|
||||
Text("Change from previous")
|
||||
Spacer()
|
||||
Text("\(change) (\(percentage))")
|
||||
.foregroundColor(
|
||||
(viewModel.changeFromPrevious ?? 0) >= 0
|
||||
? .positiveGreen
|
||||
: .negativeRed
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.inputMode == .detailed {
|
||||
Section {
|
||||
Toggle("Include Contribution", isOn: $viewModel.includeContribution)
|
||||
|
||||
if viewModel.includeContribution {
|
||||
HStack {
|
||||
Text(viewModel.currencySymbol)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
TextField("New capital added", text: $viewModel.contributionString)
|
||||
.keyboardType(.decimalPad)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Contribution (Optional)")
|
||||
} footer: {
|
||||
Text("Track new capital you've added to separate it from investment growth.")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("Notes", text: $viewModel.notes, axis: .vertical)
|
||||
.lineLimit(3...6)
|
||||
} header: {
|
||||
Text("Notes (Optional)")
|
||||
}
|
||||
}
|
||||
.navigationTitle(viewModel.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(viewModel.buttonTitle) {
|
||||
saveSnapshot()
|
||||
}
|
||||
.disabled(!viewModel.isValid)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveSnapshot() {
|
||||
guard let value = viewModel.value else { return }
|
||||
|
||||
let repository = SnapshotRepository()
|
||||
if let snapshot = snapshot {
|
||||
repository.updateSnapshot(
|
||||
snapshot,
|
||||
date: viewModel.date,
|
||||
value: value,
|
||||
contribution: viewModel.contribution,
|
||||
notes: viewModel.notes.isEmpty ? nil : viewModel.notes,
|
||||
clearContribution: !viewModel.includeContribution,
|
||||
clearNotes: viewModel.notes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
)
|
||||
} else {
|
||||
repository.createSnapshot(
|
||||
for: source,
|
||||
date: viewModel.date,
|
||||
value: value,
|
||||
contribution: viewModel.contribution,
|
||||
notes: viewModel.notes.isEmpty ? nil : viewModel.notes
|
||||
)
|
||||
}
|
||||
|
||||
// Reschedule notification
|
||||
NotificationService.shared.scheduleReminder(for: source)
|
||||
|
||||
// Log analytics
|
||||
FirebaseService.shared.logSnapshotAdded(sourceName: source.name, value: value)
|
||||
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AddSourceView()
|
||||
.environmentObject(IAPService())
|
||||
.environmentObject(AccountStore(iapService: IAPService()))
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AddTransactionView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let onSave: (TransactionType, Date, Decimal?, Decimal?, Decimal?, String?) -> Void
|
||||
|
||||
@State private var type: TransactionType = .buy
|
||||
@State private var date = Date()
|
||||
@State private var shares = ""
|
||||
@State private var price = ""
|
||||
@State private var amount = ""
|
||||
@State private var notes = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
Picker("Type", selection: $type) {
|
||||
ForEach(TransactionType.allCases) { transactionType in
|
||||
Text(transactionType.displayName).tag(transactionType)
|
||||
}
|
||||
}
|
||||
|
||||
DatePicker("Date", selection: $date, displayedComponents: .date)
|
||||
} header: {
|
||||
Text("Transaction")
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("Shares", text: $shares)
|
||||
.keyboardType(.decimalPad)
|
||||
|
||||
TextField("Price per share", text: $price)
|
||||
.keyboardType(.decimalPad)
|
||||
|
||||
TextField("Total amount", text: $amount)
|
||||
.keyboardType(.decimalPad)
|
||||
} header: {
|
||||
Text("Amounts")
|
||||
} footer: {
|
||||
Text("Enter shares and price or just a total amount.")
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("Notes", text: $notes, axis: .vertical)
|
||||
.lineLimit(2...4)
|
||||
} header: {
|
||||
Text("Notes (Optional)")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Add Transaction")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") { save() }
|
||||
.disabled(!isValid)
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
}
|
||||
|
||||
private var isValid: Bool {
|
||||
parseDecimal(shares) != nil || parseDecimal(amount) != nil
|
||||
}
|
||||
|
||||
private func save() {
|
||||
onSave(
|
||||
type,
|
||||
date,
|
||||
parseDecimal(shares),
|
||||
parseDecimal(price),
|
||||
parseDecimal(amount),
|
||||
notes.isEmpty ? nil : notes
|
||||
)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func parseDecimal(_ value: String) -> Decimal? {
|
||||
let cleaned = value
|
||||
.replacingOccurrences(of: ",", with: ".")
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
guard !cleaned.isEmpty else { return nil }
|
||||
return Decimal(string: cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AddTransactionView { _, _, _, _, _, _ in }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,592 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
struct SourceDetailView: View {
|
||||
@StateObject private var viewModel: SourceDetailViewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
|
||||
|
||||
@State private var showingDeleteConfirmation = false
|
||||
@State private var editingSnapshot: Snapshot?
|
||||
|
||||
init(source: InvestmentSource, iapService: IAPService) {
|
||||
_viewModel = StateObject(wrappedValue: SourceDetailViewModel(
|
||||
source: source,
|
||||
iapService: iapService
|
||||
))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AppBackground()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
// Header Card
|
||||
headerCard
|
||||
|
||||
// Quick Actions
|
||||
quickActions
|
||||
|
||||
// Chart
|
||||
if !viewModel.chartData.isEmpty {
|
||||
chartSection
|
||||
}
|
||||
|
||||
// Metrics
|
||||
if calmModeEnabled {
|
||||
simpleMetricsSection
|
||||
} else {
|
||||
metricsSection
|
||||
}
|
||||
|
||||
// Transactions
|
||||
transactionsSection
|
||||
|
||||
// Snapshots List
|
||||
snapshotsSection
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.navigationTitle(viewModel.source.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Menu {
|
||||
Button {
|
||||
viewModel.showingEditSource = true
|
||||
} label: {
|
||||
Label("Edit Source", systemImage: "pencil")
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
showingDeleteConfirmation = true
|
||||
} label: {
|
||||
Label("Delete Source", systemImage: "trash")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showingAddSnapshot) {
|
||||
AddSnapshotView(source: viewModel.source)
|
||||
}
|
||||
.sheet(item: $editingSnapshot) { snapshot in
|
||||
AddSnapshotView(source: viewModel.source, snapshot: snapshot)
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showingAddTransaction) {
|
||||
AddTransactionView { type, date, shares, price, amount, notes in
|
||||
viewModel.addTransaction(
|
||||
type: type,
|
||||
date: date,
|
||||
shares: shares,
|
||||
price: price,
|
||||
amount: amount,
|
||||
notes: notes
|
||||
)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showingEditSource) {
|
||||
EditSourceView(source: viewModel.source)
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showingPaywall) {
|
||||
PaywallView()
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete Source",
|
||||
isPresented: $showingDeleteConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
// Delete and dismiss
|
||||
let repository = InvestmentSourceRepository()
|
||||
repository.deleteSource(viewModel.source)
|
||||
dismiss()
|
||||
}
|
||||
} message: {
|
||||
Text("This will permanently delete \(viewModel.source.name) and all its snapshots.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header Card
|
||||
|
||||
private var headerCard: some View {
|
||||
VStack(spacing: 12) {
|
||||
// Category badge
|
||||
HStack {
|
||||
Image(systemName: viewModel.source.category?.icon ?? "questionmark")
|
||||
Text(viewModel.categoryName)
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(Color(hex: viewModel.categoryColor) ?? .gray)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background((Color(hex: viewModel.categoryColor) ?? .gray).opacity(0.1))
|
||||
.cornerRadius(20)
|
||||
|
||||
// Current value
|
||||
Text(viewModel.formattedCurrentValue)
|
||||
.font(.system(size: 36, weight: .bold, design: .rounded))
|
||||
|
||||
// Return
|
||||
if calmModeEnabled {
|
||||
VStack(spacing: 2) {
|
||||
Text("All-time return")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text("\(viewModel.formattedTotalReturn) (\(viewModel.formattedPercentageReturn))")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: viewModel.isPositiveReturn ? "arrow.up.right" : "arrow.down.right")
|
||||
Text(viewModel.formattedTotalReturn)
|
||||
Text("(\(viewModel.formattedPercentageReturn))")
|
||||
}
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundColor(viewModel.isPositiveReturn ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
|
||||
// Last updated
|
||||
Text("Last updated: \(viewModel.lastUpdated)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
// MARK: - Quick Actions
|
||||
|
||||
private var quickActions: some View {
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
viewModel.showingAddSnapshot = true
|
||||
} label: {
|
||||
Label("Add Snapshot", systemImage: "plus.circle.fill")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.appPrimary)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
|
||||
Button {
|
||||
viewModel.showingAddTransaction = true
|
||||
} label: {
|
||||
Label("Add Transaction", systemImage: "arrow.left.arrow.right.circle")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.appSecondary.opacity(0.15))
|
||||
.foregroundColor(.appSecondary)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chart Section
|
||||
|
||||
private var chartSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Value History")
|
||||
.font(.headline)
|
||||
|
||||
Chart {
|
||||
ForEach(viewModel.chartData, id: \.date) { item in
|
||||
LineMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.interpolationMethod(.catmullRom)
|
||||
|
||||
AreaMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary.opacity(0.3), Color.appPrimary.opacity(0.0)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.interpolationMethod(.catmullRom)
|
||||
}
|
||||
|
||||
if viewModel.canViewPredictions, !viewModel.predictions.isEmpty {
|
||||
ForEach(viewModel.predictions, id: \.id) { prediction in
|
||||
AreaMark(
|
||||
x: .value("Prediction Date", prediction.date),
|
||||
yStart: .value("Lower", NSDecimalNumber(decimal: prediction.confidenceInterval.lower).doubleValue),
|
||||
yEnd: .value("Upper", NSDecimalNumber(decimal: prediction.confidenceInterval.upper).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appSecondary.opacity(0.18))
|
||||
|
||||
LineMark(
|
||||
x: .value("Prediction Date", prediction.date),
|
||||
y: .value("Predicted Value", NSDecimalNumber(decimal: prediction.predictedValue).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appSecondary)
|
||||
.lineStyle(StrokeStyle(lineWidth: 2, dash: [6, 4]))
|
||||
}
|
||||
|
||||
if let firstPrediction = viewModel.predictions.first {
|
||||
PointMark(
|
||||
x: .value("Forecast Start", firstPrediction.date),
|
||||
y: .value("Forecast Value", NSDecimalNumber(decimal: firstPrediction.predictedValue).doubleValue)
|
||||
)
|
||||
.symbolSize(30)
|
||||
.foregroundStyle(Color.appSecondary)
|
||||
.annotation(position: .topTrailing) {
|
||||
Text("Forecast")
|
||||
.font(.caption.weight(.semibold))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.appSecondary.opacity(0.15))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .stride(by: .month, count: 2)) { value in
|
||||
AxisValueLabel(format: .dateTime.month(.abbreviated))
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) { value in
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(Decimal(doubleValue).shortCurrencyString)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 200)
|
||||
|
||||
if !viewModel.canViewPredictions && viewModel.hasEnoughDataForPredictions {
|
||||
Button {
|
||||
viewModel.showingPaywall = true
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "lock.fill")
|
||||
Text("Unlock predictions on the chart")
|
||||
}
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.appPrimary)
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 10)
|
||||
.background(Color.appPrimary.opacity(0.1))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
// MARK: - Metrics Section
|
||||
|
||||
private var metricsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Performance Metrics")
|
||||
.font(.headline)
|
||||
|
||||
LazyVGrid(columns: [
|
||||
GridItem(.flexible()),
|
||||
GridItem(.flexible())
|
||||
], spacing: 16) {
|
||||
MetricCard(title: "CAGR", value: viewModel.metrics.formattedCAGR)
|
||||
MetricCard(title: "Volatility", value: viewModel.metrics.formattedVolatility)
|
||||
MetricCard(title: "Max Drawdown", value: viewModel.metrics.formattedMaxDrawdown)
|
||||
MetricCard(title: "Sharpe Ratio", value: viewModel.metrics.formattedSharpeRatio)
|
||||
MetricCard(title: "Win Rate", value: viewModel.metrics.formattedWinRate)
|
||||
MetricCard(title: "Avg Monthly", value: viewModel.metrics.formattedAverageMonthlyReturn)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private var simpleMetricsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Performance Summary")
|
||||
.font(.headline)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
MetricChip(title: "CAGR", value: viewModel.metrics.formattedCAGR)
|
||||
MetricChip(title: "Avg Monthly", value: viewModel.metrics.formattedAverageMonthlyReturn)
|
||||
MetricChip(title: "Contributions", value: viewModel.source.totalContributions.compactCurrencyString)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
// MARK: - Transactions Section
|
||||
|
||||
private var transactionsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Transactions")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("\(viewModel.transactions.count)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if viewModel.transactions.isEmpty {
|
||||
Text("Add buys, sells, dividends, and fees to track cashflows.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
HStack(spacing: 12) {
|
||||
MetricChip(title: "Invested", value: viewModel.source.totalInvested.compactCurrencyString)
|
||||
MetricChip(title: "Dividends", value: viewModel.source.totalDividends.compactCurrencyString)
|
||||
MetricChip(title: "Fees", value: viewModel.source.totalFees.compactCurrencyString)
|
||||
}
|
||||
|
||||
ForEach(viewModel.transactions.prefix(5)) { transaction in
|
||||
TransactionRow(transaction: transaction)
|
||||
.contextMenu {
|
||||
Button(role: .destructive) {
|
||||
viewModel.deleteTransaction(transaction)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.transactions.count > 5 {
|
||||
Text("+ \(viewModel.transactions.count - 5) more")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
// MARK: - Snapshots Section
|
||||
|
||||
private var snapshotsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Snapshots")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("\(viewModel.snapshotCount)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if viewModel.isHistoryLimited {
|
||||
HStack {
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundColor(.appWarning)
|
||||
Text("\(viewModel.hiddenSnapshotCount) older snapshots hidden. Upgrade for full history.")
|
||||
.font(.caption)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Upgrade") {
|
||||
viewModel.showingPaywall = true
|
||||
}
|
||||
.font(.caption.weight(.semibold))
|
||||
}
|
||||
.padding(8)
|
||||
.background(Color.appWarning.opacity(0.1))
|
||||
.cornerRadius(AppConstants.UI.smallCornerRadius)
|
||||
}
|
||||
|
||||
ForEach(viewModel.visibleSnapshots) { snapshot in
|
||||
SnapshotRowView(snapshot: snapshot, onEdit: {
|
||||
editingSnapshot = snapshot
|
||||
})
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
editingSnapshot = snapshot
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
editingSnapshot = snapshot
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button {
|
||||
editingSnapshot = snapshot
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
viewModel.deleteSnapshot(snapshot)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
|
||||
if snapshot.id != viewModel.visibleSnapshots.last?.id {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.snapshots.count > 10 {
|
||||
Text("+ \(viewModel.snapshots.count - 10) more snapshots")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Metric Card
|
||||
|
||||
struct MetricCard: View {
|
||||
let title: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Snapshot Row View
|
||||
|
||||
struct SnapshotRowView: View {
|
||||
let snapshot: Snapshot
|
||||
let onEdit: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(snapshot.date.friendlyDescription)
|
||||
.font(.subheadline)
|
||||
|
||||
if let notes = snapshot.notes, !notes.isEmpty {
|
||||
Text(notes)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(snapshot.formattedValue)
|
||||
.font(.subheadline.weight(.medium))
|
||||
|
||||
if snapshot.contribution != nil && snapshot.decimalContribution > 0 {
|
||||
Text("+ \(snapshot.decimalContribution.currencyString)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.appPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
if let onEdit = onEdit {
|
||||
Button(action: onEdit) {
|
||||
Image(systemName: "pencil")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(6)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Transaction Row View
|
||||
|
||||
struct TransactionRow: View {
|
||||
let transaction: Transaction
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(transaction.transactionType.displayName)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
Text(transaction.date.friendlyDescription)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(transaction.decimalAmount.compactCurrencyString)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(transaction.transactionType == .fee ? .negativeRed : .primary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
struct MetricChip: View {
|
||||
let title: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(value)
|
||||
.font(.caption.weight(.semibold))
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 10)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
Text("Source Detail Preview")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SourceListView: View {
|
||||
@EnvironmentObject var iapService: IAPService
|
||||
@EnvironmentObject var accountStore: AccountStore
|
||||
@StateObject private var viewModel: SourceListViewModel
|
||||
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
|
||||
|
||||
init(iapService: IAPService) {
|
||||
_viewModel = StateObject(wrappedValue: SourceListViewModel(iapService: iapService))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
AppBackground()
|
||||
|
||||
Group {
|
||||
if viewModel.isEmpty {
|
||||
emptyStateView
|
||||
} else {
|
||||
sourcesList
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Sources")
|
||||
.searchable(text: $viewModel.searchText, prompt: "Search sources")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
viewModel.addSourceTapped()
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
categoryFilterMenu
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
accountFilterMenu
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showingAddSource) {
|
||||
AddSourceView()
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showingPaywall) {
|
||||
PaywallView()
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.selectedAccount = accountStore.selectedAccount
|
||||
viewModel.showAllAccounts = accountStore.showAllAccounts
|
||||
}
|
||||
.onReceive(accountStore.$selectedAccount) { account in
|
||||
viewModel.selectedAccount = account
|
||||
}
|
||||
.onReceive(accountStore.$showAllAccounts) { showAll in
|
||||
viewModel.showAllAccounts = showAll
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sources List
|
||||
|
||||
private var sourcesList: some View {
|
||||
List {
|
||||
// Summary Header
|
||||
if !viewModel.isFiltered {
|
||||
Section {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Total Value")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(viewModel.formattedTotalValue)
|
||||
.font(.title2.weight(.bold))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing) {
|
||||
Text("Sources")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text("\(viewModel.sources.count)")
|
||||
.font(.title2.weight(.bold))
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// Source limit warning
|
||||
if viewModel.sourceLimitReached {
|
||||
Section {
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(.appWarning)
|
||||
|
||||
Text("Source limit reached. Upgrade to Premium for unlimited sources.")
|
||||
.font(.subheadline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Upgrade") {
|
||||
viewModel.showingPaywall = true
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.appPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sources
|
||||
Section {
|
||||
ForEach(viewModel.sources) { source in
|
||||
NavigationLink {
|
||||
SourceDetailView(source: source, iapService: iapService)
|
||||
} label: {
|
||||
SourceRowView(source: source, calmModeEnabled: calmModeEnabled)
|
||||
}
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
viewModel.deleteSource(at: indexSet)
|
||||
}
|
||||
} header: {
|
||||
if viewModel.isFiltered {
|
||||
HStack {
|
||||
Text("\(viewModel.sources.count) results")
|
||||
Spacer()
|
||||
Button("Clear Filters") {
|
||||
viewModel.clearFilters()
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.scrollContentBackground(.hidden)
|
||||
.refreshable {
|
||||
viewModel.loadData()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty State
|
||||
|
||||
private var emptyStateView: some View {
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: "tray")
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("No Investment Sources")
|
||||
.font(.custom("Avenir Next", size: 24).weight(.semibold))
|
||||
|
||||
Text("Add your first investment source to start tracking your portfolio.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
|
||||
Button {
|
||||
viewModel.showingAddSource = true
|
||||
} label: {
|
||||
Label("Add Source", systemImage: "plus")
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
.padding()
|
||||
.frame(maxWidth: 200)
|
||||
.background(Color.appPrimary)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Category Filter Menu
|
||||
|
||||
private var categoryFilterMenu: some View {
|
||||
Menu {
|
||||
Button {
|
||||
viewModel.selectCategory(nil)
|
||||
} label: {
|
||||
HStack {
|
||||
Text("All Categories")
|
||||
if viewModel.selectedCategory == nil {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
ForEach(viewModel.categories) { category in
|
||||
Button {
|
||||
viewModel.selectCategory(category)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: category.icon)
|
||||
Text(category.name)
|
||||
if viewModel.selectedCategory?.id == category.id {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "line.3.horizontal.decrease.circle")
|
||||
if viewModel.selectedCategory != nil {
|
||||
Circle()
|
||||
.fill(Color.appPrimary)
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Account Filter Menu
|
||||
|
||||
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: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "person.2")
|
||||
if !accountStore.showAllAccounts {
|
||||
Circle()
|
||||
.fill(Color.appPrimary)
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Source Row View
|
||||
|
||||
struct SourceRowView: View {
|
||||
@ObservedObject var source: InvestmentSource
|
||||
let calmModeEnabled: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
// Category indicator
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill((source.category?.color ?? .gray).opacity(0.2))
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
Image(systemName: source.category?.icon ?? "questionmark")
|
||||
.font(.system(size: 18))
|
||||
.foregroundColor(source.category?.color ?? .gray)
|
||||
}
|
||||
|
||||
// Source info
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(source.name)
|
||||
.font(.headline)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text(source.category?.name ?? "Uncategorized")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if source.needsUpdate {
|
||||
Label("Needs update", systemImage: "bell.badge.fill")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.appWarning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Value
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Text(source.latestValue.compactCurrencyString)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
if calmModeEnabled {
|
||||
Text(String(format: "%.1f%%", NSDecimalNumber(decimal: source.totalReturn).doubleValue))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: source.totalReturn >= 0 ? "arrow.up.right" : "arrow.down.right")
|
||||
.font(.caption2)
|
||||
Text(String(format: "%.1f%%", NSDecimalNumber(decimal: source.totalReturn).doubleValue))
|
||||
.font(.caption)
|
||||
}
|
||||
.foregroundColor(source.totalReturn >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.opacity(source.isActive ? 1 : 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SourceListView(iapService: IAPService())
|
||||
.environmentObject(IAPService())
|
||||
.environmentObject(AccountStore(iapService: IAPService()))
|
||||
}
|
||||
Reference in New Issue
Block a user