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:
Binary file not shown.
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>method</key>
|
||||||
|
<string>app-store-connect</string>
|
||||||
|
<key>teamID</key>
|
||||||
|
<string>2825Q76T7H</string>
|
||||||
|
<key>uploadSymbols</key>
|
||||||
|
<true/>
|
||||||
|
<key>signingStyle</key>
|
||||||
|
<string>automatic</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
BIN
Binary file not shown.
@@ -16,7 +16,6 @@ public class InvestmentSource: NSManagedObject, Identifiable {
|
|||||||
@NSManaged public var category: Category?
|
@NSManaged public var category: Category?
|
||||||
@NSManaged public var account: Account?
|
@NSManaged public var account: Account?
|
||||||
@NSManaged public var snapshots: NSSet?
|
@NSManaged public var snapshots: NSSet?
|
||||||
@NSManaged public var transactions: NSSet?
|
|
||||||
@NSManaged public var asset: Asset?
|
@NSManaged public var asset: Asset?
|
||||||
|
|
||||||
public override func awakeFromInsert() {
|
public override func awakeFromInsert() {
|
||||||
@@ -104,13 +103,6 @@ extension InvestmentSource {
|
|||||||
snapshots?.count ?? 0
|
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<Transaction> ?? []
|
|
||||||
return set.sorted { $0.date > $1.date }
|
|
||||||
}
|
|
||||||
|
|
||||||
var frequency: NotificationFrequency {
|
var frequency: NotificationFrequency {
|
||||||
NotificationFrequency(rawValue: notificationFrequency) ?? .monthly
|
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<Transaction> 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<Transaction> 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<Transaction> else { return Decimal.zero }
|
|
||||||
return set.reduce(Decimal.zero) { result, transaction in
|
|
||||||
transaction.transactionType == .fee ? result + transaction.decimalAmount : result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Account Scheduling
|
// MARK: - Account Scheduling
|
||||||
@@ -233,15 +194,4 @@ extension InvestmentSource {
|
|||||||
@objc(removeSnapshots:)
|
@objc(removeSnapshots:)
|
||||||
@NSManaged public func removeFromSnapshots(_ values: NSSet)
|
@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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import CoreData
|
|
||||||
|
|
||||||
@objc(Transaction)
|
|
||||||
public class Transaction: NSManagedObject, Identifiable {
|
|
||||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Transaction> {
|
|
||||||
return NSFetchRequest<Transaction>(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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -15,6 +15,12 @@ struct MonthlySummary {
|
|||||||
return NSDecimalNumber(decimal: netPerformance / base).doubleValue * 100
|
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 {
|
var formattedStartingValue: String {
|
||||||
CurrencyFormatter.format(startingValue, style: .currency, maximumFractionDigits: 0)
|
CurrencyFormatter.format(startingValue, style: .currency, maximumFractionDigits: 0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> = 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)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,7 +19,6 @@ class SampleDataService {
|
|||||||
|
|
||||||
let snapshotRepository = SnapshotRepository(context: context)
|
let snapshotRepository = SnapshotRepository(context: context)
|
||||||
let goalRepository = GoalRepository(context: context)
|
let goalRepository = GoalRepository(context: context)
|
||||||
let transactionRepository = TransactionRepository(context: context)
|
|
||||||
|
|
||||||
let categories = fetchCategories(in: context)
|
let categories = fetchCategories(in: context)
|
||||||
guard let fallbackCategory = categories.first else { return }
|
guard let fallbackCategory = categories.first else { return }
|
||||||
@@ -70,16 +69,6 @@ class SampleDataService {
|
|||||||
|
|
||||||
seedMonthlyNotes()
|
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(
|
_ = goalRepository.createGoal(
|
||||||
name: "1M Goal",
|
name: "1M Goal",
|
||||||
targetAmount: 1_000_000,
|
targetAmount: 1_000_000,
|
||||||
|
|||||||
@@ -105,6 +105,28 @@ extension Color {
|
|||||||
return Color(hex: hex) ?? .blue
|
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
|
// MARK: - Chart Colors
|
||||||
|
|
||||||
static let chartColors: [Color] = categoryColors.compactMap { Color(hex: $0) }
|
static let chartColors: [Color] = categoryColors.compactMap { Color(hex: $0) }
|
||||||
|
|||||||
@@ -136,6 +136,18 @@ extension Date {
|
|||||||
return formatter.localizedString(for: self, relativeTo: 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 {
|
var friendlyDescription: String {
|
||||||
if isToday {
|
if isToday {
|
||||||
return String(localized: "date_today")
|
return String(localized: "date_today")
|
||||||
|
|||||||
@@ -89,8 +89,7 @@ class MonthlyCheckInViewModel: ObservableObject {
|
|||||||
)
|
)
|
||||||
let targetSourceIds = Set(targetSnapshots.compactMap { $0.source?.id })
|
let targetSourceIds = Set(targetSnapshots.compactMap { $0.source?.id })
|
||||||
|
|
||||||
let now = Date()
|
let targetDate = Date()
|
||||||
let targetDate = targetRange.contains(now) ? now : targetRange.end
|
|
||||||
|
|
||||||
for source in sources {
|
for source in sources {
|
||||||
let sourceId = source.id
|
let sourceId = source.id
|
||||||
|
|||||||
@@ -316,7 +316,6 @@ class SettingsViewModel: ObservableObject {
|
|||||||
"Asset",
|
"Asset",
|
||||||
"Account",
|
"Account",
|
||||||
"Goal",
|
"Goal",
|
||||||
"Transaction",
|
|
||||||
"PredictionCache"
|
"PredictionCache"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -11,14 +11,12 @@ class SourceDetailViewModel: ObservableObject {
|
|||||||
@Published var metrics: InvestmentMetrics = .empty
|
@Published var metrics: InvestmentMetrics = .empty
|
||||||
@Published var predictions: [Prediction] = []
|
@Published var predictions: [Prediction] = []
|
||||||
@Published var predictionResult: PredictionResult?
|
@Published var predictionResult: PredictionResult?
|
||||||
@Published var transactions: [Transaction] = []
|
|
||||||
@Published var isDeleted = false
|
@Published var isDeleted = false
|
||||||
|
|
||||||
@Published var isLoading = false
|
@Published var isLoading = false
|
||||||
@Published var showingAddSnapshot = false
|
@Published var showingAddSnapshot = false
|
||||||
@Published var showingEditSource = false
|
@Published var showingEditSource = false
|
||||||
@Published var showingPaywall = false
|
@Published var showingPaywall = false
|
||||||
@Published var showingAddTransaction = false
|
|
||||||
@Published var errorMessage: String?
|
@Published var errorMessage: String?
|
||||||
|
|
||||||
// MARK: - Chart Data
|
// MARK: - Chart Data
|
||||||
@@ -29,7 +27,6 @@ class SourceDetailViewModel: ObservableObject {
|
|||||||
|
|
||||||
private let snapshotRepository: SnapshotRepository
|
private let snapshotRepository: SnapshotRepository
|
||||||
private let sourceRepository: InvestmentSourceRepository
|
private let sourceRepository: InvestmentSourceRepository
|
||||||
private let transactionRepository: TransactionRepository
|
|
||||||
private let calculationService: CalculationService
|
private let calculationService: CalculationService
|
||||||
private let predictionEngine: PredictionEngine
|
private let predictionEngine: PredictionEngine
|
||||||
private let freemiumValidator: FreemiumValidator
|
private let freemiumValidator: FreemiumValidator
|
||||||
@@ -46,7 +43,6 @@ class SourceDetailViewModel: ObservableObject {
|
|||||||
source: InvestmentSource,
|
source: InvestmentSource,
|
||||||
snapshotRepository: SnapshotRepository? = nil,
|
snapshotRepository: SnapshotRepository? = nil,
|
||||||
sourceRepository: InvestmentSourceRepository? = nil,
|
sourceRepository: InvestmentSourceRepository? = nil,
|
||||||
transactionRepository: TransactionRepository? = nil,
|
|
||||||
calculationService: CalculationService? = nil,
|
calculationService: CalculationService? = nil,
|
||||||
predictionEngine: PredictionEngine? = nil,
|
predictionEngine: PredictionEngine? = nil,
|
||||||
iapService: IAPService
|
iapService: IAPService
|
||||||
@@ -55,7 +51,6 @@ class SourceDetailViewModel: ObservableObject {
|
|||||||
self.sourceName = source.name
|
self.sourceName = source.name
|
||||||
self.snapshotRepository = snapshotRepository ?? SnapshotRepository()
|
self.snapshotRepository = snapshotRepository ?? SnapshotRepository()
|
||||||
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository()
|
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository()
|
||||||
self.transactionRepository = transactionRepository ?? TransactionRepository()
|
|
||||||
self.calculationService = calculationService ?? .shared
|
self.calculationService = calculationService ?? .shared
|
||||||
self.predictionEngine = predictionEngine ?? .shared
|
self.predictionEngine = predictionEngine ?? .shared
|
||||||
self.freemiumValidator = FreemiumValidator(iapService: iapService)
|
self.freemiumValidator = FreemiumValidator(iapService: iapService)
|
||||||
@@ -147,8 +142,6 @@ class SourceDetailViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transactions update independently
|
|
||||||
self.transactions = transactionRepository.fetchTransactions(for: source)
|
|
||||||
}
|
}
|
||||||
self.isRefreshing = false
|
self.isRefreshing = false
|
||||||
}
|
}
|
||||||
@@ -191,33 +184,6 @@ class SourceDetailViewModel: ObservableObject {
|
|||||||
refreshData()
|
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
|
// MARK: - Source Actions
|
||||||
|
|
||||||
func updateSource(
|
func updateSource(
|
||||||
|
|||||||
@@ -22,28 +22,8 @@ struct ChartsContainerView: View {
|
|||||||
// Chart Type Selector
|
// Chart Type Selector
|
||||||
chartTypeSelector
|
chartTypeSelector
|
||||||
|
|
||||||
// Time Range Selector
|
// Unified Filters
|
||||||
if viewModel.selectedChartType != .allocation &&
|
filtersSection
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chart Content
|
// Chart Content
|
||||||
chartContent
|
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
|
// MARK: - Time Range Selector
|
||||||
|
|
||||||
private var timeRangeSelector: some View {
|
private var timeRangeSelector: some View {
|
||||||
@@ -229,26 +277,31 @@ struct ChartsContainerView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ForEach(availableSources, id: \.id) { source in
|
ForEach(Array(availableSources.enumerated()), id: \.element.id) { index, source in
|
||||||
let sourceId = source.id ?? UUID()
|
let sourceId = source.id
|
||||||
let isSelected = viewModel.selectedSourceIds.contains(sourceId)
|
let isSelected = viewModel.selectedSourceIds.contains(sourceId)
|
||||||
|
let sourceColor = Color.sourceColor(at: index)
|
||||||
Button {
|
Button {
|
||||||
viewModel.selectedSource = nil
|
viewModel.selectedSource = nil
|
||||||
viewModel.selectedCategory = nil
|
|
||||||
if isSelected {
|
if isSelected {
|
||||||
viewModel.selectedSourceIds.remove(sourceId)
|
viewModel.selectedSourceIds.remove(sourceId)
|
||||||
} else {
|
} else {
|
||||||
viewModel.selectedSourceIds.insert(sourceId)
|
viewModel.selectedSourceIds.insert(sourceId)
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text(source.name)
|
HStack(spacing: 4) {
|
||||||
.font(.caption.weight(.medium))
|
Circle()
|
||||||
.padding(.horizontal, 12)
|
.fill(sourceColor)
|
||||||
.padding(.vertical, 6)
|
.frame(width: 8, height: 8)
|
||||||
.background(
|
Text(source.name)
|
||||||
isSelected
|
}
|
||||||
? Color.appPrimary
|
.font(.caption.weight(.medium))
|
||||||
: Color.gray.opacity(0.1)
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(
|
||||||
|
isSelected
|
||||||
|
? sourceColor
|
||||||
|
: Color.gray.opacity(0.1)
|
||||||
)
|
)
|
||||||
.foregroundColor(
|
.foregroundColor(
|
||||||
isSelected
|
isSelected
|
||||||
@@ -975,15 +1028,36 @@ struct AllocationEvolutionChart: View {
|
|||||||
.frame(height: 260)
|
.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 {
|
private var chartView: some View {
|
||||||
Chart(identifiableData) { item in
|
Chart(identifiableData) { item in
|
||||||
AreaMark(
|
BarMark(
|
||||||
x: .value("Date", item.date),
|
x: .value("Date", item.date, unit: .month),
|
||||||
y: .value("Percentage", item.percentage)
|
y: .value("Percentage", item.percentage)
|
||||||
)
|
)
|
||||||
.foregroundStyle(by: .value("Category", item.category))
|
.foregroundStyle(by: .value("Category", item.category))
|
||||||
}
|
}
|
||||||
.chartForegroundStyleScale(domain: categoryNames, range: categoryColors)
|
.chartForegroundStyleScale(domain: stableCategoryNames, range: stableCategoryColors)
|
||||||
.chartXAxis {
|
.chartXAxis {
|
||||||
AxisMarks(values: .stride(by: .month, count: 2)) { _ in
|
AxisMarks(values: .stride(by: .month, count: 2)) { _ in
|
||||||
AxisValueLabel(format: .dateTime.month(.abbreviated).year(.twoDigits))
|
AxisValueLabel(format: .dateTime.month(.abbreviated).year(.twoDigits))
|
||||||
@@ -1002,19 +1076,6 @@ struct AllocationEvolutionChart: View {
|
|||||||
.frame(height: 260)
|
.frame(height: 260)
|
||||||
.drawingGroup()
|
.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 {
|
#Preview {
|
||||||
|
|||||||
@@ -416,9 +416,10 @@ struct MonthlyCheckInCard: View {
|
|||||||
@State private var showingStartOptions = false
|
@State private var showingStartOptions = false
|
||||||
@State private var startDestinationActive = false
|
@State private var startDestinationActive = false
|
||||||
@State private var shouldDuplicatePrevious = false
|
@State private var shouldDuplicatePrevious = false
|
||||||
|
@State private var cachedCompletionDate: Date?
|
||||||
|
|
||||||
private var effectiveLastCheckInDate: Date? {
|
private var effectiveLastCheckInDate: Date? {
|
||||||
MonthlyCheckInStore.latestCompletionDate() ?? lastUpdatedDate
|
cachedCompletionDate ?? lastUpdatedDate
|
||||||
}
|
}
|
||||||
|
|
||||||
private var checkInProgress: Double {
|
private var checkInProgress: Double {
|
||||||
@@ -432,7 +433,8 @@ struct MonthlyCheckInCard: View {
|
|||||||
|
|
||||||
private var nextCheckInDate: Date? {
|
private var nextCheckInDate: Date? {
|
||||||
guard let last = effectiveLastCheckInDate else { return nil }
|
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 {
|
private var isOverdue: Bool {
|
||||||
@@ -473,10 +475,10 @@ struct MonthlyCheckInCard: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
Text(currentMonthLabel)
|
Text("Monthly Check-in")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
|
|
||||||
Text("Keep a calm, deliberate rhythm. Update your sources and add a short note.")
|
Text(currentMonthLabel)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
@@ -583,6 +585,14 @@ struct MonthlyCheckInCard: View {
|
|||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
.presentationDetents([.height(280)])
|
.presentationDetents([.height(280)])
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
cachedCompletionDate = MonthlyCheckInStore.latestCompletionDate()
|
||||||
|
}
|
||||||
|
.onChange(of: startDestinationActive) { _, isActive in
|
||||||
|
if !isActive {
|
||||||
|
cachedCompletionDate = MonthlyCheckInStore.latestCompletionDate()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var defaultReminderTime: Date {
|
private var defaultReminderTime: Date {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ struct MonthlyCheckInShareCardView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
Text(summary.periodLabel)
|
Text(summary.formattedMonthYear)
|
||||||
.font(.caption.weight(.semibold))
|
.font(.caption.weight(.semibold))
|
||||||
.foregroundColor(.white.opacity(0.85))
|
.foregroundColor(.white.opacity(0.85))
|
||||||
|
|
||||||
@@ -17,7 +17,9 @@ struct MonthlyCheckInShareCardView: View {
|
|||||||
|
|
||||||
metricRow("Starting", summary.formattedStartingValue)
|
metricRow("Starting", summary.formattedStartingValue)
|
||||||
metricRow("Ending", summary.formattedEndingValue)
|
metricRow("Ending", summary.formattedEndingValue)
|
||||||
metricRow("Contributions", summary.formattedContributions)
|
if summary.contributions != 0 {
|
||||||
|
metricRow("Contributions", summary.formattedContributions)
|
||||||
|
}
|
||||||
metricRow("Net performance", "\(summary.formattedNetPerformance) (\(summary.formattedNetPerformancePercentage))")
|
metricRow("Net performance", "\(summary.formattedNetPerformance) (\(summary.formattedNetPerformancePercentage))")
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
|
|||||||
@@ -106,13 +106,31 @@ struct GoalEditorView: View {
|
|||||||
|
|
||||||
private func parseDecimal(_ value: String) -> Decimal? {
|
private func parseDecimal(_ value: String) -> Decimal? {
|
||||||
let locale = CurrencyFormatter.locale(for: currencyCode)
|
let locale = CurrencyFormatter.locale(for: currencyCode)
|
||||||
let cleaned = value
|
let stripped = value
|
||||||
.replacingOccurrences(of: currencySymbol, with: "")
|
.replacingOccurrences(of: currencySymbol, with: "")
|
||||||
.replacingOccurrences(of: locale.groupingSeparator ?? "", with: "")
|
|
||||||
.trimmingCharacters(in: .whitespaces)
|
.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()
|
let formatter = NumberFormatter()
|
||||||
formatter.numberStyle = .decimal
|
formatter.numberStyle = .decimal
|
||||||
formatter.locale = locale
|
formatter.locale = locale
|
||||||
@@ -123,7 +141,7 @@ struct GoalEditorView: View {
|
|||||||
|
|
||||||
// Fallback for mixed locale input
|
// Fallback for mixed locale input
|
||||||
let normalized = cleaned
|
let normalized = cleaned
|
||||||
.replacingOccurrences(of: locale.decimalSeparator ?? ",", with: ".")
|
.replacingOccurrences(of: decimalSep, with: ".")
|
||||||
.replacingOccurrences(of: ",", with: ".")
|
.replacingOccurrences(of: ",", with: ".")
|
||||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
return formatter.number(from: normalized)?.decimalValue
|
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
|
metricsSection
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transactions
|
|
||||||
transactionsSection
|
|
||||||
|
|
||||||
// Snapshots List
|
// Snapshots List
|
||||||
snapshotsSection
|
snapshotsSection
|
||||||
}
|
}
|
||||||
@@ -95,18 +92,6 @@ struct SourceDetailView: View {
|
|||||||
MissingSnapshotView()
|
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) {
|
.sheet(isPresented: $viewModel.showingEditSource) {
|
||||||
EditSourceView(source: viewModel.source)
|
EditSourceView(source: viewModel.source)
|
||||||
}
|
}
|
||||||
@@ -187,30 +172,16 @@ struct SourceDetailView: View {
|
|||||||
// MARK: - Quick Actions
|
// MARK: - Quick Actions
|
||||||
|
|
||||||
private var quickActions: some View {
|
private var quickActions: some View {
|
||||||
HStack(spacing: 12) {
|
Button {
|
||||||
Button {
|
viewModel.showingAddSnapshot = true
|
||||||
viewModel.showingAddSnapshot = true
|
} label: {
|
||||||
} label: {
|
Label("Add Snapshot", systemImage: "plus.circle.fill")
|
||||||
Label("Add Snapshot", systemImage: "plus.circle.fill")
|
.font(.subheadline.weight(.semibold))
|
||||||
.font(.subheadline.weight(.semibold))
|
.frame(maxWidth: .infinity)
|
||||||
.frame(maxWidth: .infinity)
|
.padding()
|
||||||
.padding()
|
.background(Color.appPrimary)
|
||||||
.background(Color.appPrimary)
|
.foregroundColor(.white)
|
||||||
.foregroundColor(.white)
|
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||||
.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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,56 +333,6 @@ struct SourceDetailView: View {
|
|||||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
.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
|
// MARK: - Snapshots Section
|
||||||
|
|
||||||
private var snapshotsSection: some View {
|
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 {
|
struct MetricChip: View {
|
||||||
let title: String
|
let title: String
|
||||||
let value: String
|
let value: String
|
||||||
|
|||||||
Reference in New Issue
Block a user