0e0aec6bb9
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
187 lines
6.5 KiB
Swift
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)
|
|
}
|
|
}
|