Release 1.4.0 (build 31): retención, quick update, streak, what's new
- 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>
This commit is contained in:
@@ -5,7 +5,20 @@ struct GoalsView: View {
|
||||
@StateObject private var viewModel = GoalsViewModel()
|
||||
@State private var showingAddGoal = false
|
||||
@State private var editingGoal: Goal?
|
||||
@State private var showAchievedGoals = false
|
||||
@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 {
|
||||
@@ -14,11 +27,7 @@ struct GoalsView: View {
|
||||
|
||||
List {
|
||||
if filteredGoals.isEmpty {
|
||||
if viewModel.goals.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
hiddenAchievedState
|
||||
}
|
||||
emptyState(for: goalFilter)
|
||||
} else {
|
||||
Section {
|
||||
ForEach(filteredGoals) { goal in
|
||||
@@ -30,14 +39,24 @@ struct GoalsView: View {
|
||||
estimatedCompletionDate: viewModel.estimateCompletionDate(for: goal),
|
||||
onEdit: { editingGoal = goal }
|
||||
)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button(role: .destructive) {
|
||||
viewModel.deleteGoal(goal)
|
||||
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: {
|
||||
@@ -54,10 +73,12 @@ struct GoalsView: View {
|
||||
.navigationTitle("Goals")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button(showAchievedGoals ? "Hide Achieved" : "Show Achieved") {
|
||||
showAchievedGoals.toggle()
|
||||
Picker(String(localized: "goals_filter_active"), selection: $goalFilter) {
|
||||
ForEach(GoalFilter.allCases) { filter in
|
||||
Text(filter.label).tag(filter)
|
||||
}
|
||||
}
|
||||
.font(.caption.weight(.semibold))
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
@@ -86,44 +107,107 @@ struct GoalsView: View {
|
||||
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] {
|
||||
guard !showAchievedGoals else { return viewModel.goals }
|
||||
return viewModel.goals.filter { !viewModel.isAchieved($0) }
|
||||
switch goalFilter {
|
||||
case .active:
|
||||
return viewModel.goals.filter { $0.isActive }
|
||||
case .archived:
|
||||
return viewModel.goals.filter { !$0.isActive }
|
||||
case .all:
|
||||
return viewModel.goals
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
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)
|
||||
@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)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 32)
|
||||
}
|
||||
|
||||
private var hiddenAchievedState: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "party.popper")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(.appSecondary)
|
||||
Text("All visible goals are achieved")
|
||||
.font(.headline)
|
||||
Text("Tap \"Show Achieved\" to review completed goals.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 32)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user