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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user