import SwiftUI class SettingsManager: ObservableObject { // MARK: - Stored Properties @AppStorage("detailViewType") var detailViewQuality: BooruPostFileType = .original @AppStorage("thumbnailQuality") var thumbnailQuality: BooruPostFileType = .preview @AppStorage("searchSuggestionsMode") var searchSuggestionsMode: SettingsSearchSuggestionsMode = .disabled @AppStorage("thumbnailGridColumns") var thumbnailGridColumns = 2 @AppStorage("enableShareShortcut") var enableShareShortcut = false @AppStorage("displayDetailsInformationBar") var displayDetailsInformationBar = true @AppStorage("preloadedCarouselImages") var preloadedCarouselImages = 3 @AppStorage("enableICloudSync") var enableICloudSync: Bool = false private var iCloudSyncObservation: NSObjectProtocol? #if os(macOS) @AppStorage("saveTagsToFile") var saveTagsToFile = false #endif // MARK: - Codable Properties @AppStorage("bookmarks") private var bookmarksData = Data() @AppStorage("displayRatings") private var displayRatingsData = SettingsManager.encode(BooruRating.allCases) ?? Data() @AppStorage("blurRatings") private var blurRatingsData = SettingsManager.encode([.explicit as BooruRating]) ?? Data() @AppStorage("searchHistory") private var searchHistoryData = Data() @AppStorage("preferredBooru") private var preferredBooruData = Data() @AppStorage("customProviders") private var customProvidersData = Data() // MARK: - Computed Properties var bookmarks: [SettingsBookmark] { get { if enableICloudSync { if let data = NSUbiquitousKeyValueStore.default.data(forKey: "bookmarks") { return (Self.decode([SettingsBookmark].self, from: data) ?? []) .sorted { $0.date > $1.date } } return (Self.decode([SettingsBookmark].self, from: bookmarksData) ?? []) .sorted { $0.date > $1.date } } return (Self.decode([SettingsBookmark].self, from: bookmarksData) ?? []) .sorted { $0.date > $1.date } } set { let sortedBookmarks = newValue.sorted { $0.date > $1.date } bookmarksData = Self.encode(sortedBookmarks) ?? Data() if enableICloudSync { NSUbiquitousKeyValueStore.default.set( Self.encode(sortedBookmarks), forKey: "bookmarks" ) } } } var displayRatings: [BooruRating] { get { Self.decode([BooruRating].self, from: displayRatingsData) ?? BooruRating.allCases } set { displayRatingsData = Self.encode(newValue) ?? displayRatingsData } } var blurRatings: [BooruRating] { get { Self.decode([BooruRating].self, from: blurRatingsData) ?? [.explicit] } set { blurRatingsData = Self.encode(newValue) ?? blurRatingsData } } var searchHistory: [BooruSearchQuery] { get { if enableICloudSync { if let data = NSUbiquitousKeyValueStore.default.data(forKey: "searchHistory") { return (Self.decode([BooruSearchQuery].self, from: data) ?? []) .sorted { $0.date > $1.date } } return (Self.decode([BooruSearchQuery].self, from: searchHistoryData) ?? []) .sorted { $0.date > $1.date } } return (Self.decode([BooruSearchQuery].self, from: searchHistoryData) ?? []) .sorted { $0.date > $1.date } } set { let sortedHistory = newValue.sorted { $0.date > $1.date } searchHistoryData = Self.encode(sortedHistory) ?? Data() if enableICloudSync { NSUbiquitousKeyValueStore.default.set( Self.encode(sortedHistory), forKey: "searchHistory" ) } } } var preferredBooru: BooruProvider { get { Self.decode(BooruProvider.self, from: preferredBooruData) ?? .safebooru } set { preferredBooruData = Self.encode(newValue) ?? preferredBooruData } } var customProviders: [BooruProviderCustom] { get { if enableICloudSync { if let data = NSUbiquitousKeyValueStore.default.data(forKey: "customProviders") { return Self.decode([BooruProviderCustom].self, from: data) ?? [] } return Self.decode([BooruProviderCustom].self, from: customProvidersData) ?? [] } return Self.decode([BooruProviderCustom].self, from: customProvidersData) ?? [] } set { customProvidersData = Self.encode(newValue) ?? Data() if enableICloudSync { NSUbiquitousKeyValueStore.default.set( Self.encode(newValue), forKey: "customProviders" ) } } } // MARK: - Initialisation init() { iCloudSyncObservation = NotificationCenter.default.addObserver( forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, object: NSUbiquitousKeyValueStore.default, queue: .main ) { [weak self] _ in guard let localSelf = self, localSelf.enableICloudSync else { return } // swiftlint:disable:this self_binding if let data = NSUbiquitousKeyValueStore.default.data(forKey: "bookmarks") { localSelf.bookmarksData = data } if let data = NSUbiquitousKeyValueStore.default.data(forKey: "searchHistory") { localSelf.searchHistoryData = data } if let data = NSUbiquitousKeyValueStore.default.data(forKey: "customProviders") { localSelf.customProvidersData = data } localSelf.objectWillChange.send() } } // MARK: - Private Helpers private static func encode(_ value: T) -> Data? { try? JSONEncoder().encode(value) } private static func decode(_ type: T.Type, from data: Data) -> T? { try? JSONDecoder().decode(type, from: data) } // MARK: - Public Methods func appendToSearchHistory(_ query: BooruSearchQuery) { self.searchHistory.append(query) } func resetToDefaults() { detailViewQuality = .original thumbnailQuality = .preview searchSuggestionsMode = .disabled thumbnailGridColumns = 2 preferredBooru = .safebooru enableShareShortcut = false displayRatings = BooruRating.allCases blurRatings = [.explicit] displayDetailsInformationBar = true preloadedCarouselImages = 3 #if os(macOS) saveTagsToFile = false #endif } func syncToICloud() { if enableICloudSync { NSUbiquitousKeyValueStore.default.set(bookmarksData, forKey: "bookmarks") NSUbiquitousKeyValueStore.default.set(searchHistoryData, forKey: "searchHistory") NSUbiquitousKeyValueStore.default.set(customProvidersData, forKey: "customProviders") } } // MARK: - Bookmark Management func addBookmark(provider: BooruProvider, tags: [String]) { var updatedBookmarks = bookmarks updatedBookmarks.append( SettingsBookmark(provider: provider, tags: tags.map { $0.lowercased() }) ) if let data = Self.encode(updatedBookmarks), data.count < 1_000_000 { // 1 MB bookmarks = updatedBookmarks } else { debugPrint("SettingsManager.addBookmark: iCloud data limit exceeded") } } func removeBookmark(at offsets: IndexSet) { bookmarks.remove(atOffsets: offsets) } func removeBookmark(withTags tags: [String]) { bookmarks.removeAll { $0.tags.contains(where: tags.contains) } } func removeBookmark(withID id: UUID) { bookmarks.removeAll { $0.id == id } } func exportBookmarks() throws -> Data { try JSONEncoder().encode(bookmarks) } func importBookmarks(from data: Data) throws { let importedBookmarks = try JSONDecoder().decode([SettingsBookmark].self, from: data) let existingIDs = Set(bookmarks.map(\.id)) let newBookmarks = importedBookmarks.filter { !existingIDs.contains($0.id) } bookmarks.append(contentsOf: newBookmarks) } // MARK: - Search History Management func removeSearchHistoryEntry(at offsets: IndexSet) { searchHistory.remove(atOffsets: offsets) } func removeSearchHistoryEntry(withID id: UUID) { searchHistory.removeAll { $0.id == id } } #if DEBUG // https://stackoverflow.com/a/68926484/14452787 private func randomWord() -> String { var word = "" for _ in 0..<5 { word += String(format: "%c", Int.random(in: 97..<123)) as String } return word } func addDummyBookmarks() { for _ in 0..<10 { let randomTags: [String] = Array(repeating: randomWord(), count: Int.random(in: 1...5)) addBookmark(provider: .safebooru, tags: randomTags) } } func addDummySearchHistory() { for _ in 0..<10 { let randomTags: [String] = Array(repeating: randomWord(), count: Int.random(in: 1...5)) appendToSearchHistory( BooruSearchQuery(provider: .safebooru, tags: randomTags) ) } } #endif // MARK: - Deinitialisation deinit { if let observation = iCloudSyncObservation { NotificationCenter.default.removeObserver(observation) } } }