initial version

This commit is contained in:
alexandrev-tibco
2026-01-15 09:24:06 +01:00
parent bab350dd22
commit 7988257399
139 changed files with 13149 additions and 3233 deletions
@@ -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()))
}