diff --git a/.DS_Store b/.DS_Store index b391cc2..f9d4794 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/Changes_for_1.1.0.pdf b/Changes_for_1.1.0.pdf new file mode 100644 index 0000000..032e3c5 Binary files /dev/null and b/Changes_for_1.1.0.pdf differ diff --git a/ExportOptions.plist b/ExportOptions.plist new file mode 100644 index 0000000..601de3e --- /dev/null +++ b/ExportOptions.plist @@ -0,0 +1,14 @@ + + + + + method + app-store-connect + teamID + 2825Q76T7H + uploadSymbols + + signingStyle + automatic + + diff --git a/PortfolioJournal.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/UserInterfaceState.xcuserstate b/PortfolioJournal.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/UserInterfaceState.xcuserstate index 3bd7ec7..3376fbd 100644 Binary files a/PortfolioJournal.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/UserInterfaceState.xcuserstate and b/PortfolioJournal.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/PortfolioJournal/Models/CoreData/InvestmentSource+CoreDataClass.swift b/PortfolioJournal/Models/CoreData/InvestmentSource+CoreDataClass.swift index 332c3fc..41a602f 100644 --- a/PortfolioJournal/Models/CoreData/InvestmentSource+CoreDataClass.swift +++ b/PortfolioJournal/Models/CoreData/InvestmentSource+CoreDataClass.swift @@ -16,7 +16,6 @@ public class InvestmentSource: NSManagedObject, Identifiable { @NSManaged public var category: Category? @NSManaged public var account: Account? @NSManaged public var snapshots: NSSet? - @NSManaged public var transactions: NSSet? @NSManaged public var asset: Asset? public override func awakeFromInsert() { @@ -104,13 +103,6 @@ extension InvestmentSource { snapshots?.count ?? 0 } - /// Returns transactions sorted by date descending - /// Performance note: This sorts on every call. For repeated access, cache the result. - var transactionsArray: [Transaction] { - let set = transactions as? Set ?? [] - return set.sorted { $0.date > $1.date } - } - var frequency: NotificationFrequency { NotificationFrequency(rawValue: notificationFrequency) ?? .monthly } @@ -168,37 +160,6 @@ extension InvestmentSource { } } - /// Performance: Iterates transactions directly without sorting - var totalInvested: Decimal { - guard let set = transactions as? Set else { return Decimal.zero } - return set.reduce(Decimal.zero) { result, transaction in - let amount = transaction.decimalAmount - switch transaction.transactionType { - case .buy: - return result + amount - case .sell: - return result - amount - default: - return result - } - } - } - - /// Performance: Iterates transactions directly without sorting - var totalDividends: Decimal { - guard let set = transactions as? Set else { return Decimal.zero } - return set.reduce(Decimal.zero) { result, transaction in - transaction.transactionType == .dividend ? result + transaction.decimalAmount : result - } - } - - /// Performance: Iterates transactions directly without sorting - var totalFees: Decimal { - guard let set = transactions as? Set else { return Decimal.zero } - return set.reduce(Decimal.zero) { result, transaction in - transaction.transactionType == .fee ? result + transaction.decimalAmount : result - } - } } // MARK: - Account Scheduling @@ -233,15 +194,4 @@ extension InvestmentSource { @objc(removeSnapshots:) @NSManaged public func removeFromSnapshots(_ values: NSSet) - @objc(addTransactionsObject:) - @NSManaged public func addToTransactions(_ value: Transaction) - - @objc(removeTransactionsObject:) - @NSManaged public func removeFromTransactions(_ value: Transaction) - - @objc(addTransactions:) - @NSManaged public func addToTransactions(_ values: NSSet) - - @objc(removeTransactions:) - @NSManaged public func removeFromTransactions(_ values: NSSet) } diff --git a/PortfolioJournal/Models/CoreData/Transaction+CoreDataClass.swift b/PortfolioJournal/Models/CoreData/Transaction+CoreDataClass.swift deleted file mode 100644 index 65a36dc..0000000 --- a/PortfolioJournal/Models/CoreData/Transaction+CoreDataClass.swift +++ /dev/null @@ -1,75 +0,0 @@ -import Foundation -import CoreData - -@objc(Transaction) -public class Transaction: NSManagedObject, Identifiable { - @nonobjc public class func fetchRequest() -> NSFetchRequest { - return NSFetchRequest(entityName: "Transaction") - } - - @NSManaged public var id: UUID - @NSManaged public var date: Date - @NSManaged public var type: String - @NSManaged public var shares: NSDecimalNumber? - @NSManaged public var price: NSDecimalNumber? - @NSManaged public var amount: NSDecimalNumber? - @NSManaged public var notes: String? - @NSManaged public var createdAt: Date - @NSManaged public var source: InvestmentSource? - - public override func awakeFromInsert() { - super.awakeFromInsert() - id = UUID() - createdAt = Date() - date = Date() - type = TransactionType.buy.rawValue - } -} - -enum TransactionType: String, CaseIterable, Identifiable { - case buy - case sell - case dividend - case fee - case transfer - - var id: String { rawValue } - - var displayName: String { - switch self { - case .buy: return "Buy" - case .sell: return "Sell" - case .dividend: return "Dividend" - case .fee: return "Fee" - case .transfer: return "Transfer" - } - } - - var isInvestmentFlow: Bool { - self == .buy || self == .sell - } -} - -// MARK: - Computed Properties - -extension Transaction { - var decimalShares: Decimal { - shares?.decimalValue ?? Decimal.zero - } - - var decimalPrice: Decimal { - price?.decimalValue ?? Decimal.zero - } - - var decimalAmount: Decimal { - if let amount = amount?.decimalValue, amount != 0 { - return amount - } - return decimalShares * decimalPrice - } - - var transactionType: TransactionType { - TransactionType(rawValue: type) ?? .buy - } -} - diff --git a/PortfolioJournal/Models/MonthlySummary.swift b/PortfolioJournal/Models/MonthlySummary.swift index 338ba6e..7743f1c 100644 --- a/PortfolioJournal/Models/MonthlySummary.swift +++ b/PortfolioJournal/Models/MonthlySummary.swift @@ -15,6 +15,12 @@ struct MonthlySummary { return NSDecimalNumber(decimal: netPerformance / base).doubleValue * 100 } + var formattedMonthYear: String { + let formatter = DateFormatter() + formatter.dateFormat = "MMMM yyyy" + return formatter.string(from: startDate) + } + var formattedStartingValue: String { CurrencyFormatter.format(startingValue, style: .currency, maximumFractionDigits: 0) } diff --git a/PortfolioJournal/Repositories/TransactionRepository.swift b/PortfolioJournal/Repositories/TransactionRepository.swift deleted file mode 100644 index 2ee5dff..0000000 --- a/PortfolioJournal/Repositories/TransactionRepository.swift +++ /dev/null @@ -1,62 +0,0 @@ -import Foundation -import CoreData - -class TransactionRepository { - private let context: NSManagedObjectContext - - init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) { - self.context = context - } - - func fetchTransactions(for source: InvestmentSource) -> [Transaction] { - let request: NSFetchRequest = Transaction.fetchRequest() - request.predicate = NSPredicate(format: "source == %@", source) - request.sortDescriptors = [ - NSSortDescriptor(keyPath: \Transaction.date, ascending: false) - ] - return (try? context.fetch(request)) ?? [] - } - - @discardableResult - func createTransaction( - source: InvestmentSource, - type: TransactionType, - date: Date, - shares: Decimal?, - price: Decimal?, - amount: Decimal?, - notes: String? - ) -> Transaction { - let transaction = Transaction(context: context) - transaction.source = source - transaction.type = type.rawValue - transaction.date = date - if let shares = shares { - transaction.shares = NSDecimalNumber(decimal: shares) - } - if let price = price { - transaction.price = NSDecimalNumber(decimal: price) - } - if let amount = amount { - transaction.amount = NSDecimalNumber(decimal: amount) - } - transaction.notes = notes - - save() - return transaction - } - - func deleteTransaction(_ transaction: Transaction) { - context.delete(transaction) - save() - } - - private func save() { - guard context.hasChanges else { return } - do { - try context.save() - } catch { - print("Failed to save transaction: \(error)") - } - } -} diff --git a/PortfolioJournal/Services/SampleDataService.swift b/PortfolioJournal/Services/SampleDataService.swift index 5711604..689f11b 100644 --- a/PortfolioJournal/Services/SampleDataService.swift +++ b/PortfolioJournal/Services/SampleDataService.swift @@ -19,7 +19,6 @@ class SampleDataService { let snapshotRepository = SnapshotRepository(context: context) let goalRepository = GoalRepository(context: context) - let transactionRepository = TransactionRepository(context: context) let categories = fetchCategories(in: context) guard let fallbackCategory = categories.first else { return } @@ -70,16 +69,6 @@ class SampleDataService { seedMonthlyNotes() - transactionRepository.createTransaction( - source: stocks, - type: .buy, - date: Calendar.current.date(byAdding: .month, value: -3, to: Date()) ?? Date(), - shares: 10, - price: 400, - amount: nil, - notes: "Sample buy" - ) - _ = goalRepository.createGoal( name: "1M Goal", targetAmount: 1_000_000, diff --git a/PortfolioJournal/Utilities/Extensions/Color+Extensions.swift b/PortfolioJournal/Utilities/Extensions/Color+Extensions.swift index f5ea093..94ab1de 100644 --- a/PortfolioJournal/Utilities/Extensions/Color+Extensions.swift +++ b/PortfolioJournal/Utilities/Extensions/Color+Extensions.swift @@ -105,6 +105,28 @@ extension Color { return Color(hex: hex) ?? .blue } + // MARK: - Source Colors (distinct per-source palette, different order from categories) + + static let sourceColors: [String] = [ + "#6366F1", // Indigo + "#F97316", // Orange + "#06B6D4", // Cyan + "#EF4444", // Red + "#84CC16", // Lime + "#EC4899", // Pink + "#14B8A6", // Teal + "#F59E0B", // Amber + "#8B5CF6", // Purple + "#3B82F6", // Blue + "#A855F7", // Violet + "#10B981", // Green + ] + + static func sourceColor(at index: Int) -> Color { + let hex = sourceColors[index % sourceColors.count] + return Color(hex: hex) ?? .blue + } + // MARK: - Chart Colors static let chartColors: [Color] = categoryColors.compactMap { Color(hex: $0) } diff --git a/PortfolioJournal/Utilities/Extensions/Date+Extensions.swift b/PortfolioJournal/Utilities/Extensions/Date+Extensions.swift index 7a2297b..df5ba5b 100644 --- a/PortfolioJournal/Utilities/Extensions/Date+Extensions.swift +++ b/PortfolioJournal/Utilities/Extensions/Date+Extensions.swift @@ -136,6 +136,18 @@ extension Date { return formatter.localizedString(for: self, relativeTo: Date()) } + var relativeDayDescription: String { + let calendar = Calendar.current + let days = calendar.dateComponents([.day], from: calendar.startOfDay(for: self), to: calendar.startOfDay(for: Date())).day ?? 0 + if days == 0 { return String(localized: "date_today") } + if days == 1 { return "1d ago" } + if days < 31 { return "\(days)d ago" } + let months = calendar.dateComponents([.month], from: self, to: Date()).month ?? 0 + if months < 12 { return "\(max(1, months))mo ago" } + let years = calendar.dateComponents([.year], from: self, to: Date()).year ?? 0 + return "\(max(1, years))y ago" + } + var friendlyDescription: String { if isToday { return String(localized: "date_today") diff --git a/PortfolioJournal/ViewModels/MonthlyCheckInViewModel.swift b/PortfolioJournal/ViewModels/MonthlyCheckInViewModel.swift index 1722356..00f9076 100644 --- a/PortfolioJournal/ViewModels/MonthlyCheckInViewModel.swift +++ b/PortfolioJournal/ViewModels/MonthlyCheckInViewModel.swift @@ -89,8 +89,7 @@ class MonthlyCheckInViewModel: ObservableObject { ) let targetSourceIds = Set(targetSnapshots.compactMap { $0.source?.id }) - let now = Date() - let targetDate = targetRange.contains(now) ? now : targetRange.end + let targetDate = Date() for source in sources { let sourceId = source.id diff --git a/PortfolioJournal/ViewModels/SettingsViewModel.swift b/PortfolioJournal/ViewModels/SettingsViewModel.swift index b646eb0..0a49f74 100644 --- a/PortfolioJournal/ViewModels/SettingsViewModel.swift +++ b/PortfolioJournal/ViewModels/SettingsViewModel.swift @@ -316,7 +316,6 @@ class SettingsViewModel: ObservableObject { "Asset", "Account", "Goal", - "Transaction", "PredictionCache" ] diff --git a/PortfolioJournal/ViewModels/SourceDetailViewModel.swift b/PortfolioJournal/ViewModels/SourceDetailViewModel.swift index 46991fc..b1797e3 100644 --- a/PortfolioJournal/ViewModels/SourceDetailViewModel.swift +++ b/PortfolioJournal/ViewModels/SourceDetailViewModel.swift @@ -11,14 +11,12 @@ class SourceDetailViewModel: ObservableObject { @Published var metrics: InvestmentMetrics = .empty @Published var predictions: [Prediction] = [] @Published var predictionResult: PredictionResult? - @Published var transactions: [Transaction] = [] @Published var isDeleted = false @Published var isLoading = false @Published var showingAddSnapshot = false @Published var showingEditSource = false @Published var showingPaywall = false - @Published var showingAddTransaction = false @Published var errorMessage: String? // MARK: - Chart Data @@ -29,7 +27,6 @@ class SourceDetailViewModel: ObservableObject { private let snapshotRepository: SnapshotRepository private let sourceRepository: InvestmentSourceRepository - private let transactionRepository: TransactionRepository private let calculationService: CalculationService private let predictionEngine: PredictionEngine private let freemiumValidator: FreemiumValidator @@ -46,7 +43,6 @@ class SourceDetailViewModel: ObservableObject { source: InvestmentSource, snapshotRepository: SnapshotRepository? = nil, sourceRepository: InvestmentSourceRepository? = nil, - transactionRepository: TransactionRepository? = nil, calculationService: CalculationService? = nil, predictionEngine: PredictionEngine? = nil, iapService: IAPService @@ -55,7 +51,6 @@ class SourceDetailViewModel: ObservableObject { self.sourceName = source.name self.snapshotRepository = snapshotRepository ?? SnapshotRepository() self.sourceRepository = sourceRepository ?? InvestmentSourceRepository() - self.transactionRepository = transactionRepository ?? TransactionRepository() self.calculationService = calculationService ?? .shared self.predictionEngine = predictionEngine ?? .shared self.freemiumValidator = FreemiumValidator(iapService: iapService) @@ -147,8 +142,6 @@ class SourceDetailViewModel: ObservableObject { } } - // Transactions update independently - self.transactions = transactionRepository.fetchTransactions(for: source) } self.isRefreshing = false } @@ -191,33 +184,6 @@ class SourceDetailViewModel: ObservableObject { refreshData() } - // MARK: - Transaction Actions - - func addTransaction( - type: TransactionType, - date: Date, - shares: Decimal?, - price: Decimal?, - amount: Decimal?, - notes: String? - ) { - transactionRepository.createTransaction( - source: source, - type: type, - date: date, - shares: shares, - price: price, - amount: amount, - notes: notes - ) - refreshData() - } - - func deleteTransaction(_ transaction: Transaction) { - transactionRepository.deleteTransaction(transaction) - refreshData() - } - // MARK: - Source Actions func updateSource( diff --git a/PortfolioJournal/Views/Charts/ChartsContainerView.swift b/PortfolioJournal/Views/Charts/ChartsContainerView.swift index 780ebc8..a0b97c1 100644 --- a/PortfolioJournal/Views/Charts/ChartsContainerView.swift +++ b/PortfolioJournal/Views/Charts/ChartsContainerView.swift @@ -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(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() + 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 { diff --git a/PortfolioJournal/Views/Dashboard/DashboardView.swift b/PortfolioJournal/Views/Dashboard/DashboardView.swift index 23c91ab..acd60d9 100644 --- a/PortfolioJournal/Views/Dashboard/DashboardView.swift +++ b/PortfolioJournal/Views/Dashboard/DashboardView.swift @@ -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 { diff --git a/PortfolioJournal/Views/Dashboard/ShareCards.swift b/PortfolioJournal/Views/Dashboard/ShareCards.swift index 5ee7430..e882284 100644 --- a/PortfolioJournal/Views/Dashboard/ShareCards.swift +++ b/PortfolioJournal/Views/Dashboard/ShareCards.swift @@ -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) diff --git a/PortfolioJournal/Views/Goals/GoalEditorView.swift b/PortfolioJournal/Views/Goals/GoalEditorView.swift index 97a094f..5aba342 100644 --- a/PortfolioJournal/Views/Goals/GoalEditorView.swift +++ b/PortfolioJournal/Views/Goals/GoalEditorView.swift @@ -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 diff --git a/PortfolioJournal/Views/Sources/AddTransactionView.swift b/PortfolioJournal/Views/Sources/AddTransactionView.swift deleted file mode 100644 index c216f22..0000000 --- a/PortfolioJournal/Views/Sources/AddTransactionView.swift +++ /dev/null @@ -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 } -} - diff --git a/PortfolioJournal/Views/Sources/SourceDetailView.swift b/PortfolioJournal/Views/Sources/SourceDetailView.swift index 2b703b2..640a71f 100644 --- a/PortfolioJournal/Views/Sources/SourceDetailView.swift +++ b/PortfolioJournal/Views/Sources/SourceDetailView.swift @@ -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