Files
InvestmentTrackerApp/PortfolioJournal/Services/FirebaseService.swift
T
alexandrev-tibco 197bddcd7e Growth y monetización: fixes críticos, paywall multi-plan, teasers y App Intents
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
2026-07-02 22:12:32 +02:00

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 ?? ""
])
}
}