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
@@ -8,7 +8,7 @@ class SourceListViewModel: ObservableObject {
@Published var sources: [InvestmentSource] = []
@Published var categories: [Category] = []
@Published var selectedCategory: Category?
@Published var selectedCategoryIds: Set<UUID> = []
@Published var searchText = ""
@Published var selectedAccount: Account?
@Published var showAllAccounts = true
@@ -55,7 +55,7 @@ class SourceListViewModel: ObservableObject {
Publishers.CombineLatest4(
sourceRepository.$sources,
$searchText.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main),
$selectedCategory,
$selectedCategoryIds,
$selectedAccount
)
.combineLatest($showAllAccounts)
@@ -88,9 +88,12 @@ class SourceListViewModel: ObservableObject {
filtered = filtered.filter { $0.account?.id == selectedAccountId }
}
// Filter by category
if let category = selectedCategory {
filtered = filtered.filter { $0.category?.id == category.id }
// Filter by category (multi-select)
if !selectedCategoryIds.isEmpty {
filtered = filtered.filter {
guard let id = $0.category?.id else { return false }
return selectedCategoryIds.contains(id)
}
}
// Filter by search text
@@ -200,21 +203,29 @@ class SourceListViewModel: ObservableObject {
}
var isEmpty: Bool {
sources.isEmpty && searchText.isEmpty && selectedCategory == nil
sources.isEmpty && searchText.isEmpty && selectedCategoryIds.isEmpty
}
var isFiltered: Bool {
!searchText.isEmpty || selectedCategory != nil
!searchText.isEmpty || !selectedCategoryIds.isEmpty
}
// MARK: - Category Filter
func selectCategory(_ category: Category?) {
selectedCategory = category
guard let category else {
selectedCategoryIds = []
return
}
if selectedCategoryIds.contains(category.id) {
selectedCategoryIds.remove(category.id)
} else {
selectedCategoryIds.insert(category.id)
}
}
func clearFilters() {
searchText = ""
selectedCategory = nil
selectedCategoryIds = []
}
}