197bddcd7e
CRÍTICO — enlaces del App Store rotos (adquisición viral = 0): - GoalShareService.appStoreURL apuntaba a id6744983373 (404): el QR de TODAS las imágenes compartidas llevaba a una página muerta. Corregido a id6757678318. - SettingsView enlazaba id6741412965 (una novela romántica). Ahora usa la constante. Monetización: - IAPService multi-producto: sub anual (com.portfoliojournal.premium.annual, con intro offer/trial) + lifetime como ancla. isPremium acepta cualquiera de los dos. Paywall con selector de plan (anual por defecto si existe; degrada a solo lifetime mientras el producto no esté creado en App Store Connect). - paywallBenefits: añadidos Multiple Accounts y Family Sharing (diferenciadores). - Teaser de historia bloqueada en Charts: "N meses más con Premium" en vez de truncar en silencio (FreemiumValidator.hiddenHistoryMonths + banner). - Trigger de paywall de backups ahora instrumentado (logPaywallShown "backups"). Retención / adquisición: - Rating velocity: prompt tras 2 check-ins y 30 días (antes 3 + 90 — en una app mensual eso era >3 meses sin poder pedir la primera review con ~0 ratings). - Sample data ON por defecto en onboarding (skip ya no aterriza en dashboard vacío). - Notificación de protección de streak el día 25 si el check-in del mes está pendiente. - App Intents + AppShortcutsProvider: "Update my portfolio" / "Check my portfolio" vía Siri/Shortcuts/Spotlight. - Instrumentación: onboarding_step/onboarding_skipped y content_shared (viral loop). - ScreenshotMode (--screenshots) para capturas de marketing automatizadas. ASO: - 6 locales nuevos en metadata (en-GB, en-CA, en-AU, es-MX, fr-CA, pt-PT) reusando traducciones — 6 campos de keywords extra; pt-PT adaptado (reforma). - Categoría secundaria: PRODUCTIVITY. - 17 strings nuevas localizadas en los 7 idiomas. Pendiente manual: crear la sub anual en App Store Connect (grupo de suscripción + intro offer 7 días) con el id exacto com.portfoliojournal.premium.annual. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
320 lines
10 KiB
Swift
320 lines
10 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import SwiftUI
|
|
import LinkPresentation
|
|
|
|
class ShareService {
|
|
static let shared = ShareService()
|
|
|
|
private init() {}
|
|
|
|
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)
|
|
)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func sharePortfolioValue(
|
|
totalValue: String,
|
|
changeText: String,
|
|
changeLabel: String,
|
|
yearChange: String?,
|
|
sinceInceptionChange: String?
|
|
) {
|
|
FirebaseService.shared.logShare(type: "portfolio_value")
|
|
let appName = Self.appDisplayName
|
|
let text = Self.buildPortfolioValueShareText(
|
|
totalValue: totalValue,
|
|
changeText: changeText,
|
|
changeLabel: changeLabel,
|
|
yearChange: yearChange,
|
|
sinceInceptionChange: sinceInceptionChange,
|
|
appName: appName
|
|
)
|
|
|
|
shareCard(
|
|
cardTitle: "Portfolio Snapshot",
|
|
fallbackText: text
|
|
) {
|
|
PortfolioValueShareCardView(
|
|
totalValue: totalValue,
|
|
changeText: changeText,
|
|
changeLabel: changeLabel,
|
|
yearChange: yearChange,
|
|
sinceInceptionChange: sinceInceptionChange,
|
|
appName: appName,
|
|
qrCodeImage: GoalShareService.generateQRCode(for: GoalShareService.appStoreURL, size: 200)
|
|
)
|
|
}
|
|
}
|
|
|
|
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,
|
|
@ViewBuilder card: () -> Content
|
|
) {
|
|
guard let viewController = ShareService.topViewController() else { return }
|
|
let shareURL = GoalShareService.appStoreURL
|
|
|
|
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
|
|
}
|
|
}
|