508 lines
17 KiB
Swift
508 lines
17 KiB
Swift
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()))
|
|
}
|