initial version
This commit is contained in:
@@ -0,0 +1,507 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AddSourceView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject var iapService: IAPService
|
||||
@EnvironmentObject var accountStore: AccountStore
|
||||
|
||||
@State private var name = ""
|
||||
@State private var selectedCategory: Category?
|
||||
@State private var initialValue = ""
|
||||
@State private var showingCategoryPicker = false
|
||||
@State private var selectedAccountId: UUID?
|
||||
|
||||
@StateObject private var categoryRepository = CategoryRepository()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
if accountStore.accounts.count > 1 {
|
||||
Section {
|
||||
Picker("Account", selection: $selectedAccountId) {
|
||||
ForEach(accountStore.accounts) { account in
|
||||
Text(account.name).tag(Optional(account.id))
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Account")
|
||||
}
|
||||
}
|
||||
|
||||
// Source Info
|
||||
Section {
|
||||
TextField("Source Name", text: $name)
|
||||
.textContentType(.organizationName)
|
||||
|
||||
Button {
|
||||
showingCategoryPicker = true
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Category")
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let category = selectedCategory {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: category.icon)
|
||||
.foregroundColor(category.color)
|
||||
Text(category.name)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} else {
|
||||
Text("Select")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Source Information")
|
||||
}
|
||||
|
||||
// Initial Value (Optional)
|
||||
Section {
|
||||
HStack {
|
||||
Text(currencySymbol)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
TextField("0.00", text: $initialValue)
|
||||
.keyboardType(.decimalPad)
|
||||
}
|
||||
} header: {
|
||||
Text("Initial Value (Optional)")
|
||||
} footer: {
|
||||
Text("You can add snapshots later if you prefer.")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Add Source")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Add") {
|
||||
saveSource()
|
||||
}
|
||||
.disabled(!isValid)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingCategoryPicker) {
|
||||
CategoryPickerView(
|
||||
selectedCategory: $selectedCategory,
|
||||
categories: categoryRepository.categories
|
||||
)
|
||||
}
|
||||
.onAppear {
|
||||
categoryRepository.createDefaultCategoriesIfNeeded()
|
||||
if selectedCategory == nil {
|
||||
selectedCategory = categoryRepository.categories.first
|
||||
}
|
||||
if selectedAccountId == nil {
|
||||
selectedAccountId = accountStore.selectedAccount?.id ?? accountStore.accounts.first?.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Validation
|
||||
|
||||
private var isValid: Bool {
|
||||
!name.trimmingCharacters(in: .whitespaces).isEmpty && selectedCategory != nil
|
||||
}
|
||||
|
||||
// MARK: - Save
|
||||
|
||||
private func saveSource() {
|
||||
guard let category = selectedCategory else { return }
|
||||
|
||||
let repository = InvestmentSourceRepository()
|
||||
let source = repository.createSource(
|
||||
name: name.trimmingCharacters(in: .whitespaces),
|
||||
category: category,
|
||||
account: selectedAccount
|
||||
)
|
||||
|
||||
// Add initial snapshot if provided
|
||||
if let value = parseDecimal(initialValue), value > 0 {
|
||||
let snapshotRepository = SnapshotRepository()
|
||||
snapshotRepository.createSnapshot(
|
||||
for: source,
|
||||
date: Date(),
|
||||
value: value
|
||||
)
|
||||
}
|
||||
|
||||
// Schedule notification
|
||||
NotificationService.shared.scheduleReminder(for: source)
|
||||
|
||||
// Log analytics
|
||||
FirebaseService.shared.logSourceAdded(
|
||||
categoryName: category.name,
|
||||
sourceCount: repository.sourceCount
|
||||
)
|
||||
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func parseDecimal(_ string: String) -> Decimal? {
|
||||
let cleaned = string
|
||||
.replacingOccurrences(of: currencySymbol, with: "")
|
||||
.replacingOccurrences(of: ",", with: ".")
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
guard !cleaned.isEmpty else { return nil }
|
||||
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
formatter.locale = Locale(identifier: "en_US")
|
||||
|
||||
return formatter.number(from: cleaned)?.decimalValue
|
||||
}
|
||||
|
||||
private var currencySymbol: String {
|
||||
if let account = selectedAccount, let code = account.currencyCode {
|
||||
return CurrencyFormatter.symbol(for: code)
|
||||
}
|
||||
return AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currencySymbol
|
||||
}
|
||||
|
||||
private var selectedAccount: Account? {
|
||||
guard let id = selectedAccountId else { return nil }
|
||||
return accountStore.accounts.first { $0.id == id }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Category Picker View
|
||||
|
||||
struct CategoryPickerView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Binding var selectedCategory: Category?
|
||||
let categories: [Category]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List(categories) { category in
|
||||
Button {
|
||||
selectedCategory = category
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(category.color.opacity(0.2))
|
||||
.frame(width: 40, height: 40)
|
||||
|
||||
Image(systemName: category.icon)
|
||||
.foregroundColor(category.color)
|
||||
}
|
||||
|
||||
Text(category.name)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if selectedCategory?.id == category.id {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(.appPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Select Category")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Edit Source View
|
||||
|
||||
struct EditSourceView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject var accountStore: AccountStore
|
||||
let source: InvestmentSource
|
||||
|
||||
@State private var name: String
|
||||
@State private var selectedCategory: Category?
|
||||
@State private var showingCategoryPicker = false
|
||||
@State private var selectedAccountId: UUID?
|
||||
|
||||
@StateObject private var categoryRepository = CategoryRepository()
|
||||
|
||||
init(source: InvestmentSource) {
|
||||
self.source = source
|
||||
_name = State(initialValue: source.name)
|
||||
_selectedCategory = State(initialValue: source.category)
|
||||
_selectedAccountId = State(initialValue: source.account?.id)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
if accountStore.accounts.count > 1 {
|
||||
Section {
|
||||
Picker("Account", selection: $selectedAccountId) {
|
||||
ForEach(accountStore.accounts) { account in
|
||||
Text(account.name).tag(Optional(account.id))
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Account")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("Source Name", text: $name)
|
||||
|
||||
Button {
|
||||
showingCategoryPicker = true
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Category")
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let category = selectedCategory {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: category.icon)
|
||||
.foregroundColor(category.color)
|
||||
Text(category.name)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Edit Source")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
saveChanges()
|
||||
}
|
||||
.disabled(!isValid)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingCategoryPicker) {
|
||||
CategoryPickerView(
|
||||
selectedCategory: $selectedCategory,
|
||||
categories: categoryRepository.categories
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var isValid: Bool {
|
||||
!name.trimmingCharacters(in: .whitespaces).isEmpty && selectedCategory != nil
|
||||
}
|
||||
|
||||
private func saveChanges() {
|
||||
guard let category = selectedCategory else { return }
|
||||
|
||||
let repository = InvestmentSourceRepository()
|
||||
repository.updateSource(
|
||||
source,
|
||||
name: name.trimmingCharacters(in: .whitespaces),
|
||||
category: category,
|
||||
account: selectedAccount
|
||||
)
|
||||
|
||||
// Reschedule notification
|
||||
NotificationService.shared.scheduleReminder(for: source)
|
||||
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private var selectedAccount: Account? {
|
||||
guard let id = selectedAccountId else { return nil }
|
||||
return accountStore.accounts.first { $0.id == id }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Add Snapshot View
|
||||
|
||||
struct AddSnapshotView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let source: InvestmentSource
|
||||
let snapshot: Snapshot?
|
||||
|
||||
@StateObject private var viewModel: SnapshotFormViewModel
|
||||
|
||||
init(source: InvestmentSource, snapshot: Snapshot? = nil) {
|
||||
self.source = source
|
||||
self.snapshot = snapshot
|
||||
if let snapshot = snapshot {
|
||||
_viewModel = StateObject(wrappedValue: SnapshotFormViewModel(source: source, mode: .edit(snapshot)))
|
||||
} else {
|
||||
_viewModel = StateObject(wrappedValue: SnapshotFormViewModel(source: source))
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
if snapshot == nil && viewModel.previousValue != nil {
|
||||
Button {
|
||||
viewModel.prefillFromPreviousSnapshot()
|
||||
} label: {
|
||||
Label("Duplicate Previous Snapshot", systemImage: "doc.on.doc")
|
||||
}
|
||||
}
|
||||
|
||||
DatePicker(
|
||||
"Date",
|
||||
selection: $viewModel.date,
|
||||
in: ...Date(),
|
||||
displayedComponents: .date
|
||||
)
|
||||
|
||||
HStack {
|
||||
Text(viewModel.currencySymbol)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
TextField("Value", text: $viewModel.valueString)
|
||||
.keyboardType(.decimalPad)
|
||||
}
|
||||
|
||||
if viewModel.previousValue != nil {
|
||||
Text(viewModel.previousValueString)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} header: {
|
||||
Text("Snapshot Details")
|
||||
}
|
||||
|
||||
// Change preview
|
||||
if let change = viewModel.formattedChange,
|
||||
let percentage = viewModel.formattedChangePercentage {
|
||||
Section {
|
||||
HStack {
|
||||
Text("Change from previous")
|
||||
Spacer()
|
||||
Text("\(change) (\(percentage))")
|
||||
.foregroundColor(
|
||||
(viewModel.changeFromPrevious ?? 0) >= 0
|
||||
? .positiveGreen
|
||||
: .negativeRed
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.inputMode == .detailed {
|
||||
Section {
|
||||
Toggle("Include Contribution", isOn: $viewModel.includeContribution)
|
||||
|
||||
if viewModel.includeContribution {
|
||||
HStack {
|
||||
Text(viewModel.currencySymbol)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
TextField("New capital added", text: $viewModel.contributionString)
|
||||
.keyboardType(.decimalPad)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Contribution (Optional)")
|
||||
} footer: {
|
||||
Text("Track new capital you've added to separate it from investment growth.")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("Notes", text: $viewModel.notes, axis: .vertical)
|
||||
.lineLimit(3...6)
|
||||
} header: {
|
||||
Text("Notes (Optional)")
|
||||
}
|
||||
}
|
||||
.navigationTitle(viewModel.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(viewModel.buttonTitle) {
|
||||
saveSnapshot()
|
||||
}
|
||||
.disabled(!viewModel.isValid)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveSnapshot() {
|
||||
guard let value = viewModel.value else { return }
|
||||
|
||||
let repository = SnapshotRepository()
|
||||
if let snapshot = snapshot {
|
||||
repository.updateSnapshot(
|
||||
snapshot,
|
||||
date: viewModel.date,
|
||||
value: value,
|
||||
contribution: viewModel.contribution,
|
||||
notes: viewModel.notes.isEmpty ? nil : viewModel.notes,
|
||||
clearContribution: !viewModel.includeContribution,
|
||||
clearNotes: viewModel.notes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
)
|
||||
} else {
|
||||
repository.createSnapshot(
|
||||
for: source,
|
||||
date: viewModel.date,
|
||||
value: value,
|
||||
contribution: viewModel.contribution,
|
||||
notes: viewModel.notes.isEmpty ? nil : viewModel.notes
|
||||
)
|
||||
}
|
||||
|
||||
// Reschedule notification
|
||||
NotificationService.shared.scheduleReminder(for: source)
|
||||
|
||||
// Log analytics
|
||||
FirebaseService.shared.logSnapshotAdded(sourceName: source.name, value: value)
|
||||
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AddSourceView()
|
||||
.environmentObject(IAPService())
|
||||
.environmentObject(AccountStore(iapService: IAPService()))
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AddTransactionView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let onSave: (TransactionType, Date, Decimal?, Decimal?, Decimal?, String?) -> Void
|
||||
|
||||
@State private var type: TransactionType = .buy
|
||||
@State private var date = Date()
|
||||
@State private var shares = ""
|
||||
@State private var price = ""
|
||||
@State private var amount = ""
|
||||
@State private var notes = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
Picker("Type", selection: $type) {
|
||||
ForEach(TransactionType.allCases) { transactionType in
|
||||
Text(transactionType.displayName).tag(transactionType)
|
||||
}
|
||||
}
|
||||
|
||||
DatePicker("Date", selection: $date, displayedComponents: .date)
|
||||
} header: {
|
||||
Text("Transaction")
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("Shares", text: $shares)
|
||||
.keyboardType(.decimalPad)
|
||||
|
||||
TextField("Price per share", text: $price)
|
||||
.keyboardType(.decimalPad)
|
||||
|
||||
TextField("Total amount", text: $amount)
|
||||
.keyboardType(.decimalPad)
|
||||
} header: {
|
||||
Text("Amounts")
|
||||
} footer: {
|
||||
Text("Enter shares and price or just a total amount.")
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("Notes", text: $notes, axis: .vertical)
|
||||
.lineLimit(2...4)
|
||||
} header: {
|
||||
Text("Notes (Optional)")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Add Transaction")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") { save() }
|
||||
.disabled(!isValid)
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
}
|
||||
|
||||
private var isValid: Bool {
|
||||
parseDecimal(shares) != nil || parseDecimal(amount) != nil
|
||||
}
|
||||
|
||||
private func save() {
|
||||
onSave(
|
||||
type,
|
||||
date,
|
||||
parseDecimal(shares),
|
||||
parseDecimal(price),
|
||||
parseDecimal(amount),
|
||||
notes.isEmpty ? nil : notes
|
||||
)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func parseDecimal(_ value: String) -> Decimal? {
|
||||
let cleaned = value
|
||||
.replacingOccurrences(of: ",", with: ".")
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
guard !cleaned.isEmpty else { return nil }
|
||||
return Decimal(string: cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AddTransactionView { _, _, _, _, _, _ in }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,592 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
struct SourceDetailView: View {
|
||||
@StateObject private var viewModel: SourceDetailViewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
|
||||
|
||||
@State private var showingDeleteConfirmation = false
|
||||
@State private var editingSnapshot: Snapshot?
|
||||
|
||||
init(source: InvestmentSource, iapService: IAPService) {
|
||||
_viewModel = StateObject(wrappedValue: SourceDetailViewModel(
|
||||
source: source,
|
||||
iapService: iapService
|
||||
))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AppBackground()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
// Header Card
|
||||
headerCard
|
||||
|
||||
// Quick Actions
|
||||
quickActions
|
||||
|
||||
// Chart
|
||||
if !viewModel.chartData.isEmpty {
|
||||
chartSection
|
||||
}
|
||||
|
||||
// Metrics
|
||||
if calmModeEnabled {
|
||||
simpleMetricsSection
|
||||
} else {
|
||||
metricsSection
|
||||
}
|
||||
|
||||
// Transactions
|
||||
transactionsSection
|
||||
|
||||
// Snapshots List
|
||||
snapshotsSection
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.navigationTitle(viewModel.source.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Menu {
|
||||
Button {
|
||||
viewModel.showingEditSource = true
|
||||
} label: {
|
||||
Label("Edit Source", systemImage: "pencil")
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
showingDeleteConfirmation = true
|
||||
} label: {
|
||||
Label("Delete Source", systemImage: "trash")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showingAddSnapshot) {
|
||||
AddSnapshotView(source: viewModel.source)
|
||||
}
|
||||
.sheet(item: $editingSnapshot) { snapshot in
|
||||
AddSnapshotView(source: viewModel.source, snapshot: snapshot)
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showingAddTransaction) {
|
||||
AddTransactionView { type, date, shares, price, amount, notes in
|
||||
viewModel.addTransaction(
|
||||
type: type,
|
||||
date: date,
|
||||
shares: shares,
|
||||
price: price,
|
||||
amount: amount,
|
||||
notes: notes
|
||||
)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showingEditSource) {
|
||||
EditSourceView(source: viewModel.source)
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showingPaywall) {
|
||||
PaywallView()
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete Source",
|
||||
isPresented: $showingDeleteConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
// Delete and dismiss
|
||||
let repository = InvestmentSourceRepository()
|
||||
repository.deleteSource(viewModel.source)
|
||||
dismiss()
|
||||
}
|
||||
} message: {
|
||||
Text("This will permanently delete \(viewModel.source.name) and all its snapshots.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header Card
|
||||
|
||||
private var headerCard: some View {
|
||||
VStack(spacing: 12) {
|
||||
// Category badge
|
||||
HStack {
|
||||
Image(systemName: viewModel.source.category?.icon ?? "questionmark")
|
||||
Text(viewModel.categoryName)
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(Color(hex: viewModel.categoryColor) ?? .gray)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background((Color(hex: viewModel.categoryColor) ?? .gray).opacity(0.1))
|
||||
.cornerRadius(20)
|
||||
|
||||
// Current value
|
||||
Text(viewModel.formattedCurrentValue)
|
||||
.font(.system(size: 36, weight: .bold, design: .rounded))
|
||||
|
||||
// Return
|
||||
if calmModeEnabled {
|
||||
VStack(spacing: 2) {
|
||||
Text("All-time return")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text("\(viewModel.formattedTotalReturn) (\(viewModel.formattedPercentageReturn))")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: viewModel.isPositiveReturn ? "arrow.up.right" : "arrow.down.right")
|
||||
Text(viewModel.formattedTotalReturn)
|
||||
Text("(\(viewModel.formattedPercentageReturn))")
|
||||
}
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundColor(viewModel.isPositiveReturn ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
|
||||
// Last updated
|
||||
Text("Last updated: \(viewModel.lastUpdated)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
// MARK: - Quick Actions
|
||||
|
||||
private var quickActions: some View {
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
viewModel.showingAddSnapshot = true
|
||||
} label: {
|
||||
Label("Add Snapshot", systemImage: "plus.circle.fill")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.appPrimary)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
|
||||
Button {
|
||||
viewModel.showingAddTransaction = true
|
||||
} label: {
|
||||
Label("Add Transaction", systemImage: "arrow.left.arrow.right.circle")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.appSecondary.opacity(0.15))
|
||||
.foregroundColor(.appSecondary)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chart Section
|
||||
|
||||
private var chartSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Value History")
|
||||
.font(.headline)
|
||||
|
||||
Chart {
|
||||
ForEach(viewModel.chartData, id: \.date) { item in
|
||||
LineMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appPrimary)
|
||||
.interpolationMethod(.catmullRom)
|
||||
|
||||
AreaMark(
|
||||
x: .value("Date", item.date),
|
||||
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
||||
)
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary.opacity(0.3), Color.appPrimary.opacity(0.0)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.interpolationMethod(.catmullRom)
|
||||
}
|
||||
|
||||
if viewModel.canViewPredictions, !viewModel.predictions.isEmpty {
|
||||
ForEach(viewModel.predictions, id: \.id) { prediction in
|
||||
AreaMark(
|
||||
x: .value("Prediction Date", prediction.date),
|
||||
yStart: .value("Lower", NSDecimalNumber(decimal: prediction.confidenceInterval.lower).doubleValue),
|
||||
yEnd: .value("Upper", NSDecimalNumber(decimal: prediction.confidenceInterval.upper).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appSecondary.opacity(0.18))
|
||||
|
||||
LineMark(
|
||||
x: .value("Prediction Date", prediction.date),
|
||||
y: .value("Predicted Value", NSDecimalNumber(decimal: prediction.predictedValue).doubleValue)
|
||||
)
|
||||
.foregroundStyle(Color.appSecondary)
|
||||
.lineStyle(StrokeStyle(lineWidth: 2, dash: [6, 4]))
|
||||
}
|
||||
|
||||
if let firstPrediction = viewModel.predictions.first {
|
||||
PointMark(
|
||||
x: .value("Forecast Start", firstPrediction.date),
|
||||
y: .value("Forecast Value", NSDecimalNumber(decimal: firstPrediction.predictedValue).doubleValue)
|
||||
)
|
||||
.symbolSize(30)
|
||||
.foregroundStyle(Color.appSecondary)
|
||||
.annotation(position: .topTrailing) {
|
||||
Text("Forecast")
|
||||
.font(.caption.weight(.semibold))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.appSecondary.opacity(0.15))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .stride(by: .month, count: 2)) { value in
|
||||
AxisValueLabel(format: .dateTime.month(.abbreviated))
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) { value in
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(Decimal(doubleValue).shortCurrencyString)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 200)
|
||||
|
||||
if !viewModel.canViewPredictions && viewModel.hasEnoughDataForPredictions {
|
||||
Button {
|
||||
viewModel.showingPaywall = true
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "lock.fill")
|
||||
Text("Unlock predictions on the chart")
|
||||
}
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.appPrimary)
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 10)
|
||||
.background(Color.appPrimary.opacity(0.1))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
// MARK: - Metrics Section
|
||||
|
||||
private var metricsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Performance Metrics")
|
||||
.font(.headline)
|
||||
|
||||
LazyVGrid(columns: [
|
||||
GridItem(.flexible()),
|
||||
GridItem(.flexible())
|
||||
], spacing: 16) {
|
||||
MetricCard(title: "CAGR", value: viewModel.metrics.formattedCAGR)
|
||||
MetricCard(title: "Volatility", value: viewModel.metrics.formattedVolatility)
|
||||
MetricCard(title: "Max Drawdown", value: viewModel.metrics.formattedMaxDrawdown)
|
||||
MetricCard(title: "Sharpe Ratio", value: viewModel.metrics.formattedSharpeRatio)
|
||||
MetricCard(title: "Win Rate", value: viewModel.metrics.formattedWinRate)
|
||||
MetricCard(title: "Avg Monthly", value: viewModel.metrics.formattedAverageMonthlyReturn)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private var simpleMetricsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Performance Summary")
|
||||
.font(.headline)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
MetricChip(title: "CAGR", value: viewModel.metrics.formattedCAGR)
|
||||
MetricChip(title: "Avg Monthly", value: viewModel.metrics.formattedAverageMonthlyReturn)
|
||||
MetricChip(title: "Contributions", value: viewModel.source.totalContributions.compactCurrencyString)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
// MARK: - Transactions Section
|
||||
|
||||
private var transactionsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Transactions")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("\(viewModel.transactions.count)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if viewModel.transactions.isEmpty {
|
||||
Text("Add buys, sells, dividends, and fees to track cashflows.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
HStack(spacing: 12) {
|
||||
MetricChip(title: "Invested", value: viewModel.source.totalInvested.compactCurrencyString)
|
||||
MetricChip(title: "Dividends", value: viewModel.source.totalDividends.compactCurrencyString)
|
||||
MetricChip(title: "Fees", value: viewModel.source.totalFees.compactCurrencyString)
|
||||
}
|
||||
|
||||
ForEach(viewModel.transactions.prefix(5)) { transaction in
|
||||
TransactionRow(transaction: transaction)
|
||||
.contextMenu {
|
||||
Button(role: .destructive) {
|
||||
viewModel.deleteTransaction(transaction)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.transactions.count > 5 {
|
||||
Text("+ \(viewModel.transactions.count - 5) more")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
// MARK: - Snapshots Section
|
||||
|
||||
private var snapshotsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Snapshots")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("\(viewModel.snapshotCount)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if viewModel.isHistoryLimited {
|
||||
HStack {
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundColor(.appWarning)
|
||||
Text("\(viewModel.hiddenSnapshotCount) older snapshots hidden. Upgrade for full history.")
|
||||
.font(.caption)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Upgrade") {
|
||||
viewModel.showingPaywall = true
|
||||
}
|
||||
.font(.caption.weight(.semibold))
|
||||
}
|
||||
.padding(8)
|
||||
.background(Color.appWarning.opacity(0.1))
|
||||
.cornerRadius(AppConstants.UI.smallCornerRadius)
|
||||
}
|
||||
|
||||
ForEach(viewModel.visibleSnapshots) { snapshot in
|
||||
SnapshotRowView(snapshot: snapshot, onEdit: {
|
||||
editingSnapshot = snapshot
|
||||
})
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
editingSnapshot = snapshot
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
editingSnapshot = snapshot
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button {
|
||||
editingSnapshot = snapshot
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
viewModel.deleteSnapshot(snapshot)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
|
||||
if snapshot.id != viewModel.visibleSnapshots.last?.id {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.snapshots.count > 10 {
|
||||
Text("+ \(viewModel.snapshots.count - 10) more snapshots")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Metric Card
|
||||
|
||||
struct MetricCard: View {
|
||||
let title: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Snapshot Row View
|
||||
|
||||
struct SnapshotRowView: View {
|
||||
let snapshot: Snapshot
|
||||
let onEdit: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(snapshot.date.friendlyDescription)
|
||||
.font(.subheadline)
|
||||
|
||||
if let notes = snapshot.notes, !notes.isEmpty {
|
||||
Text(notes)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(snapshot.formattedValue)
|
||||
.font(.subheadline.weight(.medium))
|
||||
|
||||
if snapshot.contribution != nil && snapshot.decimalContribution > 0 {
|
||||
Text("+ \(snapshot.decimalContribution.currencyString)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.appPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
if let onEdit = onEdit {
|
||||
Button(action: onEdit) {
|
||||
Image(systemName: "pencil")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(6)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Transaction Row View
|
||||
|
||||
struct TransactionRow: View {
|
||||
let transaction: Transaction
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(transaction.transactionType.displayName)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
Text(transaction.date.friendlyDescription)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(transaction.decimalAmount.compactCurrencyString)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(transaction.transactionType == .fee ? .negativeRed : .primary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
struct MetricChip: View {
|
||||
let title: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(value)
|
||||
.font(.caption.weight(.semibold))
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 10)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
Text("Source Detail Preview")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SourceListView: View {
|
||||
@EnvironmentObject var iapService: IAPService
|
||||
@EnvironmentObject var accountStore: AccountStore
|
||||
@StateObject private var viewModel: SourceListViewModel
|
||||
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
|
||||
|
||||
init(iapService: IAPService) {
|
||||
_viewModel = StateObject(wrappedValue: SourceListViewModel(iapService: iapService))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
AppBackground()
|
||||
|
||||
Group {
|
||||
if viewModel.isEmpty {
|
||||
emptyStateView
|
||||
} else {
|
||||
sourcesList
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Sources")
|
||||
.searchable(text: $viewModel.searchText, prompt: "Search sources")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
viewModel.addSourceTapped()
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
categoryFilterMenu
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
accountFilterMenu
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showingAddSource) {
|
||||
AddSourceView()
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showingPaywall) {
|
||||
PaywallView()
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.selectedAccount = accountStore.selectedAccount
|
||||
viewModel.showAllAccounts = accountStore.showAllAccounts
|
||||
}
|
||||
.onReceive(accountStore.$selectedAccount) { account in
|
||||
viewModel.selectedAccount = account
|
||||
}
|
||||
.onReceive(accountStore.$showAllAccounts) { showAll in
|
||||
viewModel.showAllAccounts = showAll
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sources List
|
||||
|
||||
private var sourcesList: some View {
|
||||
List {
|
||||
// Summary Header
|
||||
if !viewModel.isFiltered {
|
||||
Section {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Total Value")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(viewModel.formattedTotalValue)
|
||||
.font(.title2.weight(.bold))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing) {
|
||||
Text("Sources")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text("\(viewModel.sources.count)")
|
||||
.font(.title2.weight(.bold))
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// Source limit warning
|
||||
if viewModel.sourceLimitReached {
|
||||
Section {
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(.appWarning)
|
||||
|
||||
Text("Source limit reached. Upgrade to Premium for unlimited sources.")
|
||||
.font(.subheadline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Upgrade") {
|
||||
viewModel.showingPaywall = true
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.appPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sources
|
||||
Section {
|
||||
ForEach(viewModel.sources) { source in
|
||||
NavigationLink {
|
||||
SourceDetailView(source: source, iapService: iapService)
|
||||
} label: {
|
||||
SourceRowView(source: source, calmModeEnabled: calmModeEnabled)
|
||||
}
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
viewModel.deleteSource(at: indexSet)
|
||||
}
|
||||
} header: {
|
||||
if viewModel.isFiltered {
|
||||
HStack {
|
||||
Text("\(viewModel.sources.count) results")
|
||||
Spacer()
|
||||
Button("Clear Filters") {
|
||||
viewModel.clearFilters()
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.scrollContentBackground(.hidden)
|
||||
.refreshable {
|
||||
viewModel.loadData()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty State
|
||||
|
||||
private var emptyStateView: some View {
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: "tray")
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("No Investment Sources")
|
||||
.font(.custom("Avenir Next", size: 24).weight(.semibold))
|
||||
|
||||
Text("Add your first investment source to start tracking your portfolio.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
|
||||
Button {
|
||||
viewModel.showingAddSource = true
|
||||
} label: {
|
||||
Label("Add Source", systemImage: "plus")
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
.padding()
|
||||
.frame(maxWidth: 200)
|
||||
.background(Color.appPrimary)
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Category Filter Menu
|
||||
|
||||
private var categoryFilterMenu: some View {
|
||||
Menu {
|
||||
Button {
|
||||
viewModel.selectCategory(nil)
|
||||
} label: {
|
||||
HStack {
|
||||
Text("All Categories")
|
||||
if viewModel.selectedCategory == nil {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
ForEach(viewModel.categories) { category in
|
||||
Button {
|
||||
viewModel.selectCategory(category)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: category.icon)
|
||||
Text(category.name)
|
||||
if viewModel.selectedCategory?.id == category.id {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "line.3.horizontal.decrease.circle")
|
||||
if viewModel.selectedCategory != nil {
|
||||
Circle()
|
||||
.fill(Color.appPrimary)
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Account Filter Menu
|
||||
|
||||
private var accountFilterMenu: some View {
|
||||
Menu {
|
||||
Button {
|
||||
accountStore.selectAllAccounts()
|
||||
} label: {
|
||||
HStack {
|
||||
Text("All Accounts")
|
||||
if accountStore.showAllAccounts {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
ForEach(accountStore.accounts) { account in
|
||||
Button {
|
||||
accountStore.selectAccount(account)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(account.name)
|
||||
if accountStore.selectedAccount?.id == account.id && !accountStore.showAllAccounts {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "person.2")
|
||||
if !accountStore.showAllAccounts {
|
||||
Circle()
|
||||
.fill(Color.appPrimary)
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Source Row View
|
||||
|
||||
struct SourceRowView: View {
|
||||
@ObservedObject var source: InvestmentSource
|
||||
let calmModeEnabled: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
// Category indicator
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill((source.category?.color ?? .gray).opacity(0.2))
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
Image(systemName: source.category?.icon ?? "questionmark")
|
||||
.font(.system(size: 18))
|
||||
.foregroundColor(source.category?.color ?? .gray)
|
||||
}
|
||||
|
||||
// Source info
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(source.name)
|
||||
.font(.headline)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text(source.category?.name ?? "Uncategorized")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if source.needsUpdate {
|
||||
Label("Needs update", systemImage: "bell.badge.fill")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.appWarning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Value
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Text(source.latestValue.compactCurrencyString)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
if calmModeEnabled {
|
||||
Text(String(format: "%.1f%%", NSDecimalNumber(decimal: source.totalReturn).doubleValue))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: source.totalReturn >= 0 ? "arrow.up.right" : "arrow.down.right")
|
||||
.font(.caption2)
|
||||
Text(String(format: "%.1f%%", NSDecimalNumber(decimal: source.totalReturn).doubleValue))
|
||||
.font(.caption)
|
||||
}
|
||||
.foregroundColor(source.totalReturn >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.opacity(source.isActive ? 1 : 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SourceListView(iapService: IAPService())
|
||||
.environmentObject(IAPService())
|
||||
.environmentObject(AccountStore(iapService: IAPService()))
|
||||
}
|
||||
Reference in New Issue
Block a user