Files
InvestmentTrackerApp/PortfolioJournal/Views/Goals/GoalEditorView.swift
T
alexandrev-tibco d88cc59627 Goals por categoría: ámbito de categoría en objetivos (build 88) #46
Un Goal ya podía acotarse por cuenta; ahora también por categoría de
fuente, de modo que se puede fijar "100.000 € en Cash" y que el progreso
cuente solo las fuentes de esa categoría.

- CoreData: relación Goal.category ↔ Category.goals (opcional, Nullify)
- Goal.includes(_:)/relevantSources(from:)/scopeKey centralizan el filtro
  de ámbito (cuenta Y categoría) que antes estaba duplicado en
  GoalsViewModel, AddSourceView y NotificationService
- GoalEditorView: sección "Ámbito" con selector de categoría
- Chip de categoría en la lista de Goals y en la tarjeta del Dashboard
- ETA del Dashboard para goals con ámbito: usa la proyección del propio
  goal en vez de la evolución global de la cartera (que daría "objetivo
  alcanzado" en cuanto la cartera total superara el importe)
- GoalRepository e ImportService deduplican por (nombre, cuenta, categoría)
- Export/Import de goals incluye "category"
- Nuevas claves localizadas en los 7 idiomas

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YcDn5ccuRFV83q7xWWokBT
2026-09-12 09:32:02 +02:00

194 lines
7.0 KiB
Swift

import SwiftUI
struct GoalEditorView: View {
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var targetAmount = ""
@State private var targetDate = Date()
@State private var includeTargetDate = false
@State private var didLoadGoal = false
@State private var selectedCategoryId: UUID?
@StateObject private var categoryRepository = CategoryRepository()
let account: Account?
let goal: Goal?
private let goalRepository = GoalRepository()
init(account: Account?, goal: Goal? = nil) {
self.account = account
self.goal = goal
}
private var currencySymbol: String {
if let account = account, let code = account.currency, !code.isEmpty {
return CurrencyFormatter.symbol(for: code)
}
return AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currencySymbol
}
private var currencyCode: String {
if let account = account, let code = account.currency, !code.isEmpty {
return code
}
return AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
}
var body: some View {
NavigationStack {
Form {
Section {
TextField("Goal name", text: $name)
HStack {
Text(currencySymbol)
.foregroundColor(.secondary)
TextField("Target amount", text: $targetAmount)
.keyboardType(.decimalPad)
}
Toggle("Add target date", isOn: $includeTargetDate)
if includeTargetDate {
DatePicker("Target date", selection: $targetDate, displayedComponents: .date)
.datePickerStyle(.graphical)
}
} header: {
Text("Goal Details")
}
Section {
Picker(String(localized: "goal_scope_category"), selection: $selectedCategoryId) {
Text("goal_scope_all_categories").tag(UUID?.none)
ForEach(categoryRepository.categories) { category in
Label(category.name, systemImage: category.icon)
.tag(UUID?.some(category.id))
}
}
} header: {
Text("goal_scope_section")
} footer: {
Text(selectedCategoryId == nil
? String(localized: "goal_scope_footer_all")
: String(localized: "goal_scope_footer_category"))
}
}
.navigationTitle(goal == nil ? "New Goal" : "Edit Goal")
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") { saveGoal() }
.disabled(!isValid)
}
}
}
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
.onAppear {
guard let goal, !didLoadGoal else { return }
name = goal.name ?? ""
if let amount = goal.targetAmount?.decimalValue {
targetAmount = formatDecimalForInput(amount)
}
if let target = goal.targetDate {
includeTargetDate = true
targetDate = target
}
selectedCategoryId = goal.category?.safeId
didLoadGoal = true
}
}
private var isValid: Bool {
!name.trimmingCharacters(in: .whitespaces).isEmpty && parseDecimal(targetAmount) != nil
}
private var selectedCategory: Category? {
guard let selectedCategoryId else { return nil }
return categoryRepository.categories.first { $0.safeId == selectedCategoryId }
}
private func saveGoal() {
guard let value = parseDecimal(targetAmount) else { return }
let category = selectedCategory
if let goal {
goalRepository.updateGoal(
goal,
name: name,
targetAmount: value,
targetDate: includeTargetDate ? targetDate : nil,
clearTargetDate: !includeTargetDate,
category: category,
clearCategory: category == nil
)
} else {
goalRepository.createGoal(
name: name,
targetAmount: value,
targetDate: includeTargetDate ? targetDate : nil,
account: account,
category: category
)
}
dismiss()
}
private func parseDecimal(_ value: String) -> Decimal? {
let locale = CurrencyFormatter.locale(for: currencyCode)
let stripped = value
.replacingOccurrences(of: currencySymbol, with: "")
.trimmingCharacters(in: .whitespaces)
guard !stripped.isEmpty else { return nil }
let decimalSep = locale.decimalSeparator ?? "."
let groupingSep = locale.groupingSeparator ?? ""
// Detect alternate decimal BEFORE removing separators
let usesAlternateDecimal =
(decimalSep == "," && stripped.contains(".") && !stripped.contains(",")) ||
(decimalSep == "." && stripped.contains(",") && !stripped.contains("."))
if usesAlternateDecimal {
let normalized = stripped
.replacingOccurrences(of: groupingSep, with: "")
.replacingOccurrences(of: ",", with: ".")
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter.number(from: normalized)?.decimalValue
}
let cleaned = stripped.replacingOccurrences(of: groupingSep, with: "")
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.locale = locale
if let result = formatter.number(from: cleaned)?.decimalValue {
return result
}
// Fallback for mixed locale input
let normalized = cleaned
.replacingOccurrences(of: decimalSep, with: ".")
.replacingOccurrences(of: ",", with: ".")
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter.number(from: normalized)?.decimalValue
}
private func formatDecimalForInput(_ decimal: Decimal) -> String {
let locale = CurrencyFormatter.locale(for: currencyCode)
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.locale = locale
formatter.minimumFractionDigits = 0
formatter.maximumFractionDigits = 2
formatter.groupingSeparator = ""
return formatter.string(from: decimal as NSDecimalNumber) ?? ""
}
}
#Preview {
GoalEditorView(account: nil)
}