b48a47ce10
- 1A: Notificación mensual de resumen de portfolio (día 5 de cada mes) - 1B: Badge de racha mensual en Dashboard (streak counter) - 1C: Empty states mejorados en Goals y Journal con CTAs claros - 2A: Quick Update sheet — actualiza todas las fuentes desde una pantalla - 2B: Notificación batch update redirige al Quick Update sheet - 2C: Widget deep link portfoliojournal://quickupdate abre Quick Update - 3A: App Store version check banner en Settings - 3C: What's New sheet en primer launch de versión nueva - Localización completa en 7 idiomas para todas las nuevas strings Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
346 lines
14 KiB
Swift
346 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
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .topTrailing) {
|
|
Button(action: onEdit) {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack {
|
|
Text(goal.name)
|
|
.font(.headline)
|
|
.foregroundColor(isAchieved ? .appSecondary : .primary)
|
|
if isAchieved {
|
|
Text("Achieved")
|
|
.font(.caption2.weight(.bold))
|
|
.foregroundColor(.white)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(Color.appSecondary)
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
|
|
GoalProgressBar(
|
|
progress: progress,
|
|
tint: isAchieved ? .appSuccess : .appSecondary,
|
|
iconColor: isAchieved ? .appSuccess : .appSecondary
|
|
)
|
|
|
|
HStack {
|
|
Text(totalValue.currencyString)
|
|
.font(.subheadline.weight(.semibold))
|
|
Spacer()
|
|
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 ? .negativeRed : .positiveGreen)
|
|
)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(12)
|
|
.background(isAchieved ? Color.appSuccess.opacity(0.10) : Color.clear)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.stroke(isAchieved ? Color.appSuccess.opacity(0.35) : Color.clear, lineWidth: 1)
|
|
)
|
|
.cornerRadius(12)
|
|
}
|
|
.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()))
|
|
}
|