diff --git a/App/Data Sources/ForumListDataSource.swift b/App/Data Sources/ForumListDataSource.swift index b15a794d7..9a995300d 100644 --- a/App/Data Sources/ForumListDataSource.swift +++ b/App/Data Sources/ForumListDataSource.swift @@ -74,6 +74,21 @@ final class ForumListDataSource: NSObject { announcementsController.delegate = self favoriteForumsController.delegate = self forumsController.delegate = self + + NotificationCenter.default.addObserver(self, selector: #selector(dataStoreDidReset), name: .dataStoreDidReset, object: nil) + } + + @objc private func dataStoreDidReset() { + // Old store's objects are no longer reachable from the coordinator. Re-fetch so + // the FRCs' caches stop pointing at dangling objectIDs. + for controller in resultsControllers { + do { + try controller.performFetch() + } catch { + logger.error("Failed to re-fetch after data store reset: \(error)") + } + } + tableView.reloadData() } private var resultsControllers: [NSFetchedResultsController] { diff --git a/App/Data Sources/MessageListDataSource.swift b/App/Data Sources/MessageListDataSource.swift index 6603d8a3a..4ae06a964 100644 --- a/App/Data Sources/MessageListDataSource.swift +++ b/App/Data Sources/MessageListDataSource.swift @@ -36,6 +36,19 @@ final class MessageListDataSource: NSObject { tableView.register(MessageListCell.self, forCellReuseIdentifier: cellReuseIdentifier) resultsController.delegate = self + + NotificationCenter.default.addObserver(self, selector: #selector(dataStoreDidReset), name: .dataStoreDidReset, object: nil) + } + + @objc private func dataStoreDidReset() { + // Old store's objects are no longer reachable from the coordinator. Re-fetch so + // the FRC's cache stops pointing at dangling objectIDs. + do { + try resultsController.performFetch() + } catch { + Log.error("Failed to re-fetch after data store reset: \(error)") + } + tableView.reloadData() } func message(at indexPath: IndexPath) -> PrivateMessage { diff --git a/App/Data Sources/ThreadListDataSource.swift b/App/Data Sources/ThreadListDataSource.swift index ba8bf21aa..53c0fb960 100644 --- a/App/Data Sources/ThreadListDataSource.swift +++ b/App/Data Sources/ThreadListDataSource.swift @@ -104,6 +104,19 @@ final class ThreadListDataSource: NSObject { tableView.register(ThreadListCell.self, forCellReuseIdentifier: threadCellIdentifier) resultsController.delegate = self + + NotificationCenter.default.addObserver(self, selector: #selector(dataStoreDidReset), name: .dataStoreDidReset, object: nil) + } + + @objc private func dataStoreDidReset() { + // The old store's objects are no longer reachable from the coordinator. Re-fetch + // against the fresh store so the FRC's cache stops pointing at dangling objectIDs. + do { + try resultsController.performFetch() + } catch { + Log.error("Failed to re-fetch after data store reset: \(error)") + } + tableView.reloadData() } func indexPath(of thread: AwfulThread) -> IndexPath? { diff --git a/App/Main/AppDelegate.swift b/App/Main/AppDelegate.swift index 0e47e71dd..d4a3dd4df 100644 --- a/App/Main/AppDelegate.swift +++ b/App/Main/AppDelegate.swift @@ -140,13 +140,12 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { } func logOut() { - // Logging out doubles as an "empty cache" button. let cookieJar = HTTPCookieStorage.shared for cookie in cookieJar.cookies ?? [] { cookieJar.deleteCookie(cookie) } - UserDefaults.standard.removeAllObjectsInMainBundleDomain() - emptyCache() + UserDefaults.standard.removeSessionObjects() + Task { await emptyCache() } let loginVC = LoginViewController.newFromStoryboard() loginVC.completionBlock = { [weak self] (login) in @@ -161,14 +160,77 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { setRootViewController(loginVC.enclosingNavigationController, animated: true) { [weak self] in self?._rootViewControllerStack = nil self?.urlRouter = nil - - self?.dataStore.deleteStoreAndReset() } } - func emptyCache() { + func emptyCache() async { URLCache.shared.removeAllCachedResponses() - ImageCache.shared.removeAll() + ImagePipeline.shared.cache.removeAll() + + // Clear WKWebView data (cookies, cache, localStorage, etc.) + let webDataStore = WKWebsiteDataStore.default() + let dataTypes = WKWebsiteDataStore.allWebsiteDataTypes() + await webDataStore.removeData(ofTypes: dataTypes, modifiedSince: .distantPast) + + // Clear external stylesheet cache + PostsViewExternalStylesheetLoader.shared.clearCache() + } + + /// Clears all caches *and* deletes the Core Data store (cached forums, threads, posts). + /// Used by the Settings "Clear Cache" button; logout intentionally does not call this. + func emptyCacheAndResetStore() async { + await emptyCache() + + // ForumsClient's background MOC retains objects tied to the old persistent store, + // so cycle its MOC around the reset — otherwise a subsequent save-notification + // merge tries to reach the deleted store and crashes. + ForumsClient.shared.managedObjectContext = nil + dataStore.deleteStoreAndReset() + ForumsClient.shared.managedObjectContext = managedObjectContext + } + + func calculateCacheSize() async -> Int64 { + let storeDirectoryURL = dataStore.storeDirectoryURL + return await Task.detached(priority: .utility) { + var totalSize: Int64 = 0 + let fileManager = FileManager.default + + // Caches directory (includes URLCache, Nuke disk cache, external stylesheet, etc.) + if let cachesURL = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first { + totalSize += Self.directorySize(at: cachesURL) + } + + // WKWebView data is typically stored in Library/WebKit + if let libraryURL = fileManager.urls(for: .libraryDirectory, in: .userDomainMask).first { + let webKitDir = libraryURL.appendingPathComponent("WebKit", isDirectory: true) + totalSize += Self.directorySize(at: webKitDir) + } + + // Core Data store (cached forums, threads, posts) + totalSize += Self.directorySize(at: storeDirectoryURL) + + return totalSize + }.value + } + + nonisolated static func directorySize(at url: URL) -> Int64 { + let fileManager = FileManager.default + guard let enumerator = fileManager.enumerator( + at: url, + includingPropertiesForKeys: [.fileSizeKey, .isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { return 0 } + + var size: Int64 = 0 + for case let fileURL as URL in enumerator { + guard + let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .isDirectoryKey]), + values.isDirectory != true, + let fileSize = values.fileSize + else { continue } + size += Int64(fileSize) + } + return size } func open(route: AwfulRoute) { diff --git a/App/Main/RootViewControllerStack.swift b/App/Main/RootViewControllerStack.swift index a2ff9b4da..d998dc0f0 100644 --- a/App/Main/RootViewControllerStack.swift +++ b/App/Main/RootViewControllerStack.swift @@ -2,6 +2,7 @@ // // Copyright 2014 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app +import AwfulCore import AwfulSettings import AwfulTheming import Combine @@ -70,6 +71,24 @@ final class RootViewControllerStack: NSObject, AwfulSplitViewControllerDelegate .store(in: &cancellables) configureSplitViewControllerDisplayMode() + + NotificationCenter.default.addObserver(self, selector: #selector(dataStoreDidReset), name: .dataStoreDidReset, object: nil) + } + + /// Pushed detail view controllers (PostsPage, PrivateMessageView, Profile, etc.) and + /// any presented modals hold references to managed objects from the now-deleted store. + /// Unwind the UI to tab roots so rendering/interaction can't fault those dead objects. + @objc private func dataStoreDidReset() { + rootViewController.dismiss(animated: false) + + for tab in tabBarController.viewControllers ?? [] { + (tab as? UINavigationController)?.popToRootViewController(animated: false) + } + + if splitViewController.viewControllers.count > 1, + let detailNav = splitViewController.viewControllers[1] as? UINavigationController { + detailNav.popToRootViewController(animated: false) + } } private func createEmptyDetailNavigationController() -> UINavigationController { diff --git a/App/Posts/PostsViewExternalStylesheetLoader.swift b/App/Posts/PostsViewExternalStylesheetLoader.swift index bab455467..4bddfed92 100644 --- a/App/Posts/PostsViewExternalStylesheetLoader.swift +++ b/App/Posts/PostsViewExternalStylesheetLoader.swift @@ -165,6 +165,16 @@ private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: } } + func clearCache() { + let fileManager = FileManager.default + if let contents = try? fileManager.contentsOfDirectory(at: cacheFolder, includingPropertiesForKeys: nil) { + for url in contents { + try? fileManager.removeItem(at: url) + } + } + stylesheet = nil + } + private func reloadCachedStylesheet() { do { stylesheet = try String(contentsOf: cachedStylesheetURL) diff --git a/App/Settings/SettingsViewController.swift b/App/Settings/SettingsViewController.swift index 6e01a9794..264c04432 100644 --- a/App/Settings/SettingsViewController.swift +++ b/App/Settings/SettingsViewController.swift @@ -4,14 +4,13 @@ import AwfulCore import AwfulSettings import AwfulSettingsUI import AwfulTheming +import Combine import CoreData -import os import SwiftUI -private let Log = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "SettingsViewController") - final class SettingsViewController: HostingController { let managedObjectContext: NSManagedObjectContext + private var cacheSizeText: CurrentValueSubject! init(managedObjectContext: NSManagedObjectContext) { self.managedObjectContext = managedObjectContext @@ -28,9 +27,11 @@ final class SettingsViewController: HostingController { unowned var contents: SettingsViewController! } let box = UnownedBox() + let cacheSizeText = CurrentValueSubject("Calculating…") super.init(rootView: SettingsContainerView( appIconDataSource: makeAppIconDataSource(), + cacheSizeText: cacheSizeText, currentUser: currentUser, emptyCache: { box.contents.emptyCache() }, goToAwfulThread: { box.contents.goToAwfulThread() }, @@ -39,8 +40,10 @@ final class SettingsViewController: HostingController { isMac: ProcessInfo.processInfo.isMacCatalystApp, isPad: UIDevice.current.userInterfaceIdiom == .pad, logOut: { AppDelegate.instance.logOut() }, - managedObjectContext: managedObjectContext + managedObjectContext: managedObjectContext, + resetSettings: { box.contents.resetSettings() } )) + self.cacheSizeText = cacheSizeText box.contents = self title = String(localized: "Settings", bundle: .module) @@ -48,24 +51,92 @@ final class SettingsViewController: HostingController { tabBarItem.selectedImage = UIImage(named: "cog-filled") themeDidChange() + + NotificationCenter.default.addObserver(self, selector: #selector(dataStoreDidReset), name: .dataStoreDidReset, object: nil) } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + @objc private func dataStoreDidReset() { + // The current User object is tied to the old store. Re-fetch (findOrCreate) so the + // Settings header doesn't crash when it tries to fault the invalidated object. + let newUser = managedObjectContext.performAndWait { + User.objectForKey(objectKey: UserKey( + userID: UserDefaults.standard.value(for: Settings.userID)!, + username: UserDefaults.standard.value(for: Settings.username) + ), in: managedObjectContext) + } + rootView = SettingsContainerView( + appIconDataSource: rootView.appIconDataSource, + cacheSizeText: rootView.cacheSizeText, + currentUser: newUser, + emptyCache: rootView.emptyCache, + goToAwfulThread: rootView.goToAwfulThread, + hasRegularSizeClassInLandscape: rootView.hasRegularSizeClassInLandscape, + isMac: rootView.isMac, + isPad: rootView.isPad, + logOut: rootView.logOut, + managedObjectContext: rootView.managedObjectContext, + resetSettings: rootView.resetSettings + ) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + refreshCacheSize() + } + + private func refreshCacheSize() { + Task { + let size = await AppDelegate.instance.calculateCacheSize() + cacheSizeText.send(Self.formatByteCount(size)) + } + } + func emptyCache() { - let usageBefore = Measurement(value: Double(URLCache.shared.currentDiskUsage), unit: UnitInformationStorage.bytes) - AppDelegate.instance.emptyCache() - let usageAfter = Measurement(value: Double(URLCache.shared.currentDiskUsage), unit: UnitInformationStorage.bytes) - let delta = (usageBefore - usageAfter).converted(to: .megabytes) - let message = "You cleared up \(delta.formatted(.measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(1)))))! Great job, go hog wild!!" - let alertController = UIAlertController(title: "Cache Cleared", message: message, preferredStyle: .alert) - let okAction = UIAlertAction(title: "OK", style: .default) { action in - self.dismiss(animated: true) + Task { + let sizeBefore = await AppDelegate.instance.calculateCacheSize() + await AppDelegate.instance.emptyCacheAndResetStore() + let sizeAfter = await AppDelegate.instance.calculateCacheSize() + + let delta = max(sizeBefore - sizeAfter, 0) + let message = "You cleared \(Self.formatByteCount(delta))! Some system-managed files can't be removed, so a small amount of cache usage is normal." + let alertController = UIAlertController(title: "Cache Cleared", message: message, preferredStyle: .alert) + alertController.addAction(UIAlertAction(title: "OK", style: .default) { _ in + self.dismiss(animated: true) + }) + self.present(alertController, animated: true) + + refreshCacheSize() } - alertController.addAction(okAction) - self.present(alertController, animated: true) + } + + private static func formatByteCount(_ bytes: Int64) -> String { + let measurement = Measurement(value: Double(bytes), unit: UnitInformationStorage.bytes) + if bytes < 1_000_000 { + return measurement.converted(to: .kilobytes).formatted( + .measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0))) + ) + } else { + return measurement.converted(to: .megabytes).formatted( + .measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(1))) + ) + } + } + + func resetSettings() { + let alert = UIAlertController( + title: "Reset All Settings?", + message: "This will restore all preferences to their defaults. You will remain logged in.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + alert.addAction(UIAlertAction(title: "Reset", style: .destructive) { _ in + UserDefaults.standard.resetPreferences() + }) + present(alert, animated: true) } func goToAwfulThread() { @@ -125,6 +196,7 @@ private let appIcons: [AppIconDataSource.AppIcon] = [ /// Wrapper for observing the current `User`. struct SettingsContainerView: View { let appIconDataSource: AppIconDataSource + let cacheSizeText: CurrentValueSubject @ObservedObject var currentUser: User let emptyCache: () -> Void let goToAwfulThread: () -> Void @@ -133,11 +205,15 @@ struct SettingsContainerView: View { let isPad: Bool let logOut: () -> Void let managedObjectContext: NSManagedObjectContext + let resetSettings: () -> Void + + @State private var displayedCacheSize: String = "Calculating…" var body: some View { SettingsView( appIconDataSource: appIconDataSource, avatarURL: currentUser.avatarURL, + cacheSizeText: displayedCacheSize, canOpenURL: UIApplication.shared.canOpenURL(_:), currentUsername: currentUser.username ?? "", emptyCache: emptyCache, @@ -145,10 +221,14 @@ struct SettingsContainerView: View { hasRegularSizeClassInLandscape: hasRegularSizeClassInLandscape, isMac: isMac, isPad: isPad, - logOut: logOut + logOut: logOut, + resetSettings: resetSettings ) .environment(\.managedObjectContext, managedObjectContext) .themed() + .onReceive(cacheSizeText) { newValue in + displayedCacheSize = newValue + } } } diff --git a/AwfulCore/Sources/AwfulCore/Data/DataStore.swift b/AwfulCore/Sources/AwfulCore/Data/DataStore.swift index 137edb93d..12b85e6a3 100644 --- a/AwfulCore/Sources/AwfulCore/Data/DataStore.swift +++ b/AwfulCore/Sources/AwfulCore/Data/DataStore.swift @@ -13,7 +13,7 @@ private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: public final class DataStore: NSObject { /// A directory in which the store is saved. Since stores can span multiple files, a directory is required. - let storeDirectoryURL: URL + public let storeDirectoryURL: URL /// A main-queue-concurrency-type context that is automatically saved when the application enters the background. public let mainManagedObjectContext: NSManagedObjectContext @@ -165,7 +165,7 @@ public final class DataStore: NSObject { invalidatePruneTimer() operationQueue.cancelAllOperations() - NotificationCenter.default.post(name: NSNotification.Name(rawValue: DataStoreWillResetNotification), object: self) + NotificationCenter.default.post(name: .dataStoreWillReset, object: self) mainManagedObjectContext.reset() if let persistentStore = persistentStore { @@ -188,7 +188,7 @@ public final class DataStore: NSObject { loadPersistentStore() - NotificationCenter.default.post(name: NSNotification.Name(rawValue: DataStoreDidResetNotification), object: self) + NotificationCenter.default.post(name: .dataStoreDidReset, object: self) } } @@ -223,8 +223,13 @@ public final class LastModifiedContextObserver: NSObject { } } -/// Posted when a data store (the notification's object) is about to delete its persistent data. Please relinquish any references you may have to managed objects originating from the data store, as they are now invalid. -let DataStoreWillResetNotification = "Data store will be deleted" +public extension Notification.Name { + /// Posted when a data store (the notification's object) is about to delete its persistent + /// data. Observers must relinquish any references to managed objects originating from the + /// store, as they are now invalid. + static let dataStoreWillReset = Notification.Name("DataStoreWillReset") -/// Posted once a data store (the notification's object) has deleted its persistent data. Managed objects can once again be inserted into, fetched from, and updated in the data store. -let DataStoreDidResetNotification = "Data store did reset" + /// Posted once a data store (the notification's object) has deleted its persistent data. + /// Managed objects can once again be inserted into, fetched from, and updated in the store. + static let dataStoreDidReset = Notification.Name("DataStoreDidReset") +} diff --git a/AwfulSettings/Sources/AwfulSettings/UserDefaults+Settings.swift b/AwfulSettings/Sources/AwfulSettings/UserDefaults+Settings.swift index 79e9dc844..a0087cf2e 100644 --- a/AwfulSettings/Sources/AwfulSettings/UserDefaults+Settings.swift +++ b/AwfulSettings/Sources/AwfulSettings/UserDefaults+Settings.swift @@ -21,9 +21,30 @@ public extension UserDefaults { // MARK: Mass deletion +private let sessionKeys: Set = [ + Settings.userID.key, + Settings.username.key, + Settings.canSendPrivateMessages.key, + Settings.lastOfferedPasteboardURLString.key, + Settings.imgurUploadMode.key, +] + public extension UserDefaults { - func removeAllObjectsInMainBundleDomain() { - guard let bundleID = Bundle.main.bundleIdentifier else { return } - setPersistentDomain([:], forName: bundleID) + /// Removes only session/auth-related keys, preserving user preferences. + func removeSessionObjects() { + for key in sessionKeys { + removeObject(forKey: key) + } + } + + /// Removes all preference keys (everything except session/auth keys), + /// restoring them to their defaults. The user stays logged in. + /// + /// Keys are removed individually rather than via `setPersistentDomain` + /// so that KVO fires for each key and `@AppStorage` updates immediately. + func resetPreferences() { + for key in dictionaryRepresentation().keys where !sessionKeys.contains(key) { + removeObject(forKey: key) + } } } diff --git a/AwfulSettingsUI/Sources/AwfulSettingsUI/ForumSpecificThemesViewController.swift b/AwfulSettingsUI/Sources/AwfulSettingsUI/ForumSpecificThemesViewController.swift index a40c98dfe..7ba1bc60c 100644 --- a/AwfulSettingsUI/Sources/AwfulSettingsUI/ForumSpecificThemesViewController.swift +++ b/AwfulSettingsUI/Sources/AwfulSettingsUI/ForumSpecificThemesViewController.swift @@ -42,12 +42,18 @@ final class ForumSpecificThemesViewController: TableViewController { try! resultsController.performFetch() NotificationCenter.default.addObserver(self, selector: #selector(forumSpecificThemeDidChange), name: Theme.themeForForumDidChangeNotification, object: Theme.self) + NotificationCenter.default.addObserver(self, selector: #selector(dataStoreDidReset), name: .dataStoreDidReset, object: nil) } @objc private func forumSpecificThemeDidChange(_ notification: Notification) { tableView.reloadData() } + @objc private func dataStoreDidReset() { + try? resultsController.performFetch() + tableView.reloadData() + } + // MARK: - UITableViewDataSource and UITableViewDelegate override func numberOfSections(in tableView: UITableView) -> Int { diff --git a/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings b/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings index 67d9aeae0..980113cdc 100644 --- a/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings +++ b/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings @@ -66,9 +66,18 @@ }, "Checking the clipboard for a forums URL when you open the app allows you to jump straight to a copied URL in Awful." : { + }, + "Clear Cache" : { + + }, + "Clearing the cache removes downloaded images, web data, and cached forums, threads, and posts. Resetting settings restores all preferences to their defaults." : { + }, "Dark Mode" : { + }, + "Data Management" : { + }, "Default Browser" : { @@ -90,9 +99,6 @@ }, "Embed Tweets" : { - }, - "Empty Cache" : { - }, "Enable Custom Title Post Layout" : { @@ -132,9 +138,6 @@ }, "Log Out" : { - }, - "Logging out erases all cached forums, threads, and posts." : { - }, "New Smilie Picker" : { @@ -159,6 +162,9 @@ }, "Pull for Next Page" : { + }, + "Reset All Settings" : { + }, "Scale Text %@%%" : { diff --git a/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift b/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift index 091725bad..193b3e339 100644 --- a/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift +++ b/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift @@ -41,6 +41,7 @@ public struct SettingsView: View { @ObservedObject var appIconDataSource: AppIconDataSource let avatarURL: URL? let buildInfo = BuildInfo() + let cacheSizeText: String let canOpenURL: (URL) -> Bool let currentUsername: String let emptyCache: () -> Void @@ -49,6 +50,7 @@ public struct SettingsView: View { let isMac: Bool let isPad: Bool let logOut: () -> Void + let resetSettings: () -> Void @Environment(\.managedObjectContext) var managedObjectContext @Environment(\.theme) var theme @@ -80,6 +82,7 @@ public struct SettingsView: View { public init( appIconDataSource: AppIconDataSource, avatarURL: URL?, + cacheSizeText: String, canOpenURL: @escaping (URL) -> Bool, currentUsername: String, emptyCache: @escaping () -> Void, @@ -87,10 +90,12 @@ public struct SettingsView: View { hasRegularSizeClassInLandscape: Bool, isMac: Bool, isPad: Bool, - logOut: @escaping () -> Void + logOut: @escaping () -> Void, + resetSettings: @escaping () -> Void ) { self.appIconDataSource = appIconDataSource self.avatarURL = avatarURL + self.cacheSizeText = cacheSizeText self.canOpenURL = canOpenURL self.currentUsername = currentUsername self.emptyCache = emptyCache @@ -99,13 +104,13 @@ public struct SettingsView: View { self.isMac = isMac self.isPad = isPad self.logOut = logOut + self.resetSettings = resetSettings } public var body: some View { Form { Section { Button("Log Out", bundle: .module) { logOut() } - Button("Empty Cache", bundle: .module) { emptyCache() } } header: { VStack(alignment: .leading) { Group { @@ -124,9 +129,6 @@ public struct SettingsView: View { Text(currentUsername) } .header() - } footer: { - Text("Logging out erases all cached forums, threads, and posts.", bundle: .module) - .footer() } .section() @@ -302,6 +304,25 @@ public struct SettingsView: View { } .section() + Section { + Button { emptyCache() } label: { + HStack { + Text("Clear Cache", bundle: .module) + Spacer() + Text(cacheSizeText) + .foregroundStyle(theme[color: "listSecondaryText"]!) + } + } + Button("Reset All Settings", bundle: .module) { resetSettings() } + } header: { + Text("Data Management", bundle: .module) + .header() + } footer: { + Text("Clearing the cache removes downloaded images, web data, and cached forums, threads, and posts. Resetting settings restores all preferences to their defaults.", bundle: .module) + .footer() + } + .section() + Section { NavigationLink("Acknowledgements", bundle: .module) { AcknowledgementsView() @@ -359,6 +380,7 @@ private struct SectionModifier: ViewModifier { SettingsView( appIconDataSource: .preview, avatarURL: nil, + cacheSizeText: "42.3 MB", canOpenURL: { _ in true }, currentUsername: "Random Newbie", emptyCache: { print("emptying cache") }, @@ -366,7 +388,8 @@ private struct SectionModifier: ViewModifier { hasRegularSizeClassInLandscape: true, isMac: false, isPad: true, - logOut: { print("logging out") } + logOut: { print("logging out") }, + resetSettings: { print("resetting settings") } ) .navigationTitle(Text(verbatim: "Settings")) .navigationBarTitleDisplayMode(.inline)