7d2f605c16
"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
568 lines
22 KiB
Swift
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()
|
|
}
|