Files
InvestmentTrackerApp/PortfolioJournal/Services/ShareService.swift
T
alexandrev-tibco 3b1f62cc26 Share cards compartibles: brand header grande, fecha, Dark/Light (build 83) #35
- Header de marca arriba, grande (logo BrandMark 48px + nombre title2 rounded),
  nunca se corta en Story (9:16 real 360x640, Post 4:5 360x450).
- Héroe = retorno % (privacy/branding-friendly), valor como apoyo.
- Fecha del último check-in bajo el nombre (share_as_of) para dar contexto a
  quien no conoce la app; DashboardViewModel.shareAsOfDate.
- Tema Dark + Light (ShareCardTheme) con selector en el sheet + preview vivo.
- Sparkline real (viewModel.evolutionData), ya se pasaba.
- CTA de descarga + QR con ct=snapshot_share (atribución App Analytics).
- L10n x7 (as_of, theme_dark/light, cta, hero, metrics).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L38583J7AYWCVPevkivscj
2026-08-07 09:45:10 +02:00

443 lines
15 KiB
Swift

import Foundation
import UIKit
import SwiftUI
import LinkPresentation
class ShareService {
static let shared = ShareService()
/// Attributed App Store link for shared cards (ct= shows up in App
/// Analytics as a campaign, so referral installs become measurable).
static let snapshotShareURL = URL(string: "https://apps.apple.com/app/portfolio-journal-tracker/id6757678318?ct=snapshot_share")!
private init() {}
/// Extracts the "(±X.XX%)" part of a combined "±1.234 (±X.XX%)" string.
static func percentPart(_ s: String?) -> String? {
guard let s else { return nil }
guard let open = s.lastIndex(of: "("), let close = s.lastIndex(of: ")"), open < close else {
return s
}
return String(s[s.index(after: open)..<close])
}
struct PortfolioCardStrings {
/// The big number (an all-time/return %) the hero of the card.
let heroPercent: String?
/// Supporting absolute value (empty in percent-only mode).
let totalValue: String
let changeText: String
let yearChange: String?
let sinceInceptionChange: String?
}
/// The all-time return is always the hero (branding + shareable). With
/// `percentOnly` every absolute amount is dropped nobody posts their net
/// worth in euros; everybody posts +42%.
static func portfolioCardStrings(
totalValue: String,
changeText: String,
yearChange: String?,
sinceInceptionChange: String?,
percentOnly: Bool
) -> PortfolioCardStrings {
let hero = percentPart(sinceInceptionChange) ?? percentPart(changeText)
if percentOnly {
return PortfolioCardStrings(
heroPercent: hero ?? totalValue,
totalValue: "",
changeText: percentPart(changeText) ?? changeText,
yearChange: percentPart(yearChange),
sinceInceptionChange: percentPart(sinceInceptionChange)
)
}
return PortfolioCardStrings(
heroPercent: hero,
totalValue: totalValue,
changeText: changeText,
yearChange: yearChange,
sinceInceptionChange: sinceInceptionChange
)
}
static func buildMonthlyCheckInShareText(summary: MonthlySummary, appName: String) -> String {
"""
\(summary.periodLabel) Check-in
Starting: \(summary.formattedStartingValue)
Ending: \(summary.formattedEndingValue)
Contributions: \(summary.formattedContributions)
Net performance: \(summary.formattedNetPerformance) (\(summary.formattedNetPerformancePercentage))
Shared from \(appName)
"""
}
static func buildPortfolioValueShareText(
totalValue: String,
changeText: String,
changeLabel: String,
yearChange: String?,
sinceInceptionChange: String?,
appName: String
) -> String {
var lines = [
"Total Portfolio Value",
totalValue,
"\(changeText) \(changeLabel)"
]
if let yearChange {
lines.append("YoY: \(yearChange)")
}
if let sinceInceptionChange {
lines.append("Since inception: \(sinceInceptionChange)")
}
lines.append("")
lines.append("Shared from \(appName)")
return lines.joined(separator: "\n")
}
@MainActor
func shareMonthlyCheckIn(summary: MonthlySummary, appName: String) {
FirebaseService.shared.logShare(type: "monthly_checkin")
let text = Self.buildMonthlyCheckInShareText(summary: summary, appName: appName)
shareCard(
cardTitle: "\(summary.periodLabel) Check-in",
fallbackText: text
) {
MonthlyCheckInShareCardView(
summary: summary,
appName: appName,
qrCodeImage: GoalShareService.generateQRCode(for: GoalShareService.appStoreURL, size: 200)
)
}
}
struct MonthlySummaryShareData {
let monthLabel: String
let totalValue: String
let changeAmount: String
let changePercentage: String
let isPositive: Bool
let streak: Int
let mood: MonthlyCheckInMood?
let rating: Int?
}
@MainActor
func shareMonthlySummary(_ data: MonthlySummaryShareData) {
FirebaseService.shared.logShare(type: "monthly_summary")
let appName = Self.appDisplayName
var lines = [
"\(data.monthLabel) — Monthly Summary",
"Portfolio Value: \(data.totalValue)",
"\(data.changeAmount) (\(data.changePercentage)) since last check-in"
]
if data.streak >= 2 {
lines.append("Streak: \(data.streak) months")
}
if let mood = data.mood {
lines.append("Mood: \(mood.title)")
}
lines.append("")
lines.append("Tracked with \(appName)")
let fallbackText = lines.joined(separator: "\n")
shareCard(
cardTitle: "\(data.monthLabel) — Monthly Summary",
fallbackText: fallbackText
) {
MonthlySummaryShareView(
monthLabel: data.monthLabel,
totalValue: data.totalValue,
changeAmount: data.changeAmount,
changePercentage: data.changePercentage,
isPositive: data.isPositive,
streak: data.streak,
mood: data.mood,
rating: data.rating,
appName: appName,
qrCodeImage: GoalShareService.generateQRCode(for: GoalShareService.appStoreURL, size: 200)
)
}
}
@MainActor
func sharePortfolioValue(
totalValue: String,
changeText: String,
changeLabel: String,
yearChange: String?,
sinceInceptionChange: String?,
sparkline: [Double] = [],
isPositive: Bool = true,
asOf: String? = nil,
theme: ShareCardTheme = .dark,
percentOnly: Bool = false,
storyFormat: Bool = false
) {
FirebaseService.shared.logShare(type: percentOnly ? "portfolio_value_percent" : "portfolio_value")
let appName = Self.appDisplayName
let display = Self.portfolioCardStrings(
totalValue: totalValue,
changeText: changeText,
yearChange: yearChange,
sinceInceptionChange: sinceInceptionChange,
percentOnly: percentOnly
)
// Fallback text honors the privacy choice too.
let text = Self.buildPortfolioValueShareText(
totalValue: display.totalValue,
changeText: display.changeText,
changeLabel: changeLabel,
yearChange: display.yearChange,
sinceInceptionChange: display.sinceInceptionChange,
appName: appName
)
shareCard(
cardTitle: "Portfolio Snapshot",
fallbackText: text,
shareURL: Self.snapshotShareURL
) {
PortfolioValueShareCardView(
totalValue: display.totalValue,
changeText: display.changeText,
changeLabel: changeLabel,
yearChange: display.yearChange,
sinceInceptionChange: display.sinceInceptionChange,
appName: appName,
qrCodeImage: GoalShareService.generateQRCode(for: Self.snapshotShareURL, size: 200),
sparkline: sparkline,
isPositive: isPositive,
heroPercent: display.heroPercent,
asOf: asOf,
theme: theme,
storyFormat: storyFormat
)
}
}
func shareText(_ content: String) {
guard let viewController = ShareService.topViewController() else { return }
let activityVC = UIActivityViewController(
activityItems: [content],
applicationActivities: nil
)
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
)
}
DispatchQueue.main.async {
viewController.present(activityVC, animated: true)
}
}
@MainActor
private func shareCard<Content: View>(
cardTitle: String,
fallbackText: String,
shareURL: URL = GoalShareService.appStoreURL,
@ViewBuilder card: () -> Content
) {
guard let viewController = ShareService.topViewController() else { return }
if #available(iOS 16.0, *) {
let renderer = ImageRenderer(content: card())
let scale = viewController.view.window?.windowScene?.screen.scale
?? viewController.traitCollection.displayScale
renderer.scale = scale
if let image = renderer.uiImage {
let item = CardShareItem(
image: image,
title: cardTitle,
text: fallbackText,
url: shareURL
)
presentShareSheet(items: [item], from: viewController)
return
}
}
presentShareSheet(items: [fallbackText], from: viewController)
}
func shareTextFile(content: String, fileName: String) {
guard let viewController = ShareService.topViewController() else { return }
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent(fileName)
do {
try content.write(to: tempURL, atomically: true, encoding: .utf8)
let activityVC = UIActivityViewController(
activityItems: [tempURL],
applicationActivities: nil
)
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
)
}
DispatchQueue.main.async {
viewController.present(activityVC, animated: true)
}
} catch {
print("Share file error: \(error)")
}
}
func shareCalendarEvent(
title: String,
notes: String,
startDate: Date,
durationMinutes: Int = 60
) {
let endDate = startDate.addingTimeInterval(TimeInterval(durationMinutes * 60))
let icsContent = calendarICS(
title: title,
notes: notes,
startDate: startDate,
endDate: endDate
)
shareTextFile(content: icsContent, fileName: "PortfolioJournal-CheckIn.ics")
}
private static func topViewController(
base: UIViewController? = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap { $0.windows }
.first(where: { $0.isKeyWindow })?.rootViewController
) -> UIViewController? {
if let nav = base as? UINavigationController {
return topViewController(base: nav.visibleViewController)
}
if let tab = base as? UITabBarController {
return topViewController(base: tab.selectedViewController)
}
if let presented = base?.presentedViewController {
return topViewController(base: presented)
}
return base
}
private func calendarICS(
title: String,
notes: String,
startDate: Date,
endDate: Date
) -> String {
let uid = UUID().uuidString
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'"
formatter.timeZone = TimeZone(secondsFromGMT: 0)
let stamp = formatter.string(from: Date())
let start = formatter.string(from: startDate)
let end = formatter.string(from: endDate)
return """
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//PortfolioJournal//MonthlyCheckIn//EN
BEGIN:VEVENT
UID:\(uid)
DTSTAMP:\(stamp)
DTSTART:\(start)
DTEND:\(end)
SUMMARY:\(escapeICS(title))
DESCRIPTION:\(escapeICS(notes))
END:VEVENT
END:VCALENDAR
"""
}
private func escapeICS(_ value: String) -> String {
value
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\n", with: "\\n")
.replacingOccurrences(of: ";", with: "\\;")
.replacingOccurrences(of: ",", with: "\\,")
}
private func presentShareSheet(items: [Any], from viewController: UIViewController) {
let activityVC = UIActivityViewController(activityItems: items, applicationActivities: nil)
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 = []
}
viewController.present(activityVC, animated: true)
}
private static var appDisplayName: String {
if let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String {
return name
}
if let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String {
return name
}
return "Portfolio Journal"
}
}
private class CardShareItem: NSObject, UIActivityItemSource {
let image: UIImage
let title: String
let text: String
let url: URL
init(image: UIImage, title: String, text: String, url: URL) {
self.image = image
self.title = title
self.text = text
self.url = url
}
func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any {
image
}
func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
image
}
func activityViewController(_ activityViewController: UIActivityViewController, subjectForActivityType activityType: UIActivity.ActivityType?) -> String {
title
}
func activityViewControllerLinkMetadata(_ activityViewController: UIActivityViewController) -> LPLinkMetadata? {
let metadata = LPLinkMetadata()
metadata.title = title
metadata.originalURL = url
metadata.url = url
metadata.imageProvider = NSItemProvider(object: image)
if let appIcon = UIImage(named: "BrandMark") {
metadata.iconProvider = NSItemProvider(object: appIcon)
}
return metadata
}
}