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
457 lines
18 KiB
Swift
457 lines
18 KiB
Swift
import SwiftUI
|
|
|
|
struct GoalsView: View {
|
|
@EnvironmentObject private var accountStore: AccountStore
|
|
@StateObject private var viewModel = GoalsViewModel()
|
|
@State private var showingAddGoal = false
|
|
@State private var editingGoal: Goal?
|
|
@State private var goalFilter: GoalFilter = .active
|
|
@State private var goalToDelete: Goal?
|
|
|
|
private enum GoalFilter: String, CaseIterable, Identifiable {
|
|
case active, archived, all
|
|
var id: String { rawValue }
|
|
var label: String {
|
|
switch self {
|
|
case .active: return String(localized: "goals_filter_active")
|
|
case .archived: return String(localized: "goals_filter_archived")
|
|
case .all: return String(localized: "goals_filter_all")
|
|
}
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ZStack {
|
|
AppBackground()
|
|
|
|
List {
|
|
if filteredGoals.isEmpty {
|
|
emptyState(for: goalFilter)
|
|
} else {
|
|
Section {
|
|
ForEach(filteredGoals) { goal in
|
|
GoalRowView(
|
|
goal: goal,
|
|
progress: viewModel.progress(for: goal),
|
|
totalValue: viewModel.totalValue(for: goal),
|
|
paceStatus: viewModel.paceStatus(for: goal),
|
|
projection: viewModel.projection(for: goal),
|
|
onEdit: { editingGoal = goal }
|
|
)
|
|
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
|
Button(role: .destructive) {
|
|
goalToDelete = goal
|
|
} label: {
|
|
Label("Delete", systemImage: "trash")
|
|
}
|
|
}
|
|
.swipeActions(edge: .leading, allowsFullSwipe: false) {
|
|
Button {
|
|
viewModel.archiveGoal(goal)
|
|
} label: {
|
|
Label(
|
|
goal.isActive ? String(localized: "goal_archive") : String(localized: "goal_unarchive"),
|
|
systemImage: goal.isActive ? "archivebox" : "arrow.uturn.backward"
|
|
)
|
|
}
|
|
.tint(goal.isActive ? .orange : .blue)
|
|
|
|
Button {
|
|
editingGoal = goal
|
|
} label: {
|
|
Label("Edit", systemImage: "pencil")
|
|
}
|
|
.tint(.appSecondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.scrollContentBackground(.hidden)
|
|
}
|
|
.navigationTitle("Goals")
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Picker(String(localized: "goals_filter_active"), selection: $goalFilter) {
|
|
ForEach(GoalFilter.allCases) { filter in
|
|
Text(filter.label).tag(filter)
|
|
}
|
|
}
|
|
.pickerStyle(.menu)
|
|
}
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button {
|
|
showingAddGoal = true
|
|
} label: {
|
|
Image(systemName: "plus")
|
|
}
|
|
}
|
|
}
|
|
.sheet(isPresented: $showingAddGoal) {
|
|
GoalEditorView(account: accountStore.showAllAccounts ? nil : accountStore.selectedAccount)
|
|
}
|
|
.sheet(item: $editingGoal) { goal in
|
|
GoalEditorView(account: goal.account, goal: goal)
|
|
}
|
|
.onAppear {
|
|
viewModel.selectedAccount = accountStore.selectedAccount
|
|
viewModel.showAllAccounts = accountStore.showAllAccounts
|
|
viewModel.refresh()
|
|
}
|
|
.onReceive(accountStore.$selectedAccount) { account in
|
|
viewModel.selectedAccount = account
|
|
viewModel.refresh()
|
|
}
|
|
.onReceive(accountStore.$showAllAccounts) { showAll in
|
|
viewModel.showAllAccounts = showAll
|
|
viewModel.refresh()
|
|
}
|
|
.alert(String(localized: "goal_delete_title"), isPresented: Binding(
|
|
get: { goalToDelete != nil },
|
|
set: { if !$0 { goalToDelete = nil } }
|
|
)) {
|
|
Button(String(localized: "goal_delete_confirm"), role: .destructive) {
|
|
if let goal = goalToDelete {
|
|
viewModel.deleteGoal(goal)
|
|
}
|
|
goalToDelete = nil
|
|
}
|
|
Button(String(localized: "cancel"), role: .cancel) {
|
|
goalToDelete = nil
|
|
}
|
|
} message: {
|
|
Text(String(localized: "goal_delete_message"))
|
|
}
|
|
}
|
|
}
|
|
|
|
private var filteredGoals: [Goal] {
|
|
switch goalFilter {
|
|
case .active:
|
|
return viewModel.goals.filter { $0.isActive }
|
|
case .archived:
|
|
return viewModel.goals.filter { !$0.isActive }
|
|
case .all:
|
|
return viewModel.goals
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func emptyState(for filter: GoalFilter) -> some View {
|
|
switch filter {
|
|
case .active:
|
|
if viewModel.goals.isEmpty {
|
|
VStack(spacing: 16) {
|
|
Image(systemName: "target")
|
|
.font(.system(size: 48))
|
|
.foregroundColor(.secondary)
|
|
Text("Set your first goal")
|
|
.font(.headline)
|
|
Text("Track progress toward milestones like \(AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currencySymbol)1M and share your wins.")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
Button {
|
|
showingAddGoal = true
|
|
} label: {
|
|
Text(String(localized: "goals_empty_add_cta"))
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundColor(.white)
|
|
.padding(.horizontal, 20)
|
|
.padding(.vertical, 10)
|
|
.background(Color.appPrimary)
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 32)
|
|
} else {
|
|
VStack(spacing: 12) {
|
|
Image(systemName: "archivebox")
|
|
.font(.system(size: 40))
|
|
.foregroundColor(.secondary)
|
|
Text(String(localized: "goals_all_active_achieved"))
|
|
.font(.headline)
|
|
.foregroundColor(.secondary)
|
|
Text("Switch to \"Archived\" or \"All\" to see other goals.")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 32)
|
|
}
|
|
case .archived:
|
|
VStack(spacing: 12) {
|
|
Image(systemName: "archivebox")
|
|
.font(.system(size: 40))
|
|
.foregroundColor(.secondary)
|
|
Text(String(localized: "goals_empty_archived"))
|
|
.font(.headline)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 32)
|
|
case .all:
|
|
VStack(spacing: 16) {
|
|
Image(systemName: "target")
|
|
.font(.system(size: 48))
|
|
.foregroundColor(.secondary)
|
|
Text("Set your first goal")
|
|
.font(.headline)
|
|
Text("Track progress toward milestones like \(AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currencySymbol)1M and share your wins.")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 32)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct GoalRowView: View {
|
|
let goal: Goal
|
|
let progress: Double
|
|
let totalValue: Decimal
|
|
let paceStatus: GoalPaceStatus?
|
|
let projection: GoalProjection.Result
|
|
let onEdit: () -> Void
|
|
|
|
@State private var showingShareOptions = false
|
|
|
|
private var isAchieved: Bool {
|
|
GoalsViewModel.isAchieved(progress: progress)
|
|
}
|
|
|
|
private var targetUrgency: GoalUrgencyLevel {
|
|
GoalsViewModel.urgencyLevel(
|
|
targetDate: goal.targetDate,
|
|
isBehind: paceStatus?.isBehind ?? false,
|
|
isAchieved: isAchieved
|
|
)
|
|
}
|
|
|
|
private var targetDateColor: Color {
|
|
switch targetUrgency {
|
|
case .normal:
|
|
return .secondary
|
|
case .warning:
|
|
return .appWarning
|
|
case .critical:
|
|
return .negativeRed
|
|
}
|
|
}
|
|
|
|
/// Semantic ring color: green when achieved or ahead, orange when behind,
|
|
/// accent while simply on the way.
|
|
private var ringTint: Color {
|
|
if isAchieved { return .appSuccess }
|
|
if paceStatus?.isBehind == true { return .appWarning }
|
|
return .appPrimary
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .topTrailing) {
|
|
Button(action: onEdit) {
|
|
HStack(spacing: 16) {
|
|
ProgressRing(progress: progress, tint: ringTint)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack(spacing: 8) {
|
|
Text(goal.name)
|
|
.font(.headline)
|
|
if isAchieved {
|
|
Text("Achieved")
|
|
.font(.caption2.weight(.bold))
|
|
.foregroundColor(.white)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 3)
|
|
.background(Color.appSuccess)
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
|
|
if let category = goal.category {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: category.icon)
|
|
.font(.caption2)
|
|
Text(category.name)
|
|
.font(.caption2.weight(.semibold))
|
|
}
|
|
.foregroundColor(category.color)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 3)
|
|
.background(category.color.opacity(0.15))
|
|
.clipShape(Capsule())
|
|
}
|
|
|
|
HStack(spacing: 4) {
|
|
Text(totalValue.currencyString)
|
|
.font(.subheadline.weight(.semibold))
|
|
.hiddenBalance()
|
|
Text("of \(goal.targetDecimal.currencyString)")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
.hiddenBalance()
|
|
}
|
|
|
|
if let targetDate = goal.targetDate {
|
|
Text("Target date: \(targetDate.mediumDateString)")
|
|
.font(.caption)
|
|
.foregroundColor(targetDateColor)
|
|
}
|
|
|
|
if let paceStatus {
|
|
Text(paceStatus.statusText)
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundColor(
|
|
isAchieved ? .appSuccess : (paceStatus.isBehind ? .appWarning : .positiveGreen)
|
|
)
|
|
}
|
|
|
|
if !isAchieved {
|
|
projectionRow
|
|
}
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.vertical, 8)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
Button {
|
|
showingShareOptions = true
|
|
} label: {
|
|
Image(systemName: "square.and.arrow.up")
|
|
.foregroundColor(.appPrimary)
|
|
.padding(.top, 2)
|
|
}
|
|
.buttonStyle(.borderless)
|
|
}
|
|
.padding(.vertical, 8)
|
|
.confirmationDialog("Share Goal", isPresented: $showingShareOptions, titleVisibility: .visible) {
|
|
Button("Share with amounts") {
|
|
shareGoal(privacyMode: false)
|
|
}
|
|
Button("Share (privacy mode)") {
|
|
shareGoal(privacyMode: true)
|
|
}
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
Text("Choose how to share your goal progress")
|
|
}
|
|
}
|
|
|
|
private func shareGoal(privacyMode: Bool) {
|
|
GoalShareService.shared.shareGoal(
|
|
name: goal.name,
|
|
progress: progress,
|
|
currentValue: totalValue,
|
|
targetValue: goal.targetDecimal,
|
|
targetDate: goal.targetDate,
|
|
estimatedCompletionDate: projection.projectedDate,
|
|
privacyMode: privacyMode
|
|
)
|
|
}
|
|
|
|
// MARK: - Projection UI
|
|
|
|
/// Projected completion date, an on-track chip, and a tiny projection sparkline.
|
|
@ViewBuilder
|
|
private var projectionRow: some View {
|
|
switch projection.status {
|
|
case .unreachable:
|
|
Label(String(localized: "goal_projection_add_contributions"), systemImage: "plus.circle")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
default:
|
|
HStack(spacing: 8) {
|
|
if let date = projection.projectedDate {
|
|
Text(String(format: String(localized: "goal_projection_projected"), date.monthYearString))
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
onTrackChip
|
|
Spacer(minLength: 0)
|
|
if projection.sparkline.count >= 2 {
|
|
ProjectionSparkline(values: projection.sparkline, tint: chipColor)
|
|
.frame(width: 48, height: 16)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var chipColor: Color {
|
|
switch projection.status {
|
|
case .aheadOfSchedule: return .appSuccess
|
|
case .onTrack: return .positiveGreen
|
|
case .behind: return .appWarning
|
|
case .noTargetDate: return .secondary
|
|
case .unreachable: return .secondary
|
|
}
|
|
}
|
|
|
|
private var chipText: String {
|
|
switch projection.status {
|
|
case .aheadOfSchedule: return String(localized: "goal_chip_ahead")
|
|
case .onTrack: return String(localized: "goal_chip_on_track")
|
|
case .behind: return String(localized: "goal_chip_behind")
|
|
case .noTargetDate: return String(localized: "goal_chip_at_this_pace")
|
|
case .unreachable: return ""
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var onTrackChip: some View {
|
|
if projection.status != .unreachable {
|
|
Text(chipText)
|
|
.font(.caption2.weight(.semibold))
|
|
.foregroundColor(chipColor)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 3)
|
|
.background(chipColor.opacity(0.15))
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Minimal filled sparkline for the goal value projection curve.
|
|
private struct ProjectionSparkline: View {
|
|
let values: [Decimal]
|
|
let tint: Color
|
|
|
|
var body: some View {
|
|
GeometryReader { geo in
|
|
let points = normalizedPoints(in: geo.size)
|
|
ZStack {
|
|
if points.count >= 2 {
|
|
Path { path in
|
|
path.move(to: points[0])
|
|
for p in points.dropFirst() { path.addLine(to: p) }
|
|
}
|
|
.stroke(tint, style: StrokeStyle(lineWidth: 1.5, lineCap: .round, lineJoin: .round))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func normalizedPoints(in size: CGSize) -> [CGPoint] {
|
|
let doubles = values.map { NSDecimalNumber(decimal: $0).doubleValue }
|
|
guard let minV = doubles.min(), let maxV = doubles.max(), doubles.count >= 2 else { return [] }
|
|
let range = max(maxV - minV, 0.0001)
|
|
return doubles.enumerated().map { index, value in
|
|
let x = size.width * CGFloat(index) / CGFloat(doubles.count - 1)
|
|
let y = size.height * (1 - CGFloat((value - minV) / range))
|
|
return CGPoint(x: x, y: y)
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
GoalsView()
|
|
.environmentObject(AccountStore(iapService: IAPService()))
|
|
}
|