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())) }