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
555 lines
18 KiB
Swift
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 = []
|
|
}
|
|
}
|
|
}
|