Files
InvestmentTrackerApp/PortfolioJournal/Views/Dashboard/EvolutionChart.swift
T
alexandrev-tibco 7d2f605c16 quitar AdMob, Firebase Analytics y ATT de la app
"Portfolio Journal sin AdMob ni Google: es la única forma de que 'sin
rastreo' sea verdad, y el ingreso por anuncios es despreciable" — la ficha
de App Store prometía "sin analíticas, sin rastreo, sin venta de datos"
mientras la app enlazaba GoogleMobileAds y FirebaseAnalytics, pedía permiso
de rastreo al arrancar y mandaba el importe del saldo a GA4 en el evento
snapshot_added.

SDKs fuera del proyecto (project.pbxproj): paquetes firebase-ios-sdk y
swift-package-manager-google-mobile-ads, productos FirebaseCore,
FirebaseAnalytics, GoogleMobileAds y FirebaseCrashlytics, más la fase de
build "Upload dSYMs to Crashlytics". Package.resolved se queda sin nada que
resolver. Con la fase de script fuera, ENABLE_USER_SCRIPT_SANDBOXING vuelve
a YES en el target app (estaba en NO solo por Crashlytics).

Código borrado:
- Services/AdMobService.swift entero — con él se van el flujo UMP,
  BannerAdView, BannerAdCoordinator y la llamada a
  ATTrackingManager.requestTrackingAuthorization().
- Services/FirebaseService.swift entero y sus 40 llamadas. Se borra en vez
  de dejarse como capa vacía: una clase llamada FirebaseService en una app
  que presume de no llevar Firebase es exactamente la clase de detalle que
  vuelve a colarse en una auditoría dentro de un año.
- El banner y su safeAreaInset en ContentView (bannerInsetView): las cinco
  pestañas ya no reservan 50 pt al pie.
- La entrada "Manage Ad Consent" de Ajustes y el @EnvironmentObject
  adMobService de SettingsView, ContentView y PortfolioJournalApp.
- AppConstants: bloque AdMob, bannerAdHeight, adConsentObtained,
  Features.enableAnalytics.
- SettingsViewModel.toggleAnalytics y analyticsEnabled — el flag no estaba
  conectado a nada ni tenía control en la interfaz. El atributo
  AppSettings.enableAnalytics se queda en CoreData con un comentario: no
  vale la pena una migración y un deploy de esquema CloudKit por borrarlo.
- Premium ya no promete "sin anuncios": fuera PremiumFeature.noAds, la
  entrada de IAPService.premiumFeatures y paywallBenefits, y las claves
  feature_no_ads / paywall_benefit_noads de los 7 idiomas. No se toca ni el
  precio ni el producto.

Info.plist: fuera NSUserTrackingUsageDescription, GADApplicationIdentifier,
GADDelayAppMeasurementInit y los 65 SKAdNetworkItems. Borrados también
GoogleService-Info.plist y los scripts Scripts/analyze_ga4.py y
Scripts/analyze_crashlytics.py, que ya no tienen de dónde leer.

Closes #50

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn
2026-09-18 16:09:04 +02:00

568 lines
22 KiB
Swift

import SwiftUI
import Charts
/// Swipeable pages for the Home evolution card.
enum DashboardChartPage: Hashable {
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
}
}
}
struct EvolutionChartCard: View {
let data: [(date: Date, value: Decimal)]
let categoryData: [CategoryEvolutionPoint]
let goals: [Goal]
/// 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
@State private var showGoalLines = true
@State private var chartWidth: CGFloat = 300
// Feedback #4: swipe the Home card between a curated set of glanceable charts.
@State private var page: DashboardChartPage = .evolution
@State private var pageInsertionEdge: Edge = .trailing
@State private var allocationSelection: String?
/// Pages available given the data on hand. "By Category" already lives as a
/// toggle inside the Evolution page, so it isn't a separate swipe page.
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"
var id: String { rawValue }
}
private static let compactXAxisDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = .autoupdatingCurrent
formatter.setLocalizedDateFormatFromTemplate("MMM yy")
return formatter
}()
/// Calculates the optimal month stride so labels never overlap,
/// using the actual rendered width of the chart instead of just data count.
private func xAxisMonthStride(for width: CGFloat) -> Int {
// ~50pt for Y-axis, ~44pt per "Jan 24" label
let usableWidth = max(width - 50, 80)
let maxLabels = max(2, Int(usableWidth / 44))
let rawStride = max(1, Int(ceil(Double(data.count) / Double(maxLabels))))
switch rawStride {
case ...1: return 1
case ...2: return 2
case ...3: return 3
case ...4: return 4
case ...6: return 6
default: return 12
}
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
headerView
pageContent
.id(page)
.transition(.asymmetric(
insertion: .move(edge: pageInsertionEdge).combined(with: .opacity),
removal: .opacity
))
if pages.count > 1 { pageIndicator }
}
.padding()
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
.contentShape(Rectangle())
// simultaneousGesture so the swipe is recognized even over the chart,
// whose own scrub DragGesture would otherwise consume it. Horizontal
// 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 {
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
// showsTitle: false the card header already names the page;
// the embedded charts' own titles duplicated it.
case .performance:
HorizontalPerformanceChart(data: chartsVM.performanceData, showsTitle: false)
case .drawdown:
DrawdownChart(data: chartsVM.drawdownData, showsTitle: false)
case .volatility:
VolatilityChartView(data: chartsVM.volatilityData, showsTitle: false)
case .prediction:
PredictionChartView(
predictions: chartsVM.predictionData,
historicalData: chartsVM.evolutionData,
showsTitle: false
)
case .yearOverYear:
YearOverYearChartView(viewModel: chartsVM, showsTitle: false)
}
}
}
/// 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))
.foregroundStyle(Color.appPrimary)
Image(systemName: "lock.circle.fill")
.font(.system(size: 22))
.foregroundStyle(Color.appWarning)
.background(Circle().fill(Color(.systemBackground)))
.offset(x: 26, y: 24)
}
Text(String(localized: "home_chart_locked_sub"))
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button {
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)
.foregroundStyle(.white)
.clipShape(Capsule())
}
}
.frame(maxWidth: .infinity, minHeight: 240)
.padding(.vertical, 12)
}
private var headerView: some View {
HStack {
Text(page.title)
.font(.headline)
Spacer()
if page == .evolution {
Button {
showGoalLines.toggle()
} label: {
Image(systemName: showGoalLines ? "target" : "slash.circle")
.foregroundStyle(.secondary)
}
.accessibilityLabel(showGoalLines ? "Hide goals" : "Show goals")
if let selected = selectedDataPoint, chartMode == .total {
VStack(alignment: .trailing) {
Text(selected.value.compactCurrencyString)
.font(.subheadline.weight(.semibold))
Text(selected.date.monthYearString)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
// MARK: - Swipe between pages
private var pageSwipeGesture: some Gesture {
DragGesture(minimumDistance: 30)
.onEnded { value in
guard abs(value.translation.width) > abs(value.translation.height) * 1.5,
let idx = pages.firstIndex(of: page) else { return }
if value.translation.width < 0, idx < pages.count - 1 {
pageInsertionEdge = .trailing
withAnimation(.snappy) { page = pages[idx + 1] }
} else if value.translation.width > 0, idx > 0 {
pageInsertionEdge = .leading
withAnimation(.snappy) { page = pages[idx - 1] }
}
}
}
private var pageIndicator: some View {
let current = pages.firstIndex(of: page) ?? 0
return HStack(spacing: 5) {
ForEach(pages.indices, id: \.self) { i in
Circle()
.fill(i == current ? Color.appPrimary : Color.secondary.opacity(0.25))
.frame(width: i == current ? 7 : 5, height: i == current ? 7 : 5)
}
}
.frame(maxWidth: .infinity)
.animation(.snappy, value: current)
}
private var allocationLegend: some View {
VStack(spacing: 6) {
ForEach(allocation.prefix(4), id: \.category) { item in
let total = allocation.reduce(Decimal.zero) { $0 + $1.value }
let pct = total > 0 ? NSDecimalNumber(decimal: item.value / total).doubleValue * 100 : 0
HStack(spacing: 8) {
Circle().fill(Color(hex: item.color) ?? .gray).frame(width: 9, height: 9)
Text(item.category).font(.caption)
Spacer()
Text(String(format: "%.1f%%", pct)).font(.caption.weight(.medium))
Text(item.value.compactCurrencyString).font(.caption).foregroundStyle(.secondary)
}
}
}
}
private var modePicker: some View {
Picker("Evolution Mode", selection: $chartMode) {
ForEach(ChartMode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.pickerStyle(.segmented)
}
@ViewBuilder
private var chartSection: some View {
if data.count >= 2 {
chartView
} else {
Text("Not enough data to display chart")
.font(.subheadline)
.foregroundStyle(.secondary)
.frame(height: 200)
.frame(maxWidth: .infinity)
}
}
private var chartView: some View {
Chart {
chartMarks
}
.chartForegroundStyleScale(domain: chartCategoryNames, range: chartCategoryColors)
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: xAxisMonthStride(for: chartWidth))) { value in
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.8, dash: [3, 3]))
.foregroundStyle(Color.secondary.opacity(0.2))
AxisTick(stroke: StrokeStyle(lineWidth: 0.8))
.foregroundStyle(Color.secondary.opacity(0.28))
AxisValueLabel {
if let date = value.as(Date.self) {
Text(date, formatter: Self.compactXAxisDateFormatter)
.font(.caption2)
}
}
}
}
.chartYAxis {
AxisMarks(position: .leading) { value in
AxisValueLabel {
if let doubleValue = value.as(Double.self) {
Text(Decimal(doubleValue).shortCurrencyString)
.font(.caption)
}
}
}
}
.chartOverlay { proxy in
GeometryReader { geometry in
Rectangle()
.fill(.clear)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
guard let plotFrameAnchor = proxy.plotFrame else { return }
let plotFrame = geometry[plotFrameAnchor]
let x = value.location.x - plotFrame.origin.x
guard let date: Date = proxy.value(atX: x) else { return }
if let closest = data.min(by: {
abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date))
}) {
selectedDataPoint = closest
}
}
.onEnded { _ in
selectedDataPoint = nil
}
)
}
}
.frame(height: 200)
.background(
GeometryReader { geo in
Color.clear
.onAppear { chartWidth = geo.size.width }
.onChange(of: geo.size.width) { _, w in chartWidth = w }
}
)
// Performance: Use GPU rendering for smoother scrolling
.drawingGroup()
}
@ChartContentBuilder
private var chartMarks: some ChartContent {
switch chartMode {
case .total:
ForEach(data, id: \.date) { item in
LineMark(
x: .value("Date", item.date),
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
)
.foregroundStyle(Color.appPrimary)
.interpolationMethod(.catmullRom)
PointMark(
x: .value("Date", item.date),
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
)
.foregroundStyle(Color.appPrimary)
.symbolSize(26)
AreaMark(
x: .value("Date", item.date),
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
)
.foregroundStyle(
LinearGradient(
colors: [Color.appPrimary.opacity(0.3), Color.appPrimary.opacity(0.0)],
startPoint: .top,
endPoint: .bottom
)
)
.interpolationMethod(.catmullRom)
}
case .byCategory:
ForEach(stackedCategoryData) { item in
AreaMark(
x: .value("Date", item.date),
yStart: .value("Start", NSDecimalNumber(decimal: item.start).doubleValue),
yEnd: .value("End", NSDecimalNumber(decimal: item.end).doubleValue)
)
.foregroundStyle(by: .value("Category", item.categoryName))
.interpolationMethod(.catmullRom)
}
}
if showGoalLines {
ForEach(goals) { goal in
RuleMark(y: .value("Goal", NSDecimalNumber(decimal: goal.targetDecimal).doubleValue))
.foregroundStyle(Color.appSecondary.opacity(0.5))
.lineStyle(StrokeStyle(lineWidth: 1, dash: [6, 4]))
.annotation(position: .topTrailing) {
Text(goal.name)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
if let selected = selectedDataPoint, chartMode == .total {
RuleMark(x: .value("Selected", selected.date))
.foregroundStyle(Color.gray.opacity(0.3))
.lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
PointMark(
x: .value("Date", selected.date),
y: .value("Value", NSDecimalNumber(decimal: selected.value).doubleValue)
)
.foregroundStyle(Color.appPrimary)
.symbolSize(100)
}
}
private var chartCategoryNames: [String] {
let names = Array(Set(categoryData.map { $0.categoryName })).sorted()
return names
}
private struct StackedCategoryPoint: Identifiable {
let date: Date
let categoryName: String
let colorHex: String
let start: Decimal
let end: Decimal
var id: String {
"\(categoryName)-\(date.timeIntervalSince1970)"
}
}
private var stackedCategoryData: [StackedCategoryPoint] {
let grouped = Dictionary(grouping: categoryData) { $0.date }
let dates = grouped.keys.sorted()
let categories = chartCategoryNames
var stacked: [StackedCategoryPoint] = []
for date in dates {
let points = grouped[date] ?? []
var running: Decimal = 0
for category in categories {
let value = points.first(where: { $0.categoryName == category })?.value ?? 0
let start = running
let end = running + value
running = end
if let colorHex = points.first(where: { $0.categoryName == category })?.colorHex {
stacked.append(StackedCategoryPoint(
date: date,
categoryName: category,
colorHex: colorHex,
start: start,
end: end
))
}
}
}
return stacked
}
private var chartCategoryColors: [Color] {
chartCategoryNames.map { name in
if let hex = categoryData.first(where: { $0.categoryName == name })?.colorHex {
return Color(hex: hex) ?? .gray
}
return .gray
}
}
}
// MARK: - Mini Sparkline
struct SparklineView: View {
let data: [(date: Date, value: Decimal)]
let color: Color
var body: some View {
if data.count >= 2 {
Chart(data, id: \.date) { item in
LineMark(
x: .value("Date", item.date),
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
)
.foregroundStyle(color)
.interpolationMethod(.catmullRom)
}
.chartXAxis(.hidden)
.chartYAxis(.hidden)
.chartLegend(.hidden)
} else {
Rectangle()
.fill(Color.gray.opacity(0.1))
}
}
}
#Preview {
let sampleData: [(date: Date, value: Decimal)] = [
(Date().adding(months: -6), 10000),
(Date().adding(months: -5), 10500),
(Date().adding(months: -4), 10200),
(Date().adding(months: -3), 11000),
(Date().adding(months: -2), 11500),
(Date().adding(months: -1), 11200),
(Date(), 12000)
]
return EvolutionChartCard(data: sampleData, categoryData: [], goals: [], iapService: IAPService())
.padding()
}