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:
alexandrev-tibco
2026-05-21 23:09:36 +02:00
parent 773da6800b
commit b48a47ce10
41 changed files with 2659 additions and 483 deletions
+126 -42
View File
@@ -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)
}
}