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
194 lines
7.2 KiB
Swift
194 lines
7.2 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
import UIKit
|
|
import CoreImage.CIFilterBuiltins
|
|
|
|
class GoalShareService {
|
|
static let shared = GoalShareService()
|
|
|
|
/// App Store URL - auto-redirects to user's country
|
|
/// NOTE: 6757678318 is the real ADAM id (verified via App Store Connect API).
|
|
/// The previous id (6744983373) returned a 404, breaking the share QR loop.
|
|
static let appStoreURL = URL(string: "https://apps.apple.com/app/portfolio-journal-tracker/id6757678318")!
|
|
|
|
/// Website URL - has Open Graph tags for social media previews
|
|
static let websiteURL = URL(string: "https://portfoliojournal.app")!
|
|
|
|
/// Share page base URL - parameters will be appended
|
|
static let sharePageBaseURL = "https://portfoliojournal.app/share"
|
|
|
|
private init() {}
|
|
|
|
/// Build share URL with goal parameters for dynamic OG tags
|
|
static func buildShareURL(goalName: String, progressPercent: Int) -> URL {
|
|
var components = URLComponents(string: sharePageBaseURL)!
|
|
components.queryItems = [
|
|
URLQueryItem(name: "goal", value: goalName),
|
|
URLQueryItem(name: "progress", value: String(progressPercent))
|
|
]
|
|
return components.url ?? URL(string: sharePageBaseURL)!
|
|
}
|
|
|
|
/// Generate a QR code image for the App Store URL
|
|
static func generateQRCode(for url: URL, size: CGFloat = 60) -> UIImage? {
|
|
let context = CIContext()
|
|
let filter = CIFilter.qrCodeGenerator()
|
|
|
|
filter.message = Data(url.absoluteString.utf8)
|
|
filter.correctionLevel = "M"
|
|
|
|
guard let outputImage = filter.outputImage else { return nil }
|
|
|
|
// Scale up the QR code
|
|
let scale = size / outputImage.extent.size.width
|
|
let scaledImage = outputImage.transformed(by: CGAffineTransform(scaleX: scale, y: scale))
|
|
|
|
guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else {
|
|
return nil
|
|
}
|
|
|
|
return UIImage(cgImage: cgImage)
|
|
}
|
|
|
|
@MainActor
|
|
func shareGoal(
|
|
name: String,
|
|
progress: Double,
|
|
currentValue: Decimal,
|
|
targetValue: Decimal,
|
|
targetDate: Date? = nil,
|
|
estimatedCompletionDate: Date? = nil,
|
|
privacyMode: Bool = false
|
|
) {
|
|
FirebaseService.shared.logShare(type: "goal")
|
|
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
|
let viewController = windowScene.windows.first?.rootViewController else {
|
|
return
|
|
}
|
|
|
|
// Generate QR code for the card
|
|
let qrCodeImage = Self.generateQRCode(for: Self.appStoreURL, size: 200)
|
|
|
|
let card = GoalShareCardView(
|
|
name: name,
|
|
progress: progress,
|
|
currentValue: currentValue,
|
|
targetValue: targetValue,
|
|
targetDate: targetDate,
|
|
estimatedCompletionDate: estimatedCompletionDate,
|
|
privacyMode: privacyMode,
|
|
qrCodeImage: qrCodeImage
|
|
)
|
|
|
|
let progressPercent = Int(progress * 100)
|
|
let shareURL = Self.buildShareURL(goalName: name, progressPercent: progressPercent)
|
|
|
|
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 {
|
|
// Use combined item provider for text + image together
|
|
let shareItem = GoalShareItem(
|
|
image: image,
|
|
goalName: name,
|
|
progressPercent: progressPercent,
|
|
shareURL: shareURL
|
|
)
|
|
presentShareSheet(items: [shareItem], from: viewController)
|
|
} else {
|
|
// Fallback to text only
|
|
let text = buildShareText(goalName: name, progressPercent: progressPercent, shareURL: shareURL)
|
|
presentShareSheet(items: [text], from: viewController)
|
|
}
|
|
} else {
|
|
// iOS 15 fallback - text only
|
|
let text = buildShareText(goalName: name, progressPercent: progressPercent, shareURL: shareURL)
|
|
presentShareSheet(items: [text], from: viewController)
|
|
}
|
|
}
|
|
|
|
private func buildShareText(goalName: String, progressPercent: Int, shareURL: URL) -> String {
|
|
return """
|
|
I'm \(progressPercent)% towards my "\(goalName)" goal! 🎯
|
|
|
|
Track your investment goals with Portfolio Journal.
|
|
\(shareURL.absoluteString)
|
|
"""
|
|
}
|
|
|
|
private func presentShareSheet(items: [Any], from viewController: UIViewController) {
|
|
let activityVC = UIActivityViewController(activityItems: items, applicationActivities: nil)
|
|
|
|
// Exclude some activities that don't make sense for goal sharing
|
|
activityVC.excludedActivityTypes = [
|
|
.addToReadingList,
|
|
.assignToContact,
|
|
.openInIBooks
|
|
]
|
|
|
|
// iPad support - prevent crash by setting popover source
|
|
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)
|
|
}
|
|
}
|
|
|
|
/// Combined share item that provides both image and text together
|
|
private class GoalShareItem: NSObject, UIActivityItemSource {
|
|
let image: UIImage
|
|
let goalName: String
|
|
let progressPercent: Int
|
|
let shareURL: URL
|
|
|
|
init(image: UIImage, goalName: String, progressPercent: Int, shareURL: URL) {
|
|
self.image = image
|
|
self.goalName = goalName
|
|
self.progressPercent = progressPercent
|
|
self.shareURL = shareURL
|
|
super.init()
|
|
}
|
|
|
|
func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any {
|
|
return image
|
|
}
|
|
|
|
func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
|
|
// For most activities, return the image
|
|
// The text will be provided via LPLinkMetadata or as a separate item
|
|
return image
|
|
}
|
|
|
|
func activityViewController(_ activityViewController: UIActivityViewController, subjectForActivityType activityType: UIActivity.ActivityType?) -> String {
|
|
return "My Investment Goal Progress - Portfolio Journal"
|
|
}
|
|
|
|
func activityViewControllerLinkMetadata(_ activityViewController: UIActivityViewController) -> LPLinkMetadata? {
|
|
let metadata = LPLinkMetadata()
|
|
metadata.title = "I'm \(progressPercent)% towards my \"\(goalName)\" goal! 🎯"
|
|
metadata.originalURL = shareURL
|
|
metadata.url = shareURL
|
|
metadata.imageProvider = NSItemProvider(object: image)
|
|
|
|
// Set icon
|
|
if let appIcon = UIImage(named: "BrandMark") {
|
|
metadata.iconProvider = NSItemProvider(object: appIcon)
|
|
}
|
|
|
|
return metadata
|
|
}
|
|
}
|
|
|
|
import LinkPresentation
|