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() 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.fetchRequest() totalSources = (try? context.count(for: sourceRequest)) ?? 0 // Fetch category count directly let categoryRequest: NSFetchRequest = Category.fetchRequest() totalCategories = (try? context.count(for: categoryRequest)) ?? 0 // Fetch snapshot count directly let snapshotRequest: NSFetchRequest = 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.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.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(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 = [] } } }