Files
InvestmentTrackerApp/PortfolioJournal/Views/Goals/GoalsView.swift
T
alexandrev-tibco ed737bc290 Calm 2.0 Fase 3: Home iPad columna única, Goals con anillos, Journal timeline, Sources polish (build 53)
Home iPad/Mac (feedback del usuario: 'me gusta más el iPhone'):
- Fuera el grid de 2 columnas (acoplaba alturas de fila y dejaba huecos entre
  cards de tamaños distintos) → columna única centrada a 720pt, patrón
  Things/Day One. Eliminado el drag-drop del grid, columnSpan y el overlay
  full-screen (~120 líneas); el orden/visibilidad sigue en Customize.

Goals:
- ProgressRing nuevo (anillo estilo Fitness con porcentaje centrado y color
  semántico: verde=logrado, ámbar=retrasado, acento=en camino) en GoalRowView
  y en la card de Goals del Home. GoalProgressBar queda solo en la share card.

Journal:
- Momentum & Streaks vive ahora en la cabecera del Journal (la historia del
  hábito es narrativa, no dashboard).
- Filas timeline: círculo de mood como ancla visual (tint semántico por mood),
  mes + estrellas + preview de nota.

Sources:
- Badge 'Needs update' como chip ámbar visible; valor en tipografía rounded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-09 11:11:04 +02:00

345 lines
14 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),
estimatedCompletionDate: viewModel.estimateCompletionDate(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 estimatedCompletionDate: Date?
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())
}
}
HStack(spacing: 4) {
Text(totalValue.currencyString)
.font(.subheadline.weight(.semibold))
Text("of \(goal.targetDecimal.currencyString)")
.font(.subheadline)
.foregroundColor(.secondary)
}
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)
)
}
}
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: estimatedCompletionDate,
privacyMode: privacyMode
)
}
}
#Preview {
GoalsView()
.environmentObject(AccountStore(iapService: IAPService()))
}