diff --git a/PortfolioJournal/App/PortfolioJournalApp.swift b/PortfolioJournal/App/PortfolioJournalApp.swift index 72c040e..1d9ee9a 100644 --- a/PortfolioJournal/App/PortfolioJournalApp.swift +++ b/PortfolioJournal/App/PortfolioJournalApp.swift @@ -34,6 +34,16 @@ struct PortfolioJournalApp: App { .onChange(of: scenePhase) { _, newPhase in if newPhase == .active { coreDataStack.refreshWidgetData() + } else if newPhase == .background { + guard iapService.isPremium else { return } + guard UserDefaults.standard.bool(forKey: "backupsEnabled") else { return } + let retention = UserDefaults.standard.integer(forKey: "backupRetentionCount") + let keepCount = [5, 10, 20].contains(retention) ? retention : 10 + let includeICloud = UserDefaults.standard.bool(forKey: "cloudSyncEnabled") + _ = BackupService.shared.createBackup( + retentionCount: keepCount, + includeICloud: includeICloud + ) } } } diff --git a/PortfolioJournal/Services/BackupService.swift b/PortfolioJournal/Services/BackupService.swift new file mode 100644 index 0000000..469bb7a --- /dev/null +++ b/PortfolioJournal/Services/BackupService.swift @@ -0,0 +1,151 @@ +import Foundation +import CoreData + +enum BackupLocation: String { + case local = "On device" + case iCloud = "iCloud" +} + +struct BackupRecord: Identifiable { + let id: String + let url: URL + let date: Date + let size: Int64 + let location: BackupLocation +} + +class BackupService { + static let shared = BackupService() + + private let fileManager = FileManager.default + private let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd-HHmmss" + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter + }() + + private init() {} + + func createBackup(retentionCount: Int, includeICloud: Bool) -> [BackupRecord] { + let timestamp = dateFormatter.string(from: Date()) + let fileName = "backup-\(timestamp).json" + + let context = CoreDataStack.shared.viewContext + let sources = fetchAllSources(in: context) + let categories = fetchAllCategories(in: context) + let content = ExportService.shared.exportToJSON(sources: sources, categories: categories) + + var records: [BackupRecord] = [] + + if let localDir = backupDirectory() { + let localURL = localDir.appendingPathComponent(fileName) + write(content: content, to: localURL) + pruneBackups(in: localDir, keep: retentionCount, location: .local) + records.append(contentsOf: listBackups(in: localDir, location: .local)) + } + + if includeICloud, let iCloudDir = iCloudBackupDirectory() { + let iCloudURL = iCloudDir.appendingPathComponent(fileName) + write(content: content, to: iCloudURL) + pruneBackups(in: iCloudDir, keep: retentionCount, location: .iCloud) + records.append(contentsOf: listBackups(in: iCloudDir, location: .iCloud)) + } + + return records.sorted { $0.date > $1.date } + } + + func listAllBackups(includeICloud: Bool) -> [BackupRecord] { + var records: [BackupRecord] = [] + if let localDir = backupDirectory() { + records.append(contentsOf: listBackups(in: localDir, location: .local)) + } + if includeICloud, let iCloudDir = iCloudBackupDirectory() { + records.append(contentsOf: listBackups(in: iCloudDir, location: .iCloud)) + } + return records.sorted { $0.date > $1.date } + } + + private func backupDirectory() -> URL? { + guard let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + return nil + } + let dir = base.appendingPathComponent("Backups", isDirectory: true) + ensureDirectoryExists(dir) + return dir + } + + private func iCloudBackupDirectory() -> URL? { + guard let base = fileManager.url(forUbiquityContainerIdentifier: nil) else { return nil } + let dir = base.appendingPathComponent("Documents/Backups", isDirectory: true) + ensureDirectoryExists(dir) + return dir + } + + private func ensureDirectoryExists(_ url: URL) { + if !fileManager.fileExists(atPath: url.path) { + try? fileManager.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil) + } + } + + private func write(content: String, to url: URL) { + do { + try content.write(to: url, atomically: true, encoding: .utf8) + } catch { + print("Backup write failed: \(error)") + } + } + + private func listBackups(in directory: URL, location: BackupLocation) -> [BackupRecord] { + guard let files = try? fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey]) else { + return [] + } + + return files.compactMap { url in + guard url.lastPathComponent.hasPrefix("backup-"), + url.pathExtension.lowercased() == "json" else { + return nil + } + + let name = url.deletingPathExtension().lastPathComponent + let date = parseDate(from: name) ?? (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? Date() + let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize).map { Int64($0) } ?? 0 + + return BackupRecord( + id: "\(location.rawValue)-\(name)", + url: url, + date: date, + size: size, + location: location + ) + } + } + + private func pruneBackups(in directory: URL, keep: Int, location: BackupLocation) { + guard keep > 0 else { return } + let backups = listBackups(in: directory, location: location).sorted { $0.date > $1.date } + let toDelete = backups.dropFirst(keep) + for backup in toDelete { + try? fileManager.removeItem(at: backup.url) + } + } + + private func parseDate(from filename: String) -> Date? { + let parts = filename.split(separator: "-") + guard parts.count >= 3 else { return nil } + let dateString = "\(parts[1])-\(parts[2])" + return dateFormatter.date(from: dateString) + } + + 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)) ?? [] + } +} diff --git a/PortfolioJournal/ViewModels/SettingsViewModel.swift b/PortfolioJournal/ViewModels/SettingsViewModel.swift index e79ece1..b646eb0 100644 --- a/PortfolioJournal/ViewModels/SettingsViewModel.swift +++ b/PortfolioJournal/ViewModels/SettingsViewModel.swift @@ -20,6 +20,8 @@ class SettingsViewModel: ObservableObject { @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? @@ -29,6 +31,12 @@ class SettingsViewModel: ObservableObject { @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 @@ -37,6 +45,7 @@ class SettingsViewModel: ObservableObject { private let categoryRepository: CategoryRepository private let freemiumValidator: FreemiumValidator private var cancellables = Set() + private let backupsEnabledKey = "backupsEnabled" // MARK: - Initialization @@ -61,7 +70,10 @@ class SettingsViewModel: ObservableObject { private func setupObservers() { iapService.$isPremium .receive(on: DispatchQueue.main) - .assign(to: &$isPremium) + .sink { [weak self] isPremium in + self?.handlePremiumChange(isPremium) + } + .store(in: &cancellables) iapService.$isFamilyShared .receive(on: DispatchQueue.main) @@ -82,6 +94,13 @@ class SettingsViewModel: ObservableObject { 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() @@ -361,6 +380,85 @@ class SettingsViewModel: ObservableObject { 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 { @@ -405,4 +503,52 @@ class SettingsViewModel: ObservableObject { 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) + showingPaywall = true + return + } + + backupsEnabled = enabled + UserDefaults.standard.set(enabled, forKey: backupsEnabledKey) + if enabled { + refreshBackups() + } else { + backups = [] + } + } } diff --git a/PortfolioJournal/Views/Settings/SettingsView.swift b/PortfolioJournal/Views/Settings/SettingsView.swift index ad97e49..b1c13fb 100644 --- a/PortfolioJournal/Views/Settings/SettingsView.swift +++ b/PortfolioJournal/Views/Settings/SettingsView.swift @@ -20,6 +20,7 @@ struct SettingsView: View { @State private var showingPinDisableAlert = false @State private var showingRestartAlert = false @State private var didLoadCloudSync = false + @State private var backupToRestore: BackupRecord? init(iapService: IAPService) { self.iapService = iapService @@ -41,6 +42,9 @@ struct SettingsView: View { // Data Section dataSection + if viewModel.backupsEnabled { + backupsSection + } // Security Section securitySection @@ -86,6 +90,26 @@ struct SettingsView: View { } message: { Text("This will permanently delete all your investment data. This action cannot be undone.") } + .confirmationDialog( + "Restore Backup", + isPresented: Binding( + get: { backupToRestore != nil }, + set: { if !$0 { backupToRestore = nil } } + ), + titleVisibility: .visible + ) { + Button("Restore", role: .destructive) { + if let backup = backupToRestore { + viewModel.restoreBackup(backup) + backupToRestore = nil + } + } + Button("Cancel", role: .cancel) { + backupToRestore = nil + } + } message: { + Text("This will replace your current data with the selected backup.") + } .alert("Success", isPresented: .constant(viewModel.successMessage != nil)) { Button("OK") { viewModel.successMessage = nil @@ -141,6 +165,9 @@ struct SettingsView: View { } .onAppear { didLoadCloudSync = true + if viewModel.backupsEnabled { + viewModel.refreshBackups() + } } } } @@ -303,8 +330,25 @@ struct SettingsView: View { if didLoadCloudSync { showingRestartAlert = true } + viewModel.refreshBackups() } + Toggle( + isOn: Binding( + get: { viewModel.backupsEnabled }, + set: { viewModel.setBackupsEnabled($0) } + ) + ) { + HStack(spacing: 8) { + Text("Enable Backups") + if !viewModel.isPremium { + Text("Premium") + .font(.caption.weight(.semibold)) + .foregroundColor(.appWarning) + } + } + } + Button { if viewModel.canExport { viewModel.showingExportOptions = true @@ -359,6 +403,66 @@ struct SettingsView: View { } } + // MARK: - Backups Section + + private var backupsSection: some View { + Section { + Picker("Keep Backups", selection: $viewModel.backupRetentionCount) { + Text("5").tag(5) + Text("10").tag(10) + Text("20").tag(20) + } + .onChange(of: viewModel.backupRetentionCount) { _, newValue in + viewModel.updateBackupRetention(newValue) + } + + Button { + viewModel.createBackupNow() + } label: { + HStack { + Label("Create Backup Now", systemImage: "arrow.clockwise") + Spacer() + if viewModel.isBackupInProgress { + ProgressView() + } + } + } + .disabled(viewModel.isBackupInProgress || viewModel.isRestoreInProgress) + + if viewModel.backups.isEmpty { + Text("No backups yet.") + .font(.caption) + .foregroundColor(.secondary) + } else { + ForEach(viewModel.backups) { backup in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(backup.date.formatted(date: .abbreviated, time: .shortened)) + .font(.subheadline) + Text("\(backup.location.rawValue) ยท \(formatBytes(backup.size))") + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + Button("Restore") { + backupToRestore = backup + } + .disabled(viewModel.isRestoreInProgress) + } + .contextMenu { + Button("Share Backup") { + viewModel.shareItem = SettingsViewModel.ShareItem(url: backup.url) + } + } + } + } + } header: { + Text("Backups") + } footer: { + Text("Backups are stored locally and in iCloud (when enabled).") + } + } + // MARK: - Security Section private var securitySection: some View { @@ -589,6 +693,12 @@ struct SettingsView: View { } return "Portfolio Journal" } + + private func formatBytes(_ bytes: Int64) -> String { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + return formatter.string(fromByteCount: bytes) + } } // MARK: - Activity View