3b1f62cc26
- 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
443 lines
15 KiB
Swift
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
|
|
}
|
|
}
|