Files
InvestmentTrackerApp/PortfolioJournal/ViewModels/SettingsViewModel.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

555 lines
18 KiB
Swift

import Foundation
import Combine
import CoreData
import UIKit
@MainActor
class SettingsViewModel: ObservableObject {
// MARK: - Published Properties
@Published var isPremium = false
@Published var isFamilyShared = false
@Published var notificationsEnabled = false
@Published var defaultNotificationTime = Date()
@Published var analyticsEnabled = true
@Published var currencyCode = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
@Published var inputMode: InputMode = .simple
@Published var isLoading = false
@Published var showingPaywall = false
@Published var showingExportOptions = false
@Published var showingImportSheet = false
@Published var showingResetConfirmation = false
@Published var isBackupInProgress = false
@Published var isRestoreInProgress = false
@Published var errorMessage: String?
@Published var successMessage: String?
// MARK: - Statistics
@Published var totalSources = 0
@Published var totalSnapshots = 0
@Published var totalCategories = 0
// MARK: - Backups
@Published var backupRetentionCount = 10
@Published var backups: [BackupRecord] = []
@Published var backupsEnabled = false
// MARK: - Dependencies
private let iapService: IAPService
private let notificationService: NotificationService
private let sourceRepository: InvestmentSourceRepository
private let categoryRepository: CategoryRepository
private let freemiumValidator: FreemiumValidator
private var cancellables = Set<AnyCancellable>()
private let backupsEnabledKey = "backupsEnabled"
// MARK: - Initialization
init(
iapService: IAPService,
notificationService: NotificationService? = nil,
sourceRepository: InvestmentSourceRepository? = nil,
categoryRepository: CategoryRepository? = nil
) {
self.iapService = iapService
self.notificationService = notificationService ?? .shared
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository()
self.categoryRepository = categoryRepository ?? CategoryRepository()
self.freemiumValidator = FreemiumValidator(iapService: iapService)
setupObservers()
loadSettings()
}
// MARK: - Setup
private func setupObservers() {
iapService.$isPremium
.receive(on: DispatchQueue.main)
.sink { [weak self] isPremium in
self?.handlePremiumChange(isPremium)
}
.store(in: &cancellables)
iapService.$isFamilyShared
.receive(on: DispatchQueue.main)
.assign(to: &$isFamilyShared)
notificationService.$isAuthorized
.receive(on: DispatchQueue.main)
.assign(to: &$notificationsEnabled)
}
// MARK: - Data Loading
func loadSettings() {
let context = CoreDataStack.shared.viewContext
let settings = AppSettings.getOrCreate(in: context)
defaultNotificationTime = settings.defaultNotificationTime ?? Date()
analyticsEnabled = settings.enableAnalytics
currencyCode = settings.currency
inputMode = InputMode(rawValue: settings.inputMode) ?? .simple
backupRetentionCount = loadBackupRetention()
backupsEnabled = loadBackupsEnabled()
if backupsEnabled {
refreshBackups()
} else {
backups = []
}
// Load statistics directly from database to avoid async race conditions
loadStatistics()
FirebaseService.shared.logScreenView(screenName: "Settings")
}
private func loadStatistics() {
let context = CoreDataStack.shared.viewContext
// Fetch source count directly
let sourceRequest: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
totalSources = (try? context.count(for: sourceRequest)) ?? 0
// Fetch category count directly
let categoryRequest: NSFetchRequest<Category> = Category.fetchRequest()
totalCategories = (try? context.count(for: categoryRequest)) ?? 0
// Fetch snapshot count directly
let snapshotRequest: NSFetchRequest<Snapshot> = Snapshot.fetchRequest()
totalSnapshots = (try? context.count(for: snapshotRequest)) ?? 0
}
// MARK: - Premium Actions
func upgradeToPremium() {
showingPaywall = true
FirebaseService.shared.logPaywallShown(trigger: "settings_upgrade")
}
func restorePurchases() async {
isLoading = true
await iapService.restorePurchases()
isLoading = false
if isPremium {
successMessage = "Purchases restored successfully!"
FirebaseService.shared.logRestorePurchases(success: true)
} else {
errorMessage = "No purchases to restore"
FirebaseService.shared.logRestorePurchases(success: false)
}
}
// MARK: - Notification Settings
func requestNotificationPermission() async {
let granted = await notificationService.requestAuthorization()
if !granted {
errorMessage = "Please enable notifications in Settings"
}
}
func updateNotificationTime(_ time: Date) {
defaultNotificationTime = time
let context = CoreDataStack.shared.viewContext
let settings = AppSettings.getOrCreate(in: context)
settings.defaultNotificationTime = time
CoreDataStack.shared.save()
// Reschedule all notifications with new time
let sources = sourceRepository.fetchActiveSources()
notificationService.scheduleAllReminders(for: sources)
}
func openSystemSettings() {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
// MARK: - Export
@Published var isExporting = false
@Published var exportProgress: Double = 0
@Published var exportStatus = ""
@Published var shareItem: ShareItem?
struct ShareItem: Identifiable {
let id = UUID()
let url: URL
}
func exportData(format: ExportService.ExportFormat) {
guard freemiumValidator.canExport() else {
showingPaywall = true
FirebaseService.shared.logPaywallShown(trigger: "export")
return
}
isExporting = true
exportProgress = 0
exportStatus = "Preparing export..."
Task {
// Fetch data directly from database
let context = CoreDataStack.shared.viewContext
let sources = fetchAllSources(in: context)
let categories = fetchAllCategories(in: context)
await MainActor.run {
exportProgress = 0.3
exportStatus = "Generating \(format.rawValue) file..."
}
let content: String
let fileName: String
switch format {
case .csv:
content = ExportService.shared.exportToCSV(sources: sources, categories: categories)
fileName = "investment_tracker_export.csv"
case .json:
content = ExportService.shared.exportToJSON(sources: sources, categories: categories)
fileName = "investment_tracker_export.json"
}
await MainActor.run {
exportProgress = 0.7
exportStatus = "Saving file..."
}
// Create temporary file
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent(fileName)
do {
try content.write(to: tempURL, atomically: true, encoding: .utf8)
await MainActor.run {
exportProgress = 1.0
exportStatus = "Export complete"
isExporting = false
showingExportOptions = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
self?.shareItem = ShareItem(url: tempURL)
}
FirebaseService.shared.logExportAttempt(format: format.rawValue, success: true)
}
} catch {
await MainActor.run {
isExporting = false
errorMessage = "Export failed: \(error.localizedDescription)"
FirebaseService.shared.logExportAttempt(format: format.rawValue, success: false)
}
}
}
}
private func fetchAllSources(in context: NSManagedObjectContext) -> [InvestmentSource] {
let request: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(keyPath: \InvestmentSource.name, ascending: true)]
return (try? context.fetch(request)) ?? []
}
private func fetchAllCategories(in context: NSManagedObjectContext) -> [Category] {
let request: NSFetchRequest<Category> = Category.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(keyPath: \Category.name, ascending: true)]
return (try? context.fetch(request)) ?? []
}
// Share sheet is presented in SettingsView using shareItem.
var canExport: Bool {
freemiumValidator.canExport()
}
// MARK: - Analytics
func toggleAnalytics(_ enabled: Bool) {
analyticsEnabled = enabled
let context = CoreDataStack.shared.viewContext
let settings = AppSettings.getOrCreate(in: context)
settings.enableAnalytics = enabled
CoreDataStack.shared.save()
// Note: In production, you'd also update Firebase Analytics consent
}
// MARK: - Currency
func updateCurrency(_ code: String) {
currencyCode = code
let context = CoreDataStack.shared.viewContext
let settings = AppSettings.getOrCreate(in: context)
settings.currency = code
CoreDataStack.shared.save()
}
// MARK: - Input Mode
func updateInputMode(_ mode: InputMode) {
inputMode = mode
let context = CoreDataStack.shared.viewContext
let settings = AppSettings.getOrCreate(in: context)
settings.inputMode = mode.rawValue
CoreDataStack.shared.save()
}
// MARK: - Data Management
func resetAllData() {
isLoading = true
errorMessage = nil
let context = CoreDataStack.shared.viewContext
let entityNames = [
"Snapshot",
"InvestmentSource",
"Category",
"Asset",
"Account",
"Goal",
"PredictionCache"
]
context.performAndWait {
var deletedObjectIDs: [NSManagedObjectID] = []
for name in entityNames {
let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: name)
let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
deleteRequest.resultType = .resultTypeObjectIDs
if let result = try? context.execute(deleteRequest) as? NSBatchDeleteResult,
let objectIDs = result.result as? [NSManagedObjectID] {
deletedObjectIDs.append(contentsOf: objectIDs)
}
}
if !deletedObjectIDs.isEmpty {
NSManagedObjectContext.mergeChanges(
fromRemoteContextSave: [NSDeletedObjectsKey: deletedObjectIDs],
into: [context]
)
}
}
context.reset()
context.performAndWait {
// Recreate default categories
let categoryCount = (try? context.count(for: Category.fetchRequest())) ?? 0
if categoryCount == 0 {
Category.createDefaultCategories(in: context)
}
// Recreate default account
let accountCount = (try? context.count(for: Account.fetchRequest())) ?? 0
if accountCount == 0 {
let defaultCurrency = AppSettings.getOrCreate(in: context).currency
let account = Account(context: context)
account.name = Account.defaultAccountName
account.currency = defaultCurrency
account.inputMode = InputMode.simple.rawValue
account.notificationFrequency = NotificationFrequency.monthly.rawValue
account.customFrequencyMonths = 1
account.sortOrder = 0
}
let settings = AppSettings.getOrCreate(in: context)
settings.selectedAccountId = nil
settings.showAllAccounts = true
try? context.save()
}
// Clear notifications + monthly check-ins
notificationService.cancelAllReminders()
MonthlyCheckInStore.clearAll()
CoreDataStack.shared.refreshWidgetData()
loadSettings()
isLoading = false
successMessage = "All data has been reset"
NotificationCenter.default.post(name: .didResetData, object: nil)
}
// MARK: - Backups
func updateBackupRetention(_ count: Int) {
guard backupsEnabled, isPremium else { return }
backupRetentionCount = count
UserDefaults.standard.set(count, forKey: "backupRetentionCount")
refreshBackups()
}
func refreshBackups() {
guard backupsEnabled, isPremium else { return }
let includeICloud = UserDefaults.standard.bool(forKey: "cloudSyncEnabled")
backups = BackupService.shared.listAllBackups(includeICloud: includeICloud)
}
func createBackupNow() {
guard backupsEnabled, isPremium else { return }
guard !isBackupInProgress else { return }
isBackupInProgress = true
errorMessage = nil
Task {
let includeICloud = UserDefaults.standard.bool(forKey: "cloudSyncEnabled")
let records = BackupService.shared.createBackup(
retentionCount: backupRetentionCount,
includeICloud: includeICloud
)
await MainActor.run {
backups = records
isBackupInProgress = false
successMessage = "Backup saved"
}
}
}
func restoreBackup(_ backup: BackupRecord) {
guard backupsEnabled, isPremium else { return }
guard !isRestoreInProgress else { return }
isRestoreInProgress = true
errorMessage = nil
Task {
let content: String
do {
content = try String(contentsOf: backup.url, encoding: .utf8)
} catch {
await MainActor.run {
isRestoreInProgress = false
errorMessage = "Failed to read backup file."
}
return
}
await MainActor.run {
resetAllData()
}
let allowMultiple = iapService.isPremium
let result = await ImportService.shared.importDataAsync(
content: content,
format: .json,
allowMultipleAccounts: allowMultiple,
defaultAccountName: Account.defaultAccountName,
progress: { _ in }
)
await MainActor.run {
isRestoreInProgress = false
if result.errors.isEmpty {
successMessage = "Backup restored"
} else {
errorMessage = "Backup restored with warnings."
}
loadSettings()
refreshBackups()
}
}
}
// MARK: - Computed Properties
var appVersion: String {
"\(AppConstants.appVersion) (\(AppConstants.buildNumber))"
}
var premiumStatusText: String {
if isPremium {
return isFamilyShared ? "Premium (Family)" : "Premium"
}
return "Free"
}
var sourceLimitText: String {
if isPremium {
return "Unlimited"
}
return "\(totalSources)/\(FreemiumLimits.maxSources)"
}
var historyLimitText: String {
if isPremium {
return "Full history"
}
return "Last \(FreemiumLimits.maxHistoricalMonths) months"
}
var storageUsedText: String {
let bytes = calculateStorageUsed()
let formatter = ByteCountFormatter()
formatter.countStyle = .file
return formatter.string(fromByteCount: Int64(bytes))
}
private func calculateStorageUsed() -> Int {
guard let storeURL = CoreDataStack.sharedStoreURL else { return 0 }
do {
let attributes = try FileManager.default.attributesOfItem(atPath: storeURL.path)
return attributes[.size] as? Int ?? 0
} catch {
return 0
}
}
private func loadBackupRetention() -> Int {
let value = UserDefaults.standard.integer(forKey: "backupRetentionCount")
let options = [5, 10, 20]
return options.contains(value) ? value : 10
}
private func loadBackupsEnabled() -> Bool {
let enabled = UserDefaults.standard.bool(forKey: backupsEnabledKey)
if !isPremium && enabled {
UserDefaults.standard.set(false, forKey: backupsEnabledKey)
return false
}
return isPremium && enabled
}
private func handlePremiumChange(_ isPremium: Bool) {
self.isPremium = isPremium
if isPremium {
backupsEnabled = UserDefaults.standard.bool(forKey: backupsEnabledKey)
if backupsEnabled {
refreshBackups()
}
} else {
if backupsEnabled {
backupsEnabled = false
}
UserDefaults.standard.set(false, forKey: backupsEnabledKey)
backups = []
}
}
func setBackupsEnabled(_ enabled: Bool) {
guard isPremium else {
backupsEnabled = false
UserDefaults.standard.set(false, forKey: backupsEnabledKey)
FirebaseService.shared.logPaywallShown(trigger: "backups")
showingPaywall = true
return
}
backupsEnabled = enabled
UserDefaults.standard.set(enabled, forKey: backupsEnabledKey)
if enabled {
refreshBackups()
} else {
backups = []
}
}
}