Files
InvestmentTrackerApp/PortfolioJournal/Services/ChartShareService.swift
T
alexandrev-tibco 0e0aec6bb9 Charts: fix selección iPad/charts bloqueadas, filtro Rolling 12M, KPIs específicos y botón compartir
Bugs:
- Charts bloqueadas (intermitente) y tiles que no responden: el observer Combine
  registraba el estado como actualizado ANTES de llamar a updateChartData, cuyo
  guard de reentrada descartaba la llamada en silencio — reintentarlo era no-op
  permanente. Ahora el bookkeeping vive dentro de updateChartData, las llamadas
  reentrantes se colean en vez de descartarse, y selectChart computa en síncrono
  (pulsar un tile es determinista y re-pulsar actúa de retry).
- Paywall sheet: movida del Group que cambia con el size class a cada layout —
  en NavigationSplitView no llegaba a presentarse (tiles premium 'no hacían nada').
- Rolling 12M: el filtro de periodo no filtraba — el cálculo necesita histórico
  completo para el lookback, pero ahora la salida respeta selectedTimeRange.

KPIs:
- El header de iPad muestra KPIs específicos del chart activo (los mismos que su
  stats row) en vez de las 5 métricas de cartera fijas; las stats rows dentro de
  las cards se ocultan en regular width para no duplicar (YoY opta por quedarse
  al no tener equivalente en el header).

Compartir:
- Botón de compartir por chart (toolbar iPad + navbar iPhone): renderiza la
  gráfica en una card con branding (BrandMark, KPIs, tagline, QR al App Store
  con ct=chart_share) vía ImageRenderer y abre el share sheet con imagen + link.
- drawingGroup() se desactiva durante el export (ImageRenderer no rasteriza
  capas Metal — salían en blanco). Strings nuevas en 7 idiomas.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-08 16:32:42 +02:00

187 lines
6.5 KiB
Swift

import Foundation
import SwiftUI
import UIKit
// MARK: - Environment flag for image export
//
// ImageRenderer can't rasterize Metal-backed `.drawingGroup()` layers (they come
// out blank). Chart views check this flag and skip drawingGroup during export.
private struct ChartImageExportKey: EnvironmentKey {
static let defaultValue = false
}
extension EnvironmentValues {
var chartImageExport: Bool {
get { self[ChartImageExportKey.self] }
set { self[ChartImageExportKey.self] = newValue }
}
}
extension View {
/// GPU-rasterize for scroll performance, except while exporting a share
/// image (ImageRenderer can't rasterize Metal layers they come out blank).
@ViewBuilder
func chartDrawingGroup(disabledForExport isExporting: Bool) -> some View {
if isExporting {
self
} else {
drawingGroup()
}
}
}
// MARK: - Chart share service
//
// Renders the current chart into a branded card (app mark, KPIs, QR to the App
// Store) and presents the system share sheet. Reuses the QR/App Store plumbing
// from GoalShareService.
@MainActor
final class ChartShareService {
static let shared = ChartShareService()
private init() {}
static let appStoreShareURL = URL(string: "https://apps.apple.com/app/portfolio-journal-tracker/id6757678318?ct=chart_share")!
func share<Content: View>(title: String, subtitle: String, stats: [ChartStat], @ViewBuilder chart: () -> Content) {
FirebaseService.shared.logShare(type: "chart")
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let viewController = windowScene.windows.first?.rootViewController else {
return
}
let card = ChartShareCardView(
title: title,
subtitle: subtitle,
stats: stats,
qrCodeImage: GoalShareService.generateQRCode(for: Self.appStoreShareURL, size: 200),
chart: chart()
)
let renderer = ImageRenderer(content: card)
renderer.scale = 3
renderer.proposedSize = ProposedViewSize(width: 720, height: nil)
let shareText = String(format: String(localized: "chart_share_text"), title)
+ "\n" + Self.appStoreShareURL.absoluteString
var items: [Any] = []
if let image = renderer.uiImage {
items.append(image)
}
items.append(shareText)
let activityVC = UIActivityViewController(activityItems: items, applicationActivities: nil)
activityVC.excludedActivityTypes = [.addToReadingList, .assignToContact, .openInIBooks]
// iPad: activity sheet requires a popover anchor.
if let popover = activityVC.popoverPresentationController {
popover.sourceView = viewController.view
popover.sourceRect = CGRect(
x: viewController.view.bounds.midX,
y: viewController.view.bounds.midY,
width: 0,
height: 0
)
popover.permittedArrowDirections = []
}
var presenter = viewController
while let presented = presenter.presentedViewController {
presenter = presented
}
presenter.present(activityVC, animated: true)
}
}
// MARK: - Branded card
struct ChartShareCardView<Content: View>: View {
let title: String
let subtitle: String
let stats: [ChartStat]
let qrCodeImage: UIImage?
let chart: Content
private static var dateLabel: String {
Date().formatted(date: .abbreviated, time: .omitted)
}
var body: some View {
VStack(alignment: .leading, spacing: 18) {
// Header: brand + chart title
HStack(spacing: 12) {
if let brandMark = UIImage(named: "BrandMark") {
Image(uiImage: brandMark)
.resizable()
.scaledToFit()
.frame(width: 44, height: 44)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
VStack(alignment: .leading, spacing: 2) {
Text("Portfolio Journal")
.font(.headline)
Text("\(title) · \(Self.dateLabel)")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
}
chart
.environment(\.chartImageExport, true)
if !stats.isEmpty {
HStack(spacing: 10) {
ForEach(stats.indices, id: \.self) { i in
VStack(alignment: .leading, spacing: 3) {
Text(stats[i].label)
.font(.caption2)
.foregroundColor(.secondary)
Text(stats[i].value)
.font(.subheadline.weight(.semibold))
.foregroundColor(stats[i].color)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color(.systemGray6))
.cornerRadius(8)
}
Spacer()
}
}
Divider()
// Footer: tagline + QR to the App Store
HStack(alignment: .center, spacing: 16) {
VStack(alignment: .leading, spacing: 4) {
Text(String(localized: "chart_share_tagline"))
.font(.footnote.weight(.medium))
Text(verbatim: "portfoliojournal.app")
.font(.footnote)
.foregroundColor(.appPrimary)
}
Spacer()
if let qrCodeImage {
VStack(spacing: 4) {
Image(uiImage: qrCodeImage)
.resizable()
.interpolation(.none)
.scaledToFit()
.frame(width: 64, height: 64)
Text(String(localized: "chart_share_scan"))
.font(.caption2)
.foregroundColor(.secondary)
}
}
}
}
.padding(24)
.frame(width: 720)
.background(Color(.systemBackground))
.environment(\.colorScheme, .light)
}
}