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