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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user