// // SettingsViewController.swift // Mastodon // // Created by ihugo on 2021/4/7. // import os.log import UIKit import Combine import ActiveLabel import CoreData import CoreDataStack class SettingsViewController: UIViewController, NeedsDependency { weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } var viewModel: SettingsViewModel! { willSet { precondition(!isViewLoaded) } } var disposeBag = Set() var triggerMenu: UIMenu { let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone let follower = L10n.Scene.Settings.Section.Notifications.Trigger.follower let follow = L10n.Scene.Settings.Section.Notifications.Trigger.follow let noOne = L10n.Scene.Settings.Section.Notifications.Trigger.noOne let menu = UIMenu( image: UIImage(systemName: "escape"), identifier: nil, options: .displayInline, children: [ UIAction(title: anyone, image: UIImage(systemName: "person.3"), attributes: []) { [weak self] action in self?.updateTrigger(by: anyone) }, UIAction(title: follower, image: UIImage(systemName: "person.crop.circle.badge.plus"), attributes: []) { [weak self] action in self?.updateTrigger(by: follower) }, UIAction(title: follow, image: UIImage(systemName: "person.crop.circle.badge.checkmark"), attributes: []) { [weak self] action in self?.updateTrigger(by: follow) }, UIAction(title: noOne, image: UIImage(systemName: "nosign"), attributes: []) { [weak self] action in self?.updateTrigger(by: noOne) }, ].reversed() ) return menu } lazy var notifySectionHeader: UIView = { let view = UIStackView() view.translatesAutoresizingMaskIntoConstraints = false view.isLayoutMarginsRelativeArrangement = true view.layoutMargins = UIEdgeInsets(top: 15, left: 4, bottom: 5, right: 4) view.axis = .horizontal view.alignment = .fill view.distribution = .equalSpacing view.spacing = 4 let notifyLabel = UILabel() notifyLabel.translatesAutoresizingMaskIntoConstraints = false notifyLabel.font = UIFontMetrics(forTextStyle: .title3).scaledFont(for: UIFont.systemFont(ofSize: 20, weight: .semibold)) notifyLabel.textColor = Asset.Colors.Label.primary.color notifyLabel.text = L10n.Scene.Settings.Section.Notifications.Trigger.title view.addArrangedSubview(notifyLabel) view.addArrangedSubview(whoButton) return view }() lazy var whoButton: UIButton = { let whoButton = UIButton(type: .roundedRect) whoButton.menu = triggerMenu whoButton.showsMenuAsPrimaryAction = true whoButton.setBackgroundColor(Asset.Colors.battleshipGrey.color, for: .normal) whoButton.setTitleColor(Asset.Colors.Label.primary.color, for: .normal) if let setting = self.viewModel.setting.value, let trigger = setting.triggerBy { whoButton.setTitle(trigger, for: .normal) } whoButton.titleLabel?.font = UIFontMetrics(forTextStyle: .title3).scaledFont(for: UIFont.systemFont(ofSize: 20, weight: .semibold)) whoButton.contentEdgeInsets = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5) whoButton.layer.cornerRadius = 10 whoButton.clipsToBounds = true return whoButton }() lazy var tableView: UITableView = { // init with a frame to fix a conflict ('UIView-Encapsulated-Layout-Width' UIStackView:0x7f8c2b6c0590.width == 0) let tableView = UITableView(frame: CGRect(x: 0, y: 0, width: 320, height: 320), style: .grouped) tableView.translatesAutoresizingMaskIntoConstraints = false tableView.delegate = self tableView.rowHeight = UITableView.automaticDimension tableView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color tableView.register(SettingsAppearanceTableViewCell.self, forCellReuseIdentifier: "SettingsAppearanceTableViewCell") tableView.register(SettingsToggleTableViewCell.self, forCellReuseIdentifier: "SettingsToggleTableViewCell") tableView.register(SettingsLinkTableViewCell.self, forCellReuseIdentifier: "SettingsLinkTableViewCell") return tableView }() lazy var footerView: UIView = { // init with a frame to fix a conflict ('UIView-Encapsulated-Layout-Height' UIStackView:0x7ffe41e47da0.height == 0) let view = UIStackView(frame: CGRect(x: 0, y: 0, width: 320, height: 320)) view.isLayoutMarginsRelativeArrangement = true view.layoutMargins = UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20) view.axis = .vertical view.alignment = .center let label = ActiveLabel(style: .default) label.textAlignment = .center label.configure(content: "Mastodon is open source software. You can contribute or report issues on GitHub at tootsuite/mastodon (v3.3.0).") label.delegate = self view.addArrangedSubview(label) return view }() override func viewDidLoad() { super.viewDidLoad() setupView() bindViewModel() viewModel.viewDidLoad.send() } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() guard let footerView = self.tableView.tableFooterView else { return } let width = self.tableView.bounds.size.width let size = footerView.systemLayoutSizeFitting(CGSize(width: width, height: UIView.layoutFittingCompressedSize.height)) if footerView.frame.size.height != size.height { footerView.frame.size.height = size.height self.tableView.tableFooterView = footerView } } // MAKR: - Private methods private func bindViewModel() { let input = SettingsViewModel.Input() _ = viewModel.transform(input: input) } private func setupView() { view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color setupNavigation() setupTableView() view.addSubview(tableView) NSLayoutConstraint.activate([ tableView.topAnchor.constraint(equalTo: view.topAnchor), tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) } private func setupNavigation() { navigationController?.navigationBar.prefersLargeTitles = true navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: UIBarButtonItem.SystemItem.done, target: self, action: #selector(doneButtonDidClick)) navigationItem.title = L10n.Scene.Settings.title let barAppearance = UINavigationBarAppearance() barAppearance.configureWithDefaultBackground() navigationItem.standardAppearance = barAppearance navigationItem.compactAppearance = barAppearance navigationItem.scrollEdgeAppearance = barAppearance } private func setupTableView() { viewModel.dataSource = UITableViewDiffableDataSource(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in switch item { case .apperance(let item): guard let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsAppearanceTableViewCell") as? SettingsAppearanceTableViewCell else { assertionFailure() return nil } cell.update(with: item, delegate: self) return cell case .notification(let item): guard let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsToggleTableViewCell") as? SettingsToggleTableViewCell else { assertionFailure() return nil } cell.update(with: item, delegate: self) return cell case .boringZone(let item), .spicyZone(let item): guard let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsLinkTableViewCell") as? SettingsLinkTableViewCell else { assertionFailure() return nil } cell.update(with: item) return cell } }) tableView.tableFooterView = footerView } func signout() { guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } context.authenticationService.signOutMastodonUser( domain: activeMastodonAuthenticationBox.domain, userID: activeMastodonAuthenticationBox.userID ) .receive(on: DispatchQueue.main) .sink { [weak self] result in guard let self = self else { return } switch result { case .failure(let error): assertionFailure(error.localizedDescription) case .success(let isSignOut): os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: sign out %s", ((#file as NSString).lastPathComponent), #line, #function, isSignOut ? "success" : "fail") guard isSignOut else { return } self.coordinator.setup() self.coordinator.setupOnboardingIfNeeds(animated: true) } } .store(in: &disposeBag) } // Mark: - Actions @objc func doneButtonDidClick() { dismiss(animated: true, completion: nil) } } extension SettingsViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { let sections = viewModel.dataSource.snapshot().sectionIdentifiers guard section < sections.count else { return nil } let sectionData = sections[section] if section == 1 { let header = SettingsSectionHeader( frame: CGRect(x: 0, y: 0, width: 375, height: 66), customView: notifySectionHeader) header.update(title: sectionData.title) if let setting = self.viewModel.setting.value, let trigger = setting.triggerBy { whoButton.setTitle(trigger, for: .normal) } else { let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone whoButton.setTitle(anyone, for: .normal) } return header } else { let header = SettingsSectionHeader(frame: CGRect(x: 0, y: 0, width: 375, height: 66)) header.update(title: sectionData.title) return header } } // remove the gap of table's footer func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { return UIView() } // remove the gap of table's footer func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { return 0 } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard indexPath.section == 2 || indexPath.section == 3 else { return } if indexPath.section == 2 { coordinator.present( scene: .webview(url: URL(string: "https://mastodon.online/terms")!), from: self, transition: .modal(animated: true, completion: nil)) } // iTODO: clear media cache // logout if indexPath.section == 3, indexPath.row == 1 { signout() } } } // Update setting into core data extension SettingsViewController { func updateTrigger(by who: String) { guard let setting = self.viewModel.setting.value else { return } context.managedObjectContext.performChanges { setting.update(triggerBy: who) } .sink { (_) in }.store(in: &disposeBag) } func updateAlert(title: String?, isOn: Bool) { guard let title = title else { return } guard let settings = self.viewModel.setting.value else { return } guard let triggerBy = settings.triggerBy else { return } var values: [Bool?]? if let alerts = settings.subscription?.first(where: { (s) -> Bool in return s.type == settings.triggerBy })?.alert { var items = [Bool?]() items.append(alerts.favourite) items.append(alerts.follow) items.append(alerts.reblog) items.append(alerts.mention) values = items } guard var alertValues = values else { return } guard alertValues.count >= 4 else { return } switch title { case L10n.Scene.Settings.Section.Notifications.favorites: alertValues[0] = isOn case L10n.Scene.Settings.Section.Notifications.follows: alertValues[1] = isOn case L10n.Scene.Settings.Section.Notifications.boosts: alertValues[2] = isOn case L10n.Scene.Settings.Section.Notifications.mentions: alertValues[3] = isOn default: break } self.viewModel.alertUpdate.send((triggerBy: triggerBy, values: alertValues)) } } extension SettingsViewController: SettingsAppearanceTableViewCellDelegate { func settingsAppearanceCell(_ view: SettingsAppearanceTableViewCell, didSelect: SettingsItem.AppearanceMode) { print("[SettingsViewController]: didSelect \(didSelect)") guard let setting = self.viewModel.setting.value else { return } context.managedObjectContext.performChanges { setting.update(appearance: didSelect.rawValue) } .sink { (_) in // change light / dark mode var overrideUserInterfaceStyle: UIUserInterfaceStyle! switch didSelect { case .automatic: overrideUserInterfaceStyle = .unspecified case .light: overrideUserInterfaceStyle = .light case .dark: overrideUserInterfaceStyle = .dark } view.window?.overrideUserInterfaceStyle = overrideUserInterfaceStyle }.store(in: &disposeBag) } } extension SettingsViewController: SettingsToggleCellDelegate { func settingsToggleCell(_ cell: SettingsToggleTableViewCell, didChangeStatus: Bool) { updateAlert(title: cell.data?.title, isOn: didChangeStatus) } } extension SettingsViewController: ActiveLabelDelegate { func activeLabel(_ activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { coordinator.present( scene: .webview(url: URL(string: "https://github.com/tootsuite/mastodon")!), from: self, transition: .modal(animated: true, completion: nil)) } } extension SettingsViewController { static func updateOverrideUserInterfaceStyle(window: UIWindow?) { guard let box = AppContext.shared.authenticationService.activeMastodonAuthenticationBox.value else { return } guard let setting: Setting? = { let domain = box.domain let request = Setting.sortedFetchRequest request.predicate = Setting.predicate(domain: domain) request.fetchLimit = 1 request.returnsObjectsAsFaults = false do { return try AppContext.shared.managedObjectContext.fetch(request).first } catch { assertionFailure(error.localizedDescription) return nil } }() else { return } guard let didSelect = SettingsItem.AppearanceMode(rawValue: setting?.appearance ?? "") else { return } var overrideUserInterfaceStyle: UIUserInterfaceStyle! switch didSelect { case .automatic: overrideUserInterfaceStyle = .unspecified case .light: overrideUserInterfaceStyle = .light case .dark: overrideUserInterfaceStyle = .dark } window?.overrideUserInterfaceStyle = overrideUserInterfaceStyle } } #if canImport(SwiftUI) && DEBUG import SwiftUI struct SettingsViewController_Previews: PreviewProvider { static var previews: some View { Group { UIViewControllerPreview { () -> UIViewController in return SettingsViewController() } .previewLayout(.fixed(width: 390, height: 844)) } } } #endif