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? @State private var duplicateError: String? @StateObject private var categoryRepository = CategoryRepository() @StateObject private var sourceRepository = InvestmentSourceRepository() private var availableAccounts: [Account] { accountStore.accounts.filter { $0.safeId != nil } } var body: some View { NavigationStack { Form { if availableAccounts.count > 1 { Section { Picker("Account", selection: $selectedAccountId) { ForEach(availableAccounts, id: \.objectID) { account in Text(account.name).tag(Optional(account.safeId)) } } .onChange(of: selectedAccountId) { _, _ in validateSourceName(name) } } header: { Text("Account") } } // Source Info Section { TextField(String(localized: "add_source_name_placeholder"), text: $name) .textContentType(.organizationName) .onChange(of: name) { _, newValue in validateSourceName(newValue) } 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") } footer: { if let error = duplicateError { Text(error) .foregroundColor(.negativeRed) } else { Text(String(localized: "add_source_name_footer")) .foregroundColor(.secondary) } } // 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?.safeId ?? availableAccounts.first?.safeId } } } } // MARK: - Validation private var isValid: Bool { !name.trimmingCharacters(in: .whitespaces).isEmpty && selectedCategory != nil && duplicateError == nil } private func validateSourceName(_ newName: String) { let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { duplicateError = nil return } if sourceRepository.sourceExists(name: trimmed, in: selectedAccount) { duplicateError = "A source with this name already exists." } else { duplicateError = 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) // Check milestone after snapshot is saved let sourceRepo = InvestmentSourceRepository() let allSources = sourceRepo.fetchActiveSources() let total = allSources.reduce(Decimal.zero) { $0 + $1.latestValue } NotificationService.shared.checkAndScheduleMilestoneNotification(portfolioValue: total) // Log analytics FirebaseService.shared.logSourceAdded( categoryName: category.name, sourceCount: repository.sourceCount ) dismiss() } private func parseDecimal(_ string: String) -> Decimal? { CurrencyFormatter.parseUserInput(string, currencySymbol: currencySymbol) } 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 currencyCode: String { if let account = selectedAccount, let code = account.currencyCode { return code } return AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency } private var selectedAccount: Account? { guard let id = selectedAccountId else { return nil } return availableAccounts.first { $0.safeId == 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?.safeId) } var body: some View { NavigationStack { Form { if availableAccounts.count > 1 { Section { Picker("Account", selection: $selectedAccountId) { ForEach(availableAccounts, id: \.objectID) { account in Text(account.name).tag(Optional(account.safeId)) } } } 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 availableAccounts: [Account] { accountStore.accounts.filter { $0.safeId != nil } } 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 availableAccounts.first { $0.safeId == id } } } // MARK: - Add Snapshot View struct AddSnapshotView: View { @Environment(\.dismiss) private var dismiss @Environment(\.scenePhase) private var scenePhase let source: InvestmentSource let snapshot: Snapshot? @StateObject private var viewModel: SnapshotFormViewModel @State private var showingDuplicateAlert = false @State private var showingPropagationDialog = false @State private var pendingContribution: Decimal? 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 let clipboardValue = viewModel.clipboardValue { Button { viewModel.applyClipboardValue() } label: { Label("Paste \(clipboardValue) from clipboard", systemImage: "doc.on.clipboard") } .tint(.appPrimary) } 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 ) } } } 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 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) } } .onAppear { viewModel.checkClipboard() } .onChange(of: scenePhase) { _, phase in if phase == .active { viewModel.checkClipboard() } } .alert( String(localized: "snapshot_duplicate_title"), isPresented: $showingDuplicateAlert ) { Button(String(localized: "snapshot_duplicate_replace"), role: .destructive) { performSave() } Button(String(localized: "snapshot_duplicate_add")) { performSave() } Button("Cancel", role: .cancel) {} } message: { Text(String(localized: "snapshot_duplicate_message")) } .confirmationDialog( String(localized: "snapshot_contribution_propagate_title"), isPresented: $showingPropagationDialog, titleVisibility: .visible ) { Button(String(localized: "snapshot_contribution_propagate_forward")) { propagate(.forward) } Button(String(localized: "snapshot_contribution_propagate_backward")) { propagate(.backward) } Button(String(localized: "snapshot_contribution_propagate_all")) { propagate(.all) } Button(String(localized: "snapshot_contribution_propagate_this"), role: .cancel) { dismiss() } } message: { if let amount = pendingContribution { Text(String(format: String(localized: "snapshot_contribution_propagate_message"), amount.currencyString)) } } } } private func saveSnapshot() { guard snapshot == nil else { performSave() return } // Check for existing snapshot in the same month let calendar = Calendar.current let selectedMonth = calendar.dateComponents([.year, .month], from: viewModel.date) let repo = SnapshotRepository() let existing = repo.fetchSnapshots(for: source, limitedToMonths: nil) let hasDuplicate = existing.contains { snap in let snapMonth = calendar.dateComponents([.year, .month], from: snap.date) return snapMonth == selectedMonth } if hasDuplicate { showingDuplicateAlert = true } else { performSave() } } private func performSave() { guard let value = viewModel.value else { return } let repository = SnapshotRepository() let isEdit = snapshot != nil if let snapshot = snapshot { let contributionTextIsEmpty = viewModel.contributionString .trimmingCharacters(in: .whitespacesAndNewlines) .isEmpty repository.updateSnapshot( snapshot, date: viewModel.date, value: value, contribution: viewModel.contribution, notes: viewModel.notes.isEmpty ? nil : viewModel.notes, clearContribution: !viewModel.includeContribution || contributionTextIsEmpty, clearNotes: viewModel.notes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ) } else { // Capture pre-save goal state for achievement detection let goalRepo = GoalRepository() let sourceRepo = InvestmentSourceRepository() let prevAchievedIds = goalsAchievedBeforeSave(goalRepo: goalRepo, sourceRepo: sourceRepo) repository.createSnapshot( for: source, date: viewModel.date, value: value, contribution: viewModel.contribution, notes: viewModel.notes.isEmpty ? nil : viewModel.notes ) // Check if any goals became achieved checkGoalAchievements(goalRepo: goalRepo, sourceRepo: sourceRepo, prevAchievedIds: prevAchievedIds) } // Reschedule notification NotificationService.shared.scheduleReminder(for: source) // Log analytics FirebaseService.shared.logSnapshotAdded(sourceName: source.name, value: value) // When editing, offer to propagate a changed contribution to other snapshots. if isEdit, let contribution = viewModel.contribution, viewModel.contributionChanged { pendingContribution = contribution showingPropagationDialog = true return } dismiss() } private func propagate(_ direction: SnapshotRepository.ContributionPropagation) { if let snapshot = snapshot, let amount = pendingContribution { SnapshotRepository().propagateContribution(amount, from: snapshot, direction: direction) } dismiss() } private func goalsAchievedBeforeSave(goalRepo: GoalRepository, sourceRepo: InvestmentSourceRepository) -> Set { let goals = goalRepo.goals.filter { $0.isActive } var achieved = Set() for goal in goals { let currentValue: Decimal if let accountId = goal.account?.safeId { currentValue = sourceRepo.sources .filter { $0.account?.id == accountId } .reduce(Decimal.zero) { $0 + $1.latestValue } } else { currentValue = sourceRepo.sources.reduce(Decimal.zero) { $0 + $1.latestValue } } let target = goal.targetDecimal if target > 0 && currentValue / target >= 1 { achieved.insert(goal.id) } } return achieved } private func checkGoalAchievements(goalRepo: GoalRepository, sourceRepo: InvestmentSourceRepository, prevAchievedIds: Set) { // Reload sources to pick up new snapshot value let goals = goalRepo.goals.filter { $0.isActive } for goal in goals { guard !prevAchievedIds.contains(goal.id) else { continue } let currentValue: Decimal if let accountId = goal.account?.safeId { currentValue = sourceRepo.sources .filter { $0.account?.id == accountId } .reduce(Decimal.zero) { $0 + $1.latestValue } } else { currentValue = sourceRepo.sources.reduce(Decimal.zero) { $0 + $1.latestValue } } let target = goal.targetDecimal if target > 0 && currentValue / target >= 1 { NotificationService.shared.scheduleGoalAchievedNotification(goalName: goal.name) } } } } #Preview { AddSourceView() .environmentObject(IAPService()) .environmentObject(AccountStore(iapService: IAPService())) }