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,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()))
}