Home: carrusel de charts con gating premium (candado) + aviso de próximo check-in

- EvolutionChartCard: swipe entre 7 charts (evolution, allocation libres; performance,
  drawdown, volatility, prediction, year-over-year premium). No premium ve las premium
  con candado + CTA de unlock; premium las ve reales. Reutiliza ChartsViewModel (computa
  solo la página visible) y las vistas del feature Charts.
- MonthlyCheckInCard: aviso de próximo check-in siempre visible (fecha + badge de
  días restantes/atrasado), con fallback cuando no hay check-in previo.
- Localización de nuevas cadenas en los 7 idiomas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZFmGhbWzibhApev3C4554
This commit is contained in:
alexandrev-tibco
2026-07-21 09:39:26 +02:00
parent 2a5ffca48f
commit 95c689d2b8
9 changed files with 219 additions and 22 deletions
@@ -309,7 +309,9 @@ struct DashboardView: View {
goals: goalsViewModel.goals,
allocation: viewModel.categoryMetrics.map {
(category: $0.categoryName, value: $0.totalValue, color: $0.colorHex)
}
},
iapService: iapService,
onRequestUpgrade: { viewModel.showingPaywall = true }
)
}
case .categoryBreakdown:
@@ -572,9 +574,22 @@ struct MonthlyCheckInCard: View {
}
private var nextCheckInDate: Date? {
guard let last = effectiveLastCheckInDate else { return nil }
let effective = MonthlyCheckInStore.effectiveMonth(for: last, relativeTo: last)
return effective.adding(months: 1).endOfMonth
if let last = effectiveLastCheckInDate {
let effective = MonthlyCheckInStore.effectiveMonth(for: last, relativeTo: last)
return effective.adding(months: 1).endOfMonth
}
// No prior check-in yet still surface a due date (end of the current
// effective month) so the reminder is always visible on the Home card.
let effectiveNow = MonthlyCheckInStore.effectiveMonth(for: Date(), relativeTo: Date())
return effectiveNow.endOfMonth
}
/// Short urgency badge shown next to the next check-in date.
private var deadlineBadgeText: String {
guard let days = daysUntilDeadline else { return "" }
if days < 0 { return String(localized: "checkin_overdue") }
if days == 0 { return String(localized: "checkin_due_today") }
return String(format: String(localized: "checkin_days_left"), days)
}
private var isOverdue: Bool {
@@ -785,9 +800,28 @@ struct MonthlyCheckInCard: View {
}
if let nextDate = nextCheckInDate {
Text("Next check-in: \(nextDate.mediumDateString)")
.font(.caption)
.foregroundColor(isOverdue ? .red : .secondary)
HStack(spacing: 6) {
Image(systemName: "calendar")
.font(.caption)
.foregroundColor(isOverdue ? .red : .secondary)
Text(String(format: String(localized: "checkin_next_due"), nextDate.mediumDateString))
.font(.caption)
.foregroundColor(isOverdue ? .red : .secondary)
Spacer()
if !deadlineBadgeText.isEmpty {
Text(deadlineBadgeText)
.font(.caption2.weight(.semibold))
.foregroundColor(progressBarTint)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(progressBarTint.opacity(0.12))
.clipShape(Capsule())
}
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(Color(.tertiarySystemFill))
.cornerRadius(10)
}
Button {
@@ -3,12 +3,36 @@ import Charts
/// Swipeable pages for the Home evolution card.
enum DashboardChartPage: Hashable {
case evolution, allocation
case evolution, allocation, performance, drawdown, volatility, prediction, yearOverYear
var title: String {
switch self {
case .evolution: return String(localized: "Portfolio Evolution")
case .allocation: return String(localized: "Asset Allocation")
case .performance: return String(localized: "performance")
case .drawdown: return String(localized: "drawdown")
case .volatility: return String(localized: "volatility")
case .prediction: return String(localized: "prediction")
case .yearOverYear: return String(localized: "dash_chart_yoy")
}
}
/// Pages that stay unlocked on the Home carousel for everyone. The rest show a
/// lock teaser for non-premium users (they still see the page to drive upgrade).
var isFreeOnHome: Bool {
self == .evolution || self == .allocation
}
/// Maps to the full Charts feature type so we can reuse its computed data + views.
var chartsType: ChartsViewModel.ChartType {
switch self {
case .evolution: return .evolution
case .allocation: return .allocation
case .performance: return .performance
case .drawdown: return .drawdown
case .volatility: return .volatility
case .prediction: return .prediction
case .yearOverYear: return .yearOverYear
}
}
}
@@ -20,6 +44,31 @@ struct EvolutionChartCard: View {
/// Current allocation per category (name, value, colorHex) for the swipeable
/// Allocation page. Empty the Allocation page is skipped.
var allocation: [(category: String, value: Decimal, color: String)] = []
let iapService: IAPService
/// Called when a non-premium user taps "unlock" on a locked chart page.
var onRequestUpgrade: () -> Void = {}
/// Reused to render the premium analytics pages (performance/drawdown/): it
/// already computes their data and the Charts feature already ships the views.
/// Only the currently-visible premium page is computed (see syncChartsVM).
@StateObject private var chartsVM: ChartsViewModel
init(
data: [(date: Date, value: Decimal)],
categoryData: [CategoryEvolutionPoint],
goals: [Goal],
allocation: [(category: String, value: Decimal, color: String)] = [],
iapService: IAPService,
onRequestUpgrade: @escaping () -> Void = {}
) {
self.data = data
self.categoryData = categoryData
self.goals = goals
self.allocation = allocation
self.iapService = iapService
self.onRequestUpgrade = onRequestUpgrade
_chartsVM = StateObject(wrappedValue: ChartsViewModel(iapService: iapService))
}
@State private var selectedDataPoint: (date: Date, value: Decimal)?
@State private var chartMode: ChartMode = .total
@@ -35,9 +84,25 @@ struct EvolutionChartCard: View {
private var pages: [DashboardChartPage] {
var p: [DashboardChartPage] = [.evolution]
if !allocation.isEmpty { p.append(.allocation) }
// Premium analytics pages are shown to everyone premium users see the
// real chart, free users see a lock teaser (drives the upgrade).
p.append(contentsOf: [.performance, .drawdown, .volatility, .prediction, .yearOverYear])
return p
}
private func isLocked(_ page: DashboardChartPage) -> Bool {
!page.isFreeOnHome && !iapService.isPremium
}
/// Compute data for the visible premium page on demand (the VM computes only its
/// selected chart). No-op for free/locked pages so nothing is calculated in vain.
private func syncChartsVM(for page: DashboardChartPage) {
guard !isLocked(page), !page.isFreeOnHome else { return }
if chartsVM.selectedChartType != page.chartsType {
chartsVM.selectChart(page.chartsType)
}
}
enum ChartMode: String, CaseIterable, Identifiable {
case total = "Total"
case byCategory = "By Category"
@@ -90,26 +155,82 @@ struct EvolutionChartCard: View {
// dominance is enforced in onEnded so vertical scroll still works; a
// single page is a harmless no-op (bounds prevent movement).
.simultaneousGesture(pageSwipeGesture)
.onAppear { syncChartsVM(for: page) }
.onChange(of: page) { _, newPage in syncChartsVM(for: newPage) }
}
@ViewBuilder
private var pageContent: some View {
switch page {
case .evolution:
modePicker
chartSection
case .allocation:
SemicircleAllocation(
data: allocation,
total: allocation.reduce(Decimal.zero) { $0 + $1.value },
selectedSlice: $allocationSelection
)
.frame(height: 150)
.padding(.vertical, 8)
allocationLegend
if isLocked(page) {
lockedPageView(page)
} else {
switch page {
case .evolution:
modePicker
chartSection
case .allocation:
SemicircleAllocation(
data: allocation,
total: allocation.reduce(Decimal.zero) { $0 + $1.value },
selectedSlice: $allocationSelection
)
.frame(height: 150)
.padding(.vertical, 8)
allocationLegend
case .performance:
HorizontalPerformanceChart(data: chartsVM.performanceData)
case .drawdown:
DrawdownChart(data: chartsVM.drawdownData)
case .volatility:
VolatilityChartView(data: chartsVM.volatilityData)
case .prediction:
PredictionChartView(
predictions: chartsVM.predictionData,
historicalData: chartsVM.evolutionData
)
case .yearOverYear:
YearOverYearChartView(viewModel: chartsVM)
}
}
}
/// Compact lock teaser reused from the Charts feature's premium treatment.
private func lockedPageView(_ page: DashboardChartPage) -> some View {
VStack(spacing: 12) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.12))
.frame(width: 72, height: 72)
Image(systemName: page.chartsType.icon)
.font(.system(size: 28))
.foregroundColor(.appPrimary)
Image(systemName: "lock.circle.fill")
.font(.system(size: 22))
.foregroundColor(.appWarning)
.background(Circle().fill(Color(.systemBackground)))
.offset(x: 26, y: 24)
}
Text(String(localized: "home_chart_locked_sub"))
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
Button {
FirebaseService.shared.logPaywallShown(trigger: "home_charts")
onRequestUpgrade()
} label: {
Label(String(localized: "chart_locked_cta"), systemImage: "lock.open.fill")
.font(.subheadline.weight(.semibold))
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(Color.appPrimary)
.foregroundColor(.white)
.clipShape(Capsule())
}
}
.frame(maxWidth: .infinity, minHeight: 240)
.padding(.vertical, 12)
}
private var headerView: some View {
HStack {
Text(page.title)
@@ -439,6 +560,6 @@ struct SparklineView: View {
(Date(), 12000)
]
return EvolutionChartCard(data: sampleData, categoryData: [], goals: [])
return EvolutionChartCard(data: sampleData, categoryData: [], goals: [], iapService: IAPService())
.padding()
}