d88cc59627
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
194 lines
7.0 KiB
Swift
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)
|
|
}
|