Remove Add Transaction feature and clean all related code

Deleted files:
- AddTransactionView.swift
- TransactionRepository.swift
- Transaction+CoreDataClass.swift

Cleaned files:
- SourceDetailViewModel: removed transactions published property,
  showingAddTransaction flag, transactionRepository dependency,
  addTransaction() and deleteTransaction() methods
- SourceDetailView: removed transactionsSection, TransactionRow struct,
  Add Transaction button from quickActions, sheet presentation
- InvestmentSource+CoreDataClass: removed transactions NSManaged property,
  transactionsArray, totalInvested, totalDividends, totalFees computed
  properties, and all transaction Core Data accessors
- SettingsViewModel: removed "Transaction" from resetAllData entity list
- SampleDataService: removed transactionRepository and sample transaction

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
alexandrev-tibco
2026-02-20 23:04:03 +01:00
parent b17d8866a4
commit 7cb5f92cf4
20 changed files with 215 additions and 503 deletions
@@ -22,28 +22,8 @@ struct ChartsContainerView: View {
// Chart Type Selector
chartTypeSelector
// Time Range Selector
if viewModel.selectedChartType != .allocation &&
viewModel.selectedChartType != .riskReturn {
timeRangeSelector
}
// Category Filter
if viewModel.selectedChartType == .evolution ||
viewModel.selectedChartType == .prediction {
categoryFilter
}
// Source Filter
if viewModel.selectedChartType == .evolution {
sourceFilter
}
// Breakdown Selector
if viewModel.selectedChartType == .allocation ||
viewModel.selectedChartType == .performance {
breakdownSelector
}
// Unified Filters
filtersSection
// Chart Content
chartContent
@@ -108,6 +88,74 @@ struct ChartsContainerView: View {
}
}
// MARK: - Unified Filters Section
private var hasAnyFilter: Bool {
let chartType = viewModel.selectedChartType
let hasTimeRange = chartType != .allocation && chartType != .riskReturn
let hasCategories = !viewModel.availableCategories(for: chartType).isEmpty
let hasSources = !viewModel.availableSources(for: chartType).isEmpty
let hasBreakdown = chartType == .allocation || chartType == .performance
return hasTimeRange || hasCategories || hasSources || hasBreakdown
}
@ViewBuilder
private var filtersSection: some View {
if hasAnyFilter {
VStack(alignment: .leading, spacing: 10) {
let chartType = viewModel.selectedChartType
// Time Range
if chartType != .allocation && chartType != .riskReturn {
filterRow(icon: "calendar", label: "Period") {
timeRangeSelector
}
}
// Breakdown (category vs source)
if chartType == .allocation || chartType == .performance {
filterRow(icon: "square.grid.2x2", label: "Group") {
breakdownSelector
}
}
// Category
let availableCategories = viewModel.availableCategories(for: chartType)
if !availableCategories.isEmpty {
filterRow(icon: "tag", label: "Category") {
categoryFilter
}
}
// Source
let availableSources = viewModel.availableSources(for: chartType)
if !availableSources.isEmpty {
filterRow(icon: "building.2", label: "Source") {
sourceFilter
}
}
}
.padding(12)
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 4, y: 1)
}
}
private func filterRow<Content: View>(icon: String, label: String, @ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 4) {
Image(systemName: icon)
.font(.caption2)
.foregroundColor(.secondary)
Text(label)
.font(.caption.weight(.medium))
.foregroundColor(.secondary)
}
content()
}
}
// MARK: - Time Range Selector
private var timeRangeSelector: some View {
@@ -229,26 +277,31 @@ struct ChartsContainerView: View {
}
}
ForEach(availableSources, id: \.id) { source in
let sourceId = source.id ?? UUID()
ForEach(Array(availableSources.enumerated()), id: \.element.id) { index, source in
let sourceId = source.id
let isSelected = viewModel.selectedSourceIds.contains(sourceId)
let sourceColor = Color.sourceColor(at: index)
Button {
viewModel.selectedSource = nil
viewModel.selectedCategory = nil
if isSelected {
viewModel.selectedSourceIds.remove(sourceId)
} else {
viewModel.selectedSourceIds.insert(sourceId)
}
} label: {
Text(source.name)
.font(.caption.weight(.medium))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
isSelected
? Color.appPrimary
: Color.gray.opacity(0.1)
HStack(spacing: 4) {
Circle()
.fill(sourceColor)
.frame(width: 8, height: 8)
Text(source.name)
}
.font(.caption.weight(.medium))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
isSelected
? sourceColor
: Color.gray.opacity(0.1)
)
.foregroundColor(
isSelected
@@ -975,15 +1028,36 @@ struct AllocationEvolutionChart: View {
.frame(height: 260)
}
/// Categories in stable order (preserving the ViewModel's sort: largest overall first)
private var stableCategoryNames: [String] {
var seen = Set<String>()
var ordered: [String] = []
for item in data {
if seen.insert(item.category).inserted {
ordered.append(item.category)
}
}
return ordered
}
private var stableCategoryColors: [Color] {
stableCategoryNames.map { name in
if let hex = data.first(where: { $0.category == name })?.color {
return Color(hex: hex) ?? .gray
}
return .gray
}
}
private var chartView: some View {
Chart(identifiableData) { item in
AreaMark(
x: .value("Date", item.date),
BarMark(
x: .value("Date", item.date, unit: .month),
y: .value("Percentage", item.percentage)
)
.foregroundStyle(by: .value("Category", item.category))
}
.chartForegroundStyleScale(domain: categoryNames, range: categoryColors)
.chartForegroundStyleScale(domain: stableCategoryNames, range: stableCategoryColors)
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: 2)) { _ in
AxisValueLabel(format: .dateTime.month(.abbreviated).year(.twoDigits))
@@ -1002,19 +1076,6 @@ struct AllocationEvolutionChart: View {
.frame(height: 260)
.drawingGroup()
}
private var categoryNames: [String] {
Array(Set(data.map { $0.category })).sorted()
}
private var categoryColors: [Color] {
categoryNames.map { name in
if let hex = data.first(where: { $0.category == name })?.color {
return Color(hex: hex) ?? .gray
}
return .gray
}
}
}
#Preview {
@@ -416,9 +416,10 @@ struct MonthlyCheckInCard: View {
@State private var showingStartOptions = false
@State private var startDestinationActive = false
@State private var shouldDuplicatePrevious = false
@State private var cachedCompletionDate: Date?
private var effectiveLastCheckInDate: Date? {
MonthlyCheckInStore.latestCompletionDate() ?? lastUpdatedDate
cachedCompletionDate ?? lastUpdatedDate
}
private var checkInProgress: Double {
@@ -432,7 +433,8 @@ struct MonthlyCheckInCard: View {
private var nextCheckInDate: Date? {
guard let last = effectiveLastCheckInDate else { return nil }
return last.adding(months: 1)
let effective = MonthlyCheckInStore.effectiveMonth(for: last, relativeTo: last)
return effective.adding(months: 1).endOfMonth
}
private var isOverdue: Bool {
@@ -473,10 +475,10 @@ struct MonthlyCheckInCard: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(currentMonthLabel)
Text("Monthly Check-in")
.font(.headline)
Text("Keep a calm, deliberate rhythm. Update your sources and add a short note.")
Text(currentMonthLabel)
.font(.subheadline)
.foregroundColor(.secondary)
@@ -583,6 +585,14 @@ struct MonthlyCheckInCard: View {
.padding(.horizontal, 24)
.presentationDetents([.height(280)])
}
.onAppear {
cachedCompletionDate = MonthlyCheckInStore.latestCompletionDate()
}
.onChange(of: startDestinationActive) { _, isActive in
if !isActive {
cachedCompletionDate = MonthlyCheckInStore.latestCompletionDate()
}
}
}
private var defaultReminderTime: Date {
@@ -7,7 +7,7 @@ struct MonthlyCheckInShareCardView: View {
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text(summary.periodLabel)
Text(summary.formattedMonthYear)
.font(.caption.weight(.semibold))
.foregroundColor(.white.opacity(0.85))
@@ -17,7 +17,9 @@ struct MonthlyCheckInShareCardView: View {
metricRow("Starting", summary.formattedStartingValue)
metricRow("Ending", summary.formattedEndingValue)
metricRow("Contributions", summary.formattedContributions)
if summary.contributions != 0 {
metricRow("Contributions", summary.formattedContributions)
}
metricRow("Net performance", "\(summary.formattedNetPerformance) (\(summary.formattedNetPerformancePercentage))")
Spacer(minLength: 0)
@@ -106,13 +106,31 @@ struct GoalEditorView: View {
private func parseDecimal(_ value: String) -> Decimal? {
let locale = CurrencyFormatter.locale(for: currencyCode)
let cleaned = value
let stripped = value
.replacingOccurrences(of: currencySymbol, with: "")
.replacingOccurrences(of: locale.groupingSeparator ?? "", with: "")
.trimmingCharacters(in: .whitespaces)
guard !cleaned.isEmpty else { return nil }
guard !stripped.isEmpty else { return nil }
let decimalSep = locale.decimalSeparator ?? "."
let groupingSep = locale.groupingSeparator ?? ""
// Detect alternate decimal BEFORE removing separators
let usesAlternateDecimal =
(decimalSep == "," && stripped.contains(".") && !stripped.contains(",")) ||
(decimalSep == "." && stripped.contains(",") && !stripped.contains("."))
if usesAlternateDecimal {
let normalized = stripped
.replacingOccurrences(of: groupingSep, with: "")
.replacingOccurrences(of: ",", with: ".")
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter.number(from: normalized)?.decimalValue
}
let cleaned = stripped.replacingOccurrences(of: groupingSep, with: "")
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.locale = locale
@@ -123,7 +141,7 @@ struct GoalEditorView: View {
// Fallback for mixed locale input
let normalized = cleaned
.replacingOccurrences(of: locale.decimalSeparator ?? ",", with: ".")
.replacingOccurrences(of: decimalSep, with: ".")
.replacingOccurrences(of: ",", with: ".")
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter.number(from: normalized)?.decimalValue
@@ -1,94 +0,0 @@
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 }
}
@@ -55,9 +55,6 @@ struct SourceDetailView: View {
metricsSection
}
// Transactions
transactionsSection
// Snapshots List
snapshotsSection
}
@@ -95,18 +92,6 @@ struct SourceDetailView: View {
MissingSnapshotView()
}
}
.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)
}
@@ -187,30 +172,16 @@ struct SourceDetailView: View {
// 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)
}
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)
}
}
@@ -362,56 +333,6 @@ struct SourceDetailView: View {
.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 {
@@ -567,32 +488,6 @@ struct SnapshotRowView: View {
}
}
// 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