diff --git a/Mac/AppDefaults.swift b/Mac/AppDefaults.swift index 51985e00d..a0fb1f20c 100644 --- a/Mac/AppDefaults.swift +++ b/Mac/AppDefaults.swift @@ -15,6 +15,20 @@ enum FontSize: Int { case veryLarge = 3 } +enum ArticleThemeSelectionMode: Int, CaseIterable, Sendable { + case single = 0 + case appearance = 1 + + var title: String { + switch self { + case .single: + return NSLocalizedString("Same Theme for All Appearances", comment: "Article theme selection mode") + case .appearance: + return NSLocalizedString("Match Appearance", comment: "Article theme selection mode") + } + } +} + final class AppDefaults: Sendable { static let defaultThemeName = "Default" @@ -42,6 +56,9 @@ final class AppDefaults: Sendable { static let exportOPMLAccountID = "exportOPMLAccountID" static let defaultBrowserID = "defaultBrowserID" static let currentThemeName = "currentThemeName" + static let articleThemeSelectionMode = "articleThemeSelectionMode" + static let lightThemeName = "lightThemeName" + static let darkThemeName = "darkThemeName" static let articleContentJavascriptEnabled = "articleContentJavascriptEnabled" // Hidden prefs @@ -221,6 +238,33 @@ final class AppDefaults: Sendable { } } + var articleThemeSelectionMode: ArticleThemeSelectionMode { + get { + ArticleThemeSelectionMode(rawValue: AppDefaults.int(for: Key.articleThemeSelectionMode)) ?? .single + } + set { + AppDefaults.setInt(for: Key.articleThemeSelectionMode, newValue.rawValue) + } + } + + var lightThemeName: String? { + get { + return AppDefaults.string(for: Key.lightThemeName) + } + set { + AppDefaults.setString(for: Key.lightThemeName, newValue) + } + } + + var darkThemeName: String? { + get { + return AppDefaults.string(for: Key.darkThemeName) + } + set { + AppDefaults.setString(for: Key.darkThemeName, newValue) + } + } + var showTitleOnMainWindow: Bool { return AppDefaults.bool(for: Key.showTitleOnMainWindow) } @@ -344,6 +388,7 @@ final class AppDefaults: Sendable { Key.refreshInterval: RefreshInterval.every2Hours.rawValue, Key.showDebugMenu: showDebugMenu, Key.currentThemeName: Self.defaultThemeName, + Key.articleThemeSelectionMode: ArticleThemeSelectionMode.single.rawValue, Key.articleContentJavascriptEnabled: true ] diff --git a/Mac/AppDelegate.swift b/Mac/AppDelegate.swift index a54c50a06..c05800d0a 100644 --- a/Mac/AppDelegate.swift +++ b/Mac/AppDelegate.swift @@ -245,6 +245,7 @@ let appName = "NetNewsWire" } ArticleThemesManager.shared.start() + ArticleThemesManager.shared.updateCurrentAppearance(NSApplication.shared.effectiveAppearance.isDarkMode ? .dark : .light) NetworkMonitor.shared.start() MemoryPressureMonitor.shared.start() diff --git a/Mac/MainWindow/Detail/DetailWebView.swift b/Mac/MainWindow/Detail/DetailWebView.swift index 7752cd766..128da6261 100644 --- a/Mac/MainWindow/Detail/DetailWebView.swift +++ b/Mac/MainWindow/Detail/DetailWebView.swift @@ -58,6 +58,7 @@ final class DetailWebView: WKWebView { override func viewDidMoveToWindow() { super.viewDidMoveToWindow() updateObscuredContentInsets() + ArticleThemesManager.shared.updateCurrentAppearance(NSApplication.shared.effectiveAppearance.isDarkMode ? .dark : .light) if let window, !isObservingResizeNotifications { NotificationCenter.default.addObserver( @@ -70,6 +71,11 @@ final class DetailWebView: WKWebView { } } + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + ArticleThemesManager.shared.updateCurrentAppearance(NSApplication.shared.effectiveAppearance.isDarkMode ? .dark : .light) + } + @objc func windowDidResize(_ notification: Notification) { updateObscuredContentInsets() } diff --git a/Mac/MainWindow/MainWindowController.swift b/Mac/MainWindow/MainWindowController.swift index 06fed2191..46f806893 100644 --- a/Mac/MainWindow/MainWindowController.swift +++ b/Mac/MainWindow/MainWindowController.swift @@ -565,7 +565,7 @@ final class MainWindowController: NSWindowController, NSUserInterfaceValidations } @objc func selectArticleTheme(_ menuItem: NSMenuItem) { - ArticleThemesManager.shared.currentThemeName = menuItem.title + ArticleThemesManager.shared.setActiveAppearanceThemeName(menuItem.title) } } @@ -1452,7 +1452,7 @@ private extension MainWindowController { let defaultThemeItem = NSMenuItem() defaultThemeItem.title = ArticleTheme.defaultTheme.name defaultThemeItem.action = #selector(selectArticleTheme(_:)) - defaultThemeItem.state = defaultThemeItem.title == ArticleThemesManager.shared.currentThemeName ? .on : .off + defaultThemeItem.state = defaultThemeItem.title == ArticleThemesManager.shared.activeThemeName ? .on : .off articleThemeMenu.addItem(defaultThemeItem) articleThemeMenu.addItem(NSMenuItem.separator()) @@ -1461,7 +1461,7 @@ private extension MainWindowController { let themeItem = NSMenuItem() themeItem.title = themeName themeItem.action = #selector(selectArticleTheme(_:)) - themeItem.state = themeItem.title == ArticleThemesManager.shared.currentThemeName ? .on : .off + themeItem.state = themeItem.title == ArticleThemesManager.shared.activeThemeName ? .on : .off articleThemeMenu.addItem(themeItem) } diff --git a/Mac/Preferences/General/GeneralPrefencesViewController.swift b/Mac/Preferences/General/GeneralPrefencesViewController.swift index d86805515..4de79b01d 100644 --- a/Mac/Preferences/General/GeneralPrefencesViewController.swift +++ b/Mac/Preferences/General/GeneralPrefencesViewController.swift @@ -18,6 +18,11 @@ final class GeneralPreferencesViewController: NSViewController { @IBOutlet var articleThemePopup: NSPopUpButton! @IBOutlet var defaultBrowserPopup: NSPopUpButton! + private enum ArticleThemePopupItem { + case selectionMode(ArticleThemeSelectionMode) + case theme(ArticleThemeSetting) + } + public override init(nibName nibNameOrNil: NSNib.Name?, bundle nibBundleOrNil: Bundle?) { super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) commonInit() @@ -56,7 +61,7 @@ final class GeneralPreferencesViewController: NSViewController { guard let menuItem = articleThemePopup.selectedItem else { return } - ArticleThemesManager.shared.currentThemeName = menuItem.title + applyArticleThemePopupItem(menuItem) updateArticleThemePopup() } @@ -121,19 +126,75 @@ private extension GeneralPreferencesViewController { let menu = articleThemePopup.menu! menu.removeAllItems() - menu.addItem(NSMenuItem(title: ArticleTheme.defaultTheme.name, action: nil, keyEquivalent: "")) + let sameThemeItem = articleThemeMenuItem(title: ArticleThemeSelectionMode.single.title, representedObject: ArticleThemePopupItem.selectionMode(.single)) + sameThemeItem.state = ArticleThemesManager.shared.themeSelectionMode == .single ? .on : .off + menu.addItem(sameThemeItem) + + let matchAppearanceItem = articleThemeMenuItem(title: ArticleThemeSelectionMode.appearance.title, representedObject: ArticleThemePopupItem.selectionMode(.appearance)) + matchAppearanceItem.state = ArticleThemesManager.shared.themeSelectionMode == .appearance ? .on : .off + menu.addItem(matchAppearanceItem) menu.addItem(NSMenuItem.separator()) - for themeName in ArticleThemesManager.shared.themeNames { - menu.addItem(NSMenuItem(title: themeName, action: nil, keyEquivalent: "")) + switch ArticleThemesManager.shared.themeSelectionMode { + case .single: + addThemeMenuItems(to: menu, setting: .single) + articleThemePopup.selectItem(withTitle: ArticleThemesManager.shared.currentThemeName) + if articleThemePopup.indexOfSelectedItem == -1 { + articleThemePopup.selectItem(withTitle: ArticleTheme.defaultTheme.name) + } + case .appearance: + addThemeSubmenu(to: menu, setting: .lightAppearance) + addThemeSubmenu(to: menu, setting: .darkAppearance) + articleThemePopup.selectItem(withTitle: ArticleThemeSelectionMode.appearance.title) + } + } + + @objc func articleThemeMenuItemSelected(_ menuItem: NSMenuItem) { + applyArticleThemePopupItem(menuItem) + updateArticleThemePopup() + } + + func applyArticleThemePopupItem(_ menuItem: NSMenuItem) { + guard let item = menuItem.representedObject as? ArticleThemePopupItem else { + return } - articleThemePopup.selectItem(withTitle: ArticleThemesManager.shared.currentThemeName) - if articleThemePopup.indexOfSelectedItem == -1 { - articleThemePopup.selectItem(withTitle: ArticleTheme.defaultTheme.name) + switch item { + case .selectionMode(let mode): + ArticleThemesManager.shared.themeSelectionMode = mode + case .theme(let setting): + ArticleThemesManager.shared.setThemeName(menuItem.title, for: setting) } } + func addThemeSubmenu(to menu: NSMenu, setting: ArticleThemeSetting) { + let submenuItem = NSMenuItem(title: setting.title, action: nil, keyEquivalent: "") + let submenu = NSMenu() + addThemeMenuItems(to: submenu, setting: setting) + submenuItem.submenu = submenu + menu.addItem(submenuItem) + } + + func addThemeMenuItems(to menu: NSMenu, setting: ArticleThemeSetting) { + let defaultThemeItem = articleThemeMenuItem(title: ArticleTheme.defaultTheme.name, representedObject: ArticleThemePopupItem.theme(setting)) + defaultThemeItem.state = ArticleTheme.defaultTheme.name == ArticleThemesManager.shared.themeName(for: setting) ? .on : .off + menu.addItem(defaultThemeItem) + menu.addItem(NSMenuItem.separator()) + + for themeName in ArticleThemesManager.shared.themeNames { + let themeItem = articleThemeMenuItem(title: themeName, representedObject: ArticleThemePopupItem.theme(setting)) + themeItem.state = themeName == ArticleThemesManager.shared.themeName(for: setting) ? .on : .off + menu.addItem(themeItem) + } + } + + private func articleThemeMenuItem(title: String, representedObject: ArticleThemePopupItem) -> NSMenuItem { + let menuItem = NSMenuItem(title: title, action: #selector(articleThemeMenuItemSelected(_:)), keyEquivalent: "") + menuItem.target = self + menuItem.representedObject = representedObject + return menuItem + } + func updateBrowserPopup() { let menu = defaultBrowserPopup.menu! let allBrowsers = MacWebBrowser.sortedBrowsers() diff --git a/Shared/ArticleStyles/ArticleThemesManager.swift b/Shared/ArticleStyles/ArticleThemesManager.swift index be3af5f97..6e7dd5db2 100644 --- a/Shared/ArticleStyles/ArticleThemesManager.swift +++ b/Shared/ArticleStyles/ArticleThemesManager.swift @@ -15,6 +15,37 @@ public extension Notification.Name { static let CurrentArticleThemeDidChangeNotification = Notification.Name("CurrentArticleThemeDidChangeNotification") } +enum ArticleThemeAppearance: Sendable { + case light + case dark + + var themeSetting: ArticleThemeSetting { + switch self { + case .light: + return .lightAppearance + case .dark: + return .darkAppearance + } + } +} + +enum ArticleThemeSetting: Sendable { + case single + case lightAppearance + case darkAppearance + + var title: String { + switch self { + case .single: + return NSLocalizedString("Theme", comment: "Article theme setting") + case .lightAppearance: + return NSLocalizedString("Light Appearance Theme", comment: "Article theme setting") + case .darkAppearance: + return NSLocalizedString("Dark Appearance Theme", comment: "Article theme setting") + } + } +} + final class ArticleThemesManager: NSObject, NSFilePresenter, Sendable { static let shared = ArticleThemesManager() public let folderPath: String @@ -22,16 +53,32 @@ final class ArticleThemesManager: NSObject, NSFilePresenter, Sendable { let presentedItemOperationQueue = OperationQueue.main // NSFilePresenter let presentedItemURL: URL? // NSFilePresenter + var themeSelectionMode: ArticleThemeSelectionMode { + get { + AppDefaults.shared.articleThemeSelectionMode + } + set { + guard newValue != themeSelectionMode else { + return + } + AppDefaults.shared.articleThemeSelectionMode = newValue + updateCurrentTheme() + } + } + var currentThemeName: String { get { - AppDefaults.shared.currentThemeName ?? AppDefaults.defaultThemeName + themeName(for: .single) } set { - if newValue != currentThemeName { - AppDefaults.shared.currentThemeName = newValue - updateThemeNames() - updateCurrentTheme() + let modeDidChange = themeSelectionMode != .single + guard newValue != currentThemeName || modeDidChange else { + return } + setStoredThemeName(newValue, for: .single) + AppDefaults.shared.articleThemeSelectionMode = .single + updateThemeNames() + updateCurrentTheme() } } @@ -55,9 +102,28 @@ final class ArticleThemesManager: NSObject, NSFilePresenter, Sendable { } } + var activeThemeName: String { + themeName(for: activeThemeSetting) + } + + var currentAppearance: ArticleThemeAppearance { + state.withLock { $0.currentAppearance } + } + + var themeSelectionSummary: String { + switch themeSelectionMode { + case .single: + return themeName(for: .single) + case .appearance: + let format = NSLocalizedString("%@ / %@", comment: "Light and dark article theme summary") + return String(format: format, themeName(for: .lightAppearance), themeName(for: .darkAppearance)) + } + } + private struct State { var currentTheme = ArticleTheme.defaultTheme var themeNames = [AppDefaults.defaultThemeName] + var currentAppearance = ArticleThemeAppearance.light } private let state = OSAllocatedUnfairLock(initialState: State()) @@ -98,6 +164,50 @@ final class ArticleThemesManager: NSObject, NSFilePresenter, Sendable { // MARK: API + func updateCurrentAppearance(_ appearance: ArticleThemeAppearance) { + let didChange = state.withLock { state in + guard state.currentAppearance != appearance else { + return false + } + state.currentAppearance = appearance + return true + } + + if didChange { + updateCurrentTheme() + } + } + + func themeName(for setting: ArticleThemeSetting) -> String { + switch setting { + case .single: + return AppDefaults.shared.currentThemeName ?? AppDefaults.defaultThemeName + case .lightAppearance: + return AppDefaults.shared.lightThemeName ?? themeName(for: .single) + case .darkAppearance: + return AppDefaults.shared.darkThemeName ?? themeName(for: .single) + } + } + + func setThemeName(_ themeName: String, for setting: ArticleThemeSetting) { + guard themeName != self.themeName(for: setting) else { + return + } + + setStoredThemeName(themeName, for: setting) + updateThemeNames() + updateCurrentTheme() + } + + func setActiveAppearanceThemeName(_ themeName: String) { + switch themeSelectionMode { + case .single: + currentThemeName = themeName + case .appearance: + setThemeName(themeName, for: activeThemeSetting) + } + } + func themeExists(filename: String) -> Bool { let filenameLastPathComponent = (filename as NSString).lastPathComponent let toFilename = (folderPath as NSString).appendingPathComponent(filenameLastPathComponent) @@ -151,6 +261,15 @@ final class ArticleThemesManager: NSObject, NSFilePresenter, Sendable { private extension ArticleThemesManager { + var activeThemeSetting: ArticleThemeSetting { + switch themeSelectionMode { + case .single: + return .single + case .appearance: + return currentAppearance.themeSetting + } + } + func updateThemeNames() { let appThemeFilenames = Bundle.main.paths(forResourcesOfType: ArticleTheme.nnwThemeSuffix, inDirectory: nil) let appThemeNames = Set(appThemeFilenames.map { ArticleTheme.themeNameForPath($0) }) @@ -163,6 +282,8 @@ private extension ArticleThemesManager { if sortedThemeNames != themeNames { themeNames = sortedThemeNames } + + validateStoredThemeNames() } func defaultArticleTheme() -> ArticleTheme { @@ -170,16 +291,16 @@ private extension ArticleThemesManager { } func updateCurrentTheme() { - var themeName = currentThemeName - if !themeNames.contains(themeName) { + var themeName = activeThemeName + if !isValidThemeName(themeName) { themeName = AppDefaults.defaultThemeName - currentThemeName = AppDefaults.defaultThemeName + setStoredThemeName(AppDefaults.defaultThemeName, for: activeThemeSetting) } var articleTheme = articleThemeWithThemeName(themeName) if articleTheme == nil { articleTheme = defaultArticleTheme() - currentThemeName = AppDefaults.defaultThemeName + setStoredThemeName(AppDefaults.defaultThemeName, for: activeThemeSetting) } if let articleTheme = articleTheme, articleTheme != currentTheme { @@ -187,6 +308,34 @@ private extension ArticleThemesManager { } } + func validateStoredThemeNames() { + validateThemeName(for: .single) + validateThemeName(for: .lightAppearance) + validateThemeName(for: .darkAppearance) + } + + func validateThemeName(for setting: ArticleThemeSetting) { + let themeName = themeName(for: setting) + if !isValidThemeName(themeName) { + setStoredThemeName(AppDefaults.defaultThemeName, for: setting) + } + } + + func isValidThemeName(_ themeName: String) -> Bool { + themeName == AppDefaults.defaultThemeName || themeNames.contains(themeName) + } + + func setStoredThemeName(_ themeName: String, for setting: ArticleThemeSetting) { + switch setting { + case .single: + AppDefaults.shared.currentThemeName = themeName + case .lightAppearance: + AppDefaults.shared.lightThemeName = themeName + case .darkAppearance: + AppDefaults.shared.darkThemeName = themeName + } + } + func allThemePaths(_ folder: String) -> [String] { let filepaths = FileManager.default.filePaths(inFolder: folder) return filepaths?.filter { $0.hasSuffix(ArticleTheme.nnwThemeSuffix) } ?? [] diff --git a/iOS/AppDefaults.swift b/iOS/AppDefaults.swift index 8249905b3..5483187ff 100644 --- a/iOS/AppDefaults.swift +++ b/iOS/AppDefaults.swift @@ -29,6 +29,20 @@ enum UserInterfaceColorPalette: Int, CustomStringConvertible, CaseIterable { } } +enum ArticleThemeSelectionMode: Int, CaseIterable, Sendable { + case single = 0 + case appearance = 1 + + var title: String { + switch self { + case .single: + return NSLocalizedString("Same Theme for All Appearances", comment: "Article theme selection mode") + case .appearance: + return NSLocalizedString("Match Appearance", comment: "Article theme selection mode") + } + } +} + extension Notification.Name { public static let userInterfaceColorPaletteDidUpdate = Notification.Name("UserInterfaceColorPaletteDidUpdateNotification") public static let timelineIconSizeDidChange = Notification.Name("TimelineIconSizeDidChangeNotification") @@ -66,6 +80,9 @@ final class AppDefaults: Sendable { static let addFolderAccountID = "addFolderAccountID" static let useSystemBrowser = "useSystemBrowser" static let currentThemeName = "currentThemeName" + static let articleThemeSelectionMode = "articleThemeSelectionMode" + static let lightThemeName = "lightThemeName" + static let darkThemeName = "darkThemeName" static let articleContentJavascriptEnabled = "articleContentJavascriptEnabled" static let hideReadFeeds = "hideReadFeeds" static let isShowingExtractedArticle = "isShowingExtractedArticle" @@ -268,6 +285,33 @@ final class AppDefaults: Sendable { } } + var articleThemeSelectionMode: ArticleThemeSelectionMode { + get { + ArticleThemeSelectionMode(rawValue: AppDefaults.int(for: Key.articleThemeSelectionMode)) ?? .single + } + set { + AppDefaults.setInt(for: Key.articleThemeSelectionMode, newValue.rawValue) + } + } + + var lightThemeName: String? { + get { + return AppDefaults.string(for: Key.lightThemeName) + } + set { + AppDefaults.setString(for: Key.lightThemeName, newValue) + } + } + + var darkThemeName: String? { + get { + return AppDefaults.string(for: Key.darkThemeName) + } + set { + AppDefaults.setString(for: Key.darkThemeName, newValue) + } + } + var hideReadFeeds: Bool { get { UserDefaults.standard.bool(forKey: Key.hideReadFeeds) @@ -389,6 +433,11 @@ final class AppDefaults: Sendable { } @MainActor static func registerDefaults() { + if AppDefaults.store.object(forKey: Key.articleThemeSelectionMode) == nil, + let storedMode = UserDefaults.standard.object(forKey: Key.articleThemeSelectionMode) as? Int { + AppDefaults.store.set(storedMode, forKey: Key.articleThemeSelectionMode) + } + let defaults: [String: Any] = [Key.userInterfaceColorPalette: UserInterfaceColorPalette.automatic.rawValue, Key.timelineGroupByFeed: false, Key.refreshClearsReadArticles: false, @@ -398,6 +447,7 @@ final class AppDefaults: Sendable { Key.articleFullscreenAvailable: false, Key.articleFullscreenEnabled: false, Key.confirmMarkAllAsRead: true, + Key.articleThemeSelectionMode: ArticleThemeSelectionMode.single.rawValue, Key.articleContentJavascriptEnabled: true, Key.currentThemeName: Self.defaultThemeName, Key.splitViewPreferredDisplayMode: UISplitViewController.DisplayMode.oneBesideSecondary.rawValue] diff --git a/iOS/SceneDelegate.swift b/iOS/SceneDelegate.swift index c66d7cdc2..d36d5e336 100644 --- a/iOS/SceneDelegate.swift +++ b/iOS/SceneDelegate.swift @@ -69,6 +69,12 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { completionHandler(true) } + func windowScene(_ windowScene: UIWindowScene, didUpdate previousCoordinateSpace: UICoordinateSpace, interfaceOrientation previousInterfaceOrientation: UIInterfaceOrientation, traitCollection previousTraitCollection: UITraitCollection) { + if windowScene.traitCollection.userInterfaceStyle != previousTraitCollection.userInterfaceStyle { + updateArticleThemeAppearance(for: windowScene.traitCollection) + } + } + func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { appDelegate.resumeIfNecessary() coordinator.handle(userActivity) @@ -254,5 +260,20 @@ private extension SceneDelegate { case .dark: self.window?.overrideUserInterfaceStyle = .dark } + updateArticleThemeAppearance() + } + + @MainActor func updateArticleThemeAppearance(for traitCollection: UITraitCollection? = nil) { + let appearance: ArticleThemeAppearance + switch AppDefaults.userInterfaceColorPalette { + case .automatic: + let effectiveTraitCollection = traitCollection ?? self.window?.windowScene?.traitCollection ?? self.window?.traitCollection + appearance = effectiveTraitCollection?.userInterfaceStyle == .dark ? .dark : .light + case .light: + appearance = .light + case .dark: + appearance = .dark + } + ArticleThemesManager.shared.updateCurrentAppearance(appearance) } } diff --git a/iOS/Settings/ArticleThemesTableViewController.swift b/iOS/Settings/ArticleThemesTableViewController.swift index 9c81b663d..52ee85aef 100644 --- a/iOS/Settings/ArticleThemesTableViewController.swift +++ b/iOS/Settings/ArticleThemesTableViewController.swift @@ -16,12 +16,33 @@ extension UTType { final class ArticleThemesTableViewController: UITableViewController { + private enum Mode { + case settings + case picker(ArticleThemeSetting) + } + + private enum SettingsSection: Int, CaseIterable { + case mode + case theme + } + + private var mode = Mode.settings + override func viewDidLoad() { + super.viewDidLoad() + let importBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(importTheme(_:))) importBarButtonItem.title = NSLocalizedString("Import Theme", comment: "Import Theme") navigationItem.rightBarButtonItem = importBarButtonItem NotificationCenter.default.addObserver(self, selector: #selector(articleThemeNamesDidChangeNotification(_:)), name: .ArticleThemeNamesDidChangeNotification, object: nil) + + updateTitle() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + tableView.reloadData() } // MARK: Notifications @@ -40,44 +61,71 @@ final class ArticleThemesTableViewController: UITableViewController { // MARK: - Table view data source override func numberOfSections(in tableView: UITableView) -> Int { - return 1 + switch mode { + case .settings: + return SettingsSection.allCases.count + case .picker: + return 1 + } } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return ArticleThemesManager.shared.themeNames.count + 1 + switch mode { + case .settings: + switch SettingsSection(rawValue: section) { + case .mode: + return ArticleThemeSelectionMode.allCases.count + case .theme: + return themeSettings.count + default: + return 0 + } + case .picker: + return ArticleThemesManager.shared.themeNames.count + 1 + } } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) + switch mode { + case .settings: + return settingsCell(for: indexPath) + case .picker(let setting): + let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) - let themeName: String - if indexPath.row == 0 { - themeName = ArticleTheme.defaultTheme.name - } else { - themeName = ArticleThemesManager.shared.themeNames[indexPath.row - 1] - } + let themeName = themeName(at: indexPath) - cell.textLabel?.text = themeName - if themeName == ArticleThemesManager.shared.currentTheme.name { - cell.accessoryType = .checkmark - } else { - cell.accessoryType = .none - } + cell.textLabel?.text = themeName + cell.detailTextLabel?.text = nil + if themeName == ArticleThemesManager.shared.themeName(for: setting) { + cell.accessoryType = .checkmark + } else { + cell.accessoryType = .none + } - return cell + return cell + } } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - guard let cell = tableView.cellForRow(at: indexPath), let themeName = cell.textLabel?.text else { return } - ArticleThemesManager.shared.currentThemeName = themeName - navigationController?.popViewController(animated: true) + switch mode { + case .settings: + selectSettingsRow(at: indexPath) + case .picker(let setting): + let themeName = themeName(at: indexPath) + selectTheme(themeName, for: setting) + navigationController?.popViewController(animated: true) + } } override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { - guard let cell = tableView.cellForRow(at: indexPath), - let themeName = cell.textLabel?.text, - let theme = ArticleThemesManager.shared.articleThemeWithThemeName(themeName), - !theme.isAppTheme else { return nil } + guard case .picker = mode else { + return nil + } + + let themeName = themeName(at: indexPath) + guard let theme = ArticleThemesManager.shared.articleThemeWithThemeName(themeName), !theme.isAppTheme else { + return nil + } let deleteTitle = NSLocalizedString("Delete", comment: "Delete button") let deleteAction = UIContextualAction(style: .normal, title: deleteTitle) { [weak self] (_, _, completion) in @@ -111,6 +159,88 @@ final class ArticleThemesTableViewController: UITableViewController { } } +// MARK: - Private + +private extension ArticleThemesTableViewController { + + var themeSettings: [ArticleThemeSetting] { + switch ArticleThemesManager.shared.themeSelectionMode { + case .single: + return [.single] + case .appearance: + return [.lightAppearance, .darkAppearance] + } + } + + func updateTitle() { + switch mode { + case .settings: + title = NSLocalizedString("Theme", comment: "Theme") + case .picker(let setting): + title = setting.title + } + } + + func settingsCell(for indexPath: IndexPath) -> UITableViewCell { + switch SettingsSection(rawValue: indexPath.section) { + case .mode: + let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) + let rowMode = ArticleThemeSelectionMode.allCases[indexPath.row] + cell.textLabel?.text = rowMode.title + cell.detailTextLabel?.text = nil + cell.accessoryType = rowMode == ArticleThemesManager.shared.themeSelectionMode ? .checkmark : .none + return cell + case .theme: + let cell = themeSettingCell() + let setting = themeSettings[indexPath.row] + cell.textLabel?.text = setting.title + cell.detailTextLabel?.text = ArticleThemesManager.shared.themeName(for: setting) + cell.accessoryType = .disclosureIndicator + return cell + default: + return UITableViewCell() + } + } + + func themeSettingCell() -> UITableViewCell { + if let cell = tableView.dequeueReusableCell(withIdentifier: "ThemeSettingCell") { + return cell + } + return UITableViewCell(style: .value1, reuseIdentifier: "ThemeSettingCell") + } + + func selectSettingsRow(at indexPath: IndexPath) { + switch SettingsSection(rawValue: indexPath.section) { + case .mode: + ArticleThemesManager.shared.themeSelectionMode = ArticleThemeSelectionMode.allCases[indexPath.row] + tableView.deselectRow(at: indexPath, animated: true) + tableView.reloadData() + case .theme: + let controller = UIStoryboard.settings.instantiateController(ofType: ArticleThemesTableViewController.self) + controller.mode = .picker(themeSettings[indexPath.row]) + navigationController?.pushViewController(controller, animated: true) + default: + break + } + } + + func themeName(at indexPath: IndexPath) -> String { + if indexPath.row == 0 { + return ArticleTheme.defaultTheme.name + } + return ArticleThemesManager.shared.themeNames[indexPath.row - 1] + } + + func selectTheme(_ themeName: String, for setting: ArticleThemeSetting) { + switch setting { + case .single: + ArticleThemesManager.shared.currentThemeName = themeName + case .lightAppearance, .darkAppearance: + ArticleThemesManager.shared.setThemeName(themeName, for: setting) + } + } +} + // MARK: UIDocumentPickerDelegate extension ArticleThemesTableViewController: UIDocumentPickerDelegate { diff --git a/iOS/Settings/SettingsViewController.swift b/iOS/Settings/SettingsViewController.swift index 2e10b1f04..77313f91b 100644 --- a/iOS/Settings/SettingsViewController.swift +++ b/iOS/Settings/SettingsViewController.swift @@ -117,7 +117,7 @@ final class SettingsViewController: UITableViewController { refreshClearsReadArticlesSwitch.isOn = false } - articleThemeDetailLabel.text = ArticleThemesManager.shared.currentTheme.name + articleThemeDetailLabel.text = ArticleThemesManager.shared.themeSelectionSummary if AppDefaults.shared.confirmMarkAllAsRead { confirmMarkAllAsReadSwitch.isOn = true