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
@@ -7,6 +7,7 @@ struct ChartsContainerView: View {
@StateObject private var goalsViewModel = GoalsViewModel()
@AppStorage("calmModeEnabled") private var calmModeEnabled = true
@AppStorage("showForecast") private var showForecast = true
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
init(iapService: IAPService) {
_viewModel = StateObject(wrappedValue: ChartsViewModel(iapService: iapService))
@@ -14,50 +15,19 @@ struct ChartsContainerView: View {
var body: some View {
NavigationStack {
ZStack {
AppBackground()
ScrollView {
VStack(spacing: 20) {
// Chart Type Selector
chartTypeSelector
// Paywall nudge for free users
if !viewModel.isPremium {
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
.onAppear {
FirebaseService.shared.logPaywallShown(trigger: "charts_banner")
}
}
// Unified Filters
filtersSection
// Chart Content
chartContent
}
.padding()
Group {
if horizontalSizeClass == .regular {
iPadChartsLayout
} else {
iPhoneChartsLayout
}
}
.navigationTitle("Charts")
.sheet(isPresented: $viewModel.showingPaywall) {
PaywallView()
}
.sheet(isPresented: $viewModel.showingPaywall) { PaywallView() }
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
accountFilterMenu
}
ToolbarItem(placement: .navigationBarTrailing) { accountFilterMenu }
}
.onAppear {
viewModel.selectedAccount = accountStore.selectedAccount
viewModel.showAllAccounts = accountStore.showAllAccounts
viewModel.loadData()
goalsViewModel.selectedAccount = accountStore.selectedAccount
goalsViewModel.showAllAccounts = accountStore.showAllAccounts
goalsViewModel.refresh()
viewModel.updatePredictionTargetDate(goalsViewModel.goals)
}
// Performance: Use onChange instead of onReceive for cleaner state updates
.onAppear { syncState() }
.onChange(of: accountStore.selectedAccount) { _, newAccount in
viewModel.selectedAccount = newAccount
goalsViewModel.selectedAccount = newAccount
@@ -76,6 +46,128 @@ struct ChartsContainerView: View {
}
}
// MARK: - iPad Layout (sidebar + full-width chart)
private var iPadChartsLayout: some View {
HStack(spacing: 0) {
// Left sidebar: chart type list + filters
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Text("Chart Type")
.font(.caption.weight(.semibold))
.foregroundColor(.secondary)
.padding(.horizontal, 16)
.padding(.top, 16)
.padding(.bottom, 8)
let types = viewModel.availableChartTypes(calmModeEnabled: calmModeEnabled)
.filter { showForecast || $0 != .prediction }
ForEach(types) { chartType in
iPadChartTypeRow(chartType)
}
if hasAnyFilter {
Divider().padding(.vertical, 12)
filtersSection
.padding(.horizontal, 16)
.padding(.bottom, 16)
}
}
}
.frame(width: 220)
.background(Color(.systemBackground))
Divider()
// Right panel: full-width chart
ZStack {
AppBackground()
ScrollView {
VStack(spacing: 20) {
if !viewModel.isPremium {
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
.onAppear {
FirebaseService.shared.logPaywallShown(trigger: "charts_banner")
}
}
chartContent
}
.padding()
}
}
}
.ignoresSafeArea(edges: .bottom)
.sheet(isPresented: $viewModel.showingPaywall) { PaywallView() }
}
// MARK: - iPhone Layout
private var iPhoneChartsLayout: some View {
ZStack {
AppBackground()
ScrollView {
VStack(spacing: 20) {
chartTypeSelector
if !viewModel.isPremium {
CompactPaywallBanner(showingPaywall: $viewModel.showingPaywall)
.onAppear {
FirebaseService.shared.logPaywallShown(trigger: "charts_banner")
}
}
filtersSection
chartContent
}
.padding()
}
}
}
// MARK: - iPad Chart Type Row
private func iPadChartTypeRow(_ chartType: ChartsViewModel.ChartType) -> some View {
let isSelected = viewModel.selectedChartType == chartType
let isPremiumLocked = chartType.isPremium && !viewModel.isPremium
return Button {
viewModel.selectChart(chartType)
} label: {
HStack(spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 8)
.fill(isSelected ? Color.appPrimary : Color(.systemGray5))
.frame(width: 32, height: 32)
Image(systemName: chartType.icon)
.font(.system(size: 14))
.foregroundColor(isSelected ? .white : .primary)
}
Text(chartType.rawValue)
.font(.subheadline)
.foregroundColor(isPremiumLocked ? .secondary : .primary)
Spacer()
if isPremiumLocked {
Image(systemName: "lock.fill")
.font(.caption2)
.foregroundColor(.secondary)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(isSelected ? Color.appPrimary.opacity(0.08) : Color.clear)
}
.buttonStyle(.plain)
}
// MARK: - Shared
private func syncState() {
viewModel.selectedAccount = accountStore.selectedAccount
viewModel.showAllAccounts = accountStore.showAllAccounts
viewModel.loadData()
goalsViewModel.selectedAccount = accountStore.selectedAccount
goalsViewModel.showAllAccounts = accountStore.showAllAccounts
goalsViewModel.refresh()
viewModel.updatePredictionTargetDate(goalsViewModel.goals)
}
// MARK: - Chart Type Selector
private var chartTypeSelector: some View {
@@ -167,86 +259,76 @@ struct ChartsContainerView: View {
// MARK: - Time Range Selector
private var timeRangeSelector: some View {
HStack(spacing: 8) {
ForEach(viewModel.availableTimeRanges(for: viewModel.selectedChartType)) { range in
Button {
viewModel.selectedTimeRange = range
} label: {
Text(range.rawValue)
.font(.subheadline.weight(.medium))
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(
viewModel.selectedTimeRange == range
? Color.appPrimary
: Color.gray.opacity(0.1)
)
.foregroundColor(
viewModel.selectedTimeRange == range
? .white
: .primary
)
.cornerRadius(20)
Group {
if horizontalSizeClass == .regular {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 6) {
ForEach(viewModel.availableTimeRanges(for: viewModel.selectedChartType)) { range in
timeRangeButton(range, fullWidth: true)
}
}
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(viewModel.availableTimeRanges(for: viewModel.selectedChartType)) { range in
timeRangeButton(range, fullWidth: false)
}
}
}
}
}
}
private func timeRangeButton(_ range: ChartsViewModel.TimeRange, fullWidth: Bool) -> some View {
Button {
viewModel.selectedTimeRange = range
} label: {
Text(range.rawValue)
.font(.subheadline.weight(.medium))
.frame(maxWidth: fullWidth ? .infinity : nil)
.padding(.horizontal, fullWidth ? 8 : 16)
.padding(.vertical, 8)
.background(viewModel.selectedTimeRange == range ? Color.appPrimary : Color.gray.opacity(0.1))
.foregroundColor(viewModel.selectedTimeRange == range ? .white : .primary)
.cornerRadius(20)
}
}
// MARK: - Category Filter
@ViewBuilder
private var categoryFilter: some View {
let availableCategories = viewModel.availableCategories(for: viewModel.selectedChartType)
if !availableCategories.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
if horizontalSizeClass == .regular {
VStack(spacing: 4) {
if availableCategories.count > 1 {
Button {
categoryPill(label: "All", color: .appPrimary, isSelected: viewModel.selectedCategory == nil) {
viewModel.selectedCategory = nil
} label: {
Text("All")
.font(.caption.weight(.medium))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
viewModel.selectedCategory == nil
? Color.appPrimary
: Color.gray.opacity(0.1)
)
.foregroundColor(
viewModel.selectedCategory == nil
? .white
: .primary
)
.cornerRadius(16)
}
}
ForEach(availableCategories) { category in
Button {
categoryPill(label: category.name, color: category.color,
isSelected: viewModel.selectedCategory?.id == category.id) {
viewModel.selectedCategory = category
viewModel.selectedSource = nil
} label: {
HStack(spacing: 4) {
Circle()
.fill(category.color)
.frame(width: 8, height: 8)
Text(category.name)
}
}
}
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
if availableCategories.count > 1 {
categoryPill(label: "All", color: .appPrimary,
isSelected: viewModel.selectedCategory == nil) {
viewModel.selectedCategory = nil
}
}
ForEach(availableCategories) { category in
categoryPill(label: category.name, color: category.color,
isSelected: viewModel.selectedCategory?.id == category.id) {
viewModel.selectedCategory = category
viewModel.selectedSource = nil
}
.font(.caption.weight(.medium))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
viewModel.selectedCategory?.id == category.id
? category.color
: Color.gray.opacity(0.1)
)
.foregroundColor(
viewModel.selectedCategory?.id == category.id
? .white
: .primary
)
.cornerRadius(16)
}
}
}
@@ -254,69 +336,60 @@ struct ChartsContainerView: View {
}
}
private func categoryPill(label: String, color: Color, isSelected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
.font(.caption.weight(.medium))
.frame(maxWidth: horizontalSizeClass == .regular ? .infinity : nil)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(isSelected ? color : Color.gray.opacity(0.1))
.foregroundColor(isSelected ? .white : .primary)
.cornerRadius(16)
}
}
// MARK: - Source Filter
@ViewBuilder
private var sourceFilter: some View {
let availableSources = viewModel.availableSources(for: viewModel.selectedChartType)
if !availableSources.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
if horizontalSizeClass == .regular {
VStack(spacing: 4) {
if availableSources.count > 1 {
Button {
sourcePill(label: "All Sources", color: .appPrimary,
isSelected: viewModel.selectedSourceIds.isEmpty && viewModel.selectedSource == nil) {
viewModel.selectedSource = nil
viewModel.selectedSourceIds.removeAll()
} label: {
Text("All Sources")
.font(.caption.weight(.medium))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
viewModel.selectedSourceIds.isEmpty && viewModel.selectedSource == nil
? Color.appPrimary
: Color.gray.opacity(0.1)
)
.foregroundColor(
viewModel.selectedSourceIds.isEmpty && viewModel.selectedSource == nil
? .white
: .primary
)
.cornerRadius(16)
}
}
ForEach(Array(availableSources.enumerated()), id: \.element.id) { index, source in
let sourceId = source.id
let isSelected = viewModel.selectedSourceIds.contains(sourceId)
let sourceColor = Color.sourceColor(at: index)
Button {
let isSelected = viewModel.selectedSourceIds.contains(source.id)
sourcePill(label: source.name, color: Color.sourceColor(at: index), isSelected: isSelected) {
viewModel.selectedSource = nil
if isSelected {
viewModel.selectedSourceIds.remove(sourceId)
} else {
viewModel.selectedSourceIds.insert(sourceId)
if isSelected { viewModel.selectedSourceIds.remove(source.id) }
else { viewModel.selectedSourceIds.insert(source.id) }
}
}
}
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
if availableSources.count > 1 {
sourcePill(label: "All Sources", color: .appPrimary,
isSelected: viewModel.selectedSourceIds.isEmpty && viewModel.selectedSource == nil) {
viewModel.selectedSource = nil
viewModel.selectedSourceIds.removeAll()
}
} label: {
HStack(spacing: 4) {
Circle()
.fill(sourceColor)
.frame(width: 8, height: 8)
Text(source.name)
}
ForEach(Array(availableSources.enumerated()), id: \.element.id) { index, source in
let isSelected = viewModel.selectedSourceIds.contains(source.id)
sourcePill(label: source.name, color: Color.sourceColor(at: index), isSelected: isSelected) {
viewModel.selectedSource = nil
if isSelected { viewModel.selectedSourceIds.remove(source.id) }
else { viewModel.selectedSourceIds.insert(source.id) }
}
.font(.caption.weight(.medium))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
isSelected
? sourceColor
: Color.gray.opacity(0.1)
)
.foregroundColor(
isSelected
? .white
: .primary
)
.cornerRadius(16)
}
}
}
@@ -324,34 +397,54 @@ struct ChartsContainerView: View {
}
}
private func sourcePill(label: String, color: Color, isSelected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
.font(.caption.weight(.medium))
.frame(maxWidth: horizontalSizeClass == .regular ? .infinity : nil)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(isSelected ? color : Color.gray.opacity(0.1))
.foregroundColor(isSelected ? .white : .primary)
.cornerRadius(16)
}
}
// MARK: - Breakdown Selector
private var breakdownSelector: some View {
HStack(spacing: 8) {
ForEach(ChartsViewModel.BreakdownMode.allCases) { mode in
Button {
viewModel.selectedBreakdown = mode
} label: {
Text(mode.rawValue)
.font(.subheadline.weight(.medium))
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(
viewModel.selectedBreakdown == mode
? Color.appPrimary
: Color.gray.opacity(0.1)
)
.foregroundColor(
viewModel.selectedBreakdown == mode
? .white
: .primary
)
.cornerRadius(18)
Group {
if horizontalSizeClass == .regular {
VStack(spacing: 4) {
ForEach(ChartsViewModel.BreakdownMode.allCases) { mode in
breakdownButton(mode)
}
}
} else {
HStack(spacing: 8) {
ForEach(ChartsViewModel.BreakdownMode.allCases) { mode in
breakdownButton(mode)
}
}
}
}
}
private func breakdownButton(_ mode: ChartsViewModel.BreakdownMode) -> some View {
Button {
viewModel.selectedBreakdown = mode
} label: {
Text(mode.rawValue)
.font(.subheadline.weight(.medium))
.frame(maxWidth: horizontalSizeClass == .regular ? .infinity : nil)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(viewModel.selectedBreakdown == mode ? Color.appPrimary : Color.gray.opacity(0.1))
.foregroundColor(viewModel.selectedBreakdown == mode ? .white : .primary)
.cornerRadius(18)
}
}
// MARK: - Chart Content
@ViewBuilder
@@ -403,6 +496,8 @@ struct ChartsContainerView: View {
predictions: viewModel.predictionData,
historicalData: viewModel.evolutionData
)
case .yearOverYear:
YearOverYearChartView(series: viewModel.yearOverYearData)
}
}
}