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
227 lines
6.6 KiB
Swift
227 lines
6.6 KiB
Swift
import Foundation
|
|
import FirebaseAnalytics
|
|
import FirebaseCore
|
|
|
|
class FirebaseService {
|
|
static let shared = FirebaseService()
|
|
|
|
private var isConfigured: Bool {
|
|
FirebaseApp.app() != nil
|
|
}
|
|
|
|
private init() {}
|
|
|
|
// MARK: - User Properties
|
|
|
|
func setUserTier(_ tier: UserTier) {
|
|
guard isConfigured else { return }
|
|
Analytics.setUserProperty(tier.rawValue, forName: "user_tier")
|
|
}
|
|
|
|
enum UserTier: String {
|
|
case free = "free"
|
|
case premium = "premium"
|
|
}
|
|
|
|
// MARK: - Screen Tracking
|
|
|
|
func logScreenView(screenName: String, screenClass: String? = nil) {
|
|
guard isConfigured else { return }
|
|
Analytics.logEvent(AnalyticsEventScreenView, parameters: [
|
|
AnalyticsParameterScreenName: screenName,
|
|
AnalyticsParameterScreenClass: screenClass ?? screenName
|
|
])
|
|
}
|
|
|
|
// MARK: - Investment Events
|
|
|
|
func logSourceAdded(categoryName: String, sourceCount: Int) {
|
|
guard isConfigured else { return }
|
|
Analytics.logEvent("source_added", parameters: [
|
|
"category_name": categoryName,
|
|
"total_sources": sourceCount
|
|
])
|
|
}
|
|
|
|
func logSourceDeleted(categoryName: String) {
|
|
guard isConfigured else { return }
|
|
Analytics.logEvent("source_deleted", parameters: [
|
|
"category_name": categoryName
|
|
])
|
|
}
|
|
|
|
func logSnapshotAdded(sourceName: String, value: Decimal) {
|
|
guard isConfigured else { return }
|
|
Analytics.logEvent("snapshot_added", parameters: [
|
|
"source_name": sourceName,
|
|
"value": NSDecimalNumber(decimal: value).doubleValue
|
|
])
|
|
}
|
|
|
|
func logCategoryCreated(name: String) {
|
|
guard isConfigured else { return }
|
|
Analytics.logEvent("category_created", parameters: [
|
|
"category_name": name
|
|
])
|
|
}
|
|
|
|
// MARK: - Purchase Events
|
|
|
|
func logPaywallShown(trigger: String) {
|
|
guard isConfigured else { return }
|
|
Analytics.logEvent("paywall_shown", parameters: [
|
|
"paywall_trigger": trigger
|
|
])
|
|
}
|
|
|
|
func logPurchaseAttempt(productId: String) {
|
|
guard isConfigured else { return }
|
|
Analytics.logEvent("purchase_attempt", parameters: [
|
|
"product_id": productId
|
|
])
|
|
}
|
|
|
|
func logPurchaseSuccess(productId: String, price: Decimal, isFamilyShared: Bool) {
|
|
guard isConfigured else { return }
|
|
Analytics.logEvent(AnalyticsEventPurchase, parameters: [
|
|
AnalyticsParameterItemID: productId,
|
|
AnalyticsParameterPrice: NSDecimalNumber(decimal: price).doubleValue,
|
|
AnalyticsParameterCurrency: "EUR",
|
|
"is_family_shared": isFamilyShared
|
|
])
|
|
}
|
|
|
|
func logPurchaseFailure(productId: String, error: String) {
|
|
guard isConfigured else { return }
|
|
Analytics.logEvent("purchase_failed", parameters: [
|
|
"product_id": productId,
|
|
"error": error
|
|
])
|
|
}
|
|
|
|
func logRestorePurchases(success: Bool) {
|
|
guard isConfigured else { return }
|
|
Analytics.logEvent("restore_purchases", parameters: [
|
|
"success": success
|
|
])
|
|
}
|
|
|
|
// MARK: - Feature Usage Events
|
|
|
|
func logChartViewed(chartType: String, isPremium: Bool) {
|
|
guard isConfigured else { return }
|
|
Analytics.logEvent("chart_viewed", parameters: [
|
|
"chart_type": chartType,
|
|
"is_premium_chart": isPremium
|
|
])
|
|
}
|
|
|
|
func logPredictionViewed(algorithm: String) {
|
|
guard isConfigured else { return }
|
|
Analytics.logEvent("prediction_viewed", parameters: [
|
|
"algorithm": algorithm
|
|
])
|
|
}
|
|
|
|
func logExportAttempt(format: String, success: Bool) {
|
|
guard isConfigured else { return }
|
|
Analytics.logEvent("export_attempt", parameters: [
|
|
"format": format,
|
|
"success": success
|
|
])
|
|
}
|
|
|
|
func logNotificationScheduled(frequency: String) {
|
|
guard isConfigured else { return }
|
|
Analytics.logEvent("notification_scheduled", parameters: [
|
|
"frequency": frequency
|
|
])
|
|
}
|
|
|
|
// MARK: - Ad Events
|
|
|
|
func logAdImpression(adType: String) {
|
|
guard isConfigured else { return }
|
|
Analytics.logEvent("ad_impression", parameters: [
|
|
"ad_type": adType
|
|
])
|
|
}
|
|
|
|
func logAdClick(adType: String) {
|
|
guard isConfigured else { return }
|
|
Analytics.logEvent("ad_click", parameters: [
|
|
"ad_type": adType
|
|
])
|
|
}
|
|
|
|
// MARK: - Engagement Events
|
|
|
|
func logOnboardingCompleted(stepCount: Int) {
|
|
guard isConfigured else { return }
|
|
Analytics.logEvent("onboarding_completed", parameters: [
|
|
"steps_completed": stepCount
|
|
])
|
|
}
|
|
|
|
/// Per-step onboarding funnel event — lets us see exactly where users drop off
|
|
/// inside onboarding (only the final `onboarding_completed` existed before).
|
|
func logOnboardingStep(step: Int) {
|
|
guard isConfigured else { return }
|
|
Analytics.logEvent("onboarding_step", parameters: [
|
|
"step": step
|
|
])
|
|
}
|
|
|
|
func logOnboardingSkipped(atStep step: Int) {
|
|
guard isConfigured else { return }
|
|
Analytics.logEvent("onboarding_skipped", parameters: [
|
|
"step": step
|
|
])
|
|
}
|
|
|
|
/// Viral loop measurement: fired whenever the user opens a share sheet with
|
|
/// app content (portfolio image, check-in, goal). `type` identifies the surface.
|
|
func logShare(type: String) {
|
|
guard isConfigured else { return }
|
|
Analytics.logEvent("content_shared", parameters: [
|
|
"share_type": type
|
|
])
|
|
}
|
|
|
|
func logWidgetUsed(widgetType: String) {
|
|
guard isConfigured else { return }
|
|
Analytics.logEvent("widget_used", parameters: [
|
|
"widget_type": widgetType
|
|
])
|
|
}
|
|
|
|
func logAppOpened(fromWidget: Bool) {
|
|
guard isConfigured else { return }
|
|
Analytics.logEvent("app_opened", parameters: [
|
|
"from_widget": fromWidget
|
|
])
|
|
}
|
|
|
|
// MARK: - Portfolio Events
|
|
|
|
func logPortfolioMilestone(totalValue: Decimal, milestone: String) {
|
|
guard isConfigured else { return }
|
|
Analytics.logEvent("portfolio_milestone", parameters: [
|
|
"total_value": NSDecimalNumber(decimal: totalValue).doubleValue,
|
|
"milestone": milestone
|
|
])
|
|
}
|
|
|
|
// MARK: - Error Events
|
|
|
|
func logError(type: String, message: String, context: String? = nil) {
|
|
guard isConfigured else { return }
|
|
Analytics.logEvent("app_error", parameters: [
|
|
"error_type": type,
|
|
"error_message": message,
|
|
"context": context ?? ""
|
|
])
|
|
}
|
|
}
|
|
|