mirror of https://github.com/oxen-io/session-ios
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
392 lines
14 KiB
Swift
392 lines
14 KiB
Swift
3 years ago
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||
|
|
||
|
import UIKit
|
||
|
import GRDB
|
||
|
import DifferenceKit
|
||
|
import SessionUIKit
|
||
|
import SessionUtilitiesKit
|
||
|
import SignalUtilitiesKit
|
||
|
|
||
|
class SettingsTableViewController<Section: SettingSection, SettingItem: Hashable & Differentiable>: BaseVC, UITableViewDataSource, UITableViewDelegate {
|
||
|
typealias SectionModel = SettingsTableViewModel<Section, SettingItem>.SectionModel
|
||
|
|
||
|
private let viewModel: SettingsTableViewModel<Section, SettingItem>
|
||
|
private var dataChangeObservable: DatabaseCancellable?
|
||
|
private var hasLoadedInitialSettingsData: Bool = false
|
||
|
|
||
|
// MARK: - Components
|
||
|
|
||
|
private lazy var tableView: UITableView = {
|
||
|
let result: UITableView = UITableView()
|
||
|
result.translatesAutoresizingMaskIntoConstraints = false
|
||
|
result.separatorStyle = .none
|
||
|
result.backgroundColor = .clear
|
||
|
result.showsVerticalScrollIndicator = false
|
||
|
result.showsHorizontalScrollIndicator = false
|
||
|
result.register(view: SettingsCell.self)
|
||
|
result.registerHeaderFooterView(view: SettingHeaderView.self)
|
||
|
result.dataSource = self
|
||
|
result.delegate = self
|
||
|
|
||
|
if #available(iOS 15.0, *) {
|
||
|
result.sectionHeaderTopPadding = 0
|
||
|
}
|
||
|
|
||
|
return result
|
||
|
}()
|
||
|
|
||
|
// MARK: - Initialization
|
||
|
|
||
|
init(viewModel: SettingsTableViewModel<Section, SettingItem>) {
|
||
|
self.viewModel = viewModel
|
||
|
|
||
|
super.init(nibName: nil, bundle: nil)
|
||
|
}
|
||
|
|
||
|
required init?(coder: NSCoder) {
|
||
|
fatalError("init(coder:) has not been implemented")
|
||
|
}
|
||
|
|
||
|
deinit {
|
||
|
NotificationCenter.default.removeObserver(self)
|
||
|
}
|
||
|
|
||
|
// MARK: - Lifecycle
|
||
|
|
||
|
override func viewDidLoad() {
|
||
|
super.viewDidLoad()
|
||
|
|
||
|
ViewControllerUtilities.setUpDefaultSessionStyle(
|
||
|
for: self,
|
||
|
title: viewModel.title,
|
||
|
hasCustomBackButton: false
|
||
|
)
|
||
|
|
||
|
view.themeBackgroundColor = .backgroundPrimary
|
||
|
view.addSubview(tableView)
|
||
|
|
||
|
setupLayout()
|
||
|
|
||
|
// Notifications
|
||
|
NotificationCenter.default.addObserver(
|
||
|
self,
|
||
|
selector: #selector(applicationDidBecomeActive(_:)),
|
||
|
name: UIApplication.didBecomeActiveNotification,
|
||
|
object: nil
|
||
|
)
|
||
|
NotificationCenter.default.addObserver(
|
||
|
self,
|
||
|
selector: #selector(applicationDidResignActive(_:)),
|
||
|
name: UIApplication.didEnterBackgroundNotification, object: nil
|
||
|
)
|
||
|
}
|
||
|
|
||
|
override func viewWillAppear(_ animated: Bool) {
|
||
|
super.viewWillAppear(animated)
|
||
|
|
||
|
startObservingChanges()
|
||
|
}
|
||
|
|
||
|
override func viewWillDisappear(_ animated: Bool) {
|
||
|
super.viewWillDisappear(animated)
|
||
|
|
||
|
stopObservingChanges()
|
||
|
}
|
||
|
|
||
|
@objc func applicationDidBecomeActive(_ notification: Notification) {
|
||
|
startObservingChanges()
|
||
|
}
|
||
|
|
||
|
@objc func applicationDidResignActive(_ notification: Notification) {
|
||
|
stopObservingChanges()
|
||
|
}
|
||
|
|
||
|
private func setupLayout() {
|
||
|
tableView.pin(to: view)
|
||
|
}
|
||
|
|
||
|
// MARK: - Updating
|
||
|
|
||
|
private func startObservingChanges() {
|
||
|
// Start observing for data changes
|
||
|
dataChangeObservable = Storage.shared.start(
|
||
|
viewModel.observableSettingsData,
|
||
|
// If we haven't done the initial load the trigger it immediately (blocking the main
|
||
|
// thread so we remain on the launch screen until it completes to be consistent with
|
||
|
// the old behaviour)
|
||
|
scheduling: (hasLoadedInitialSettingsData ?
|
||
|
.async(onQueue: .main) :
|
||
|
.immediate
|
||
|
),
|
||
|
onError: { _ in },
|
||
|
onChange: { [weak self] settingsData in
|
||
|
// The default scheduler emits changes on the main thread
|
||
|
self?.handleSettingsUpdates(settingsData)
|
||
|
}
|
||
|
)
|
||
|
}
|
||
|
|
||
|
private func stopObservingChanges() {
|
||
|
// Stop observing database changes
|
||
|
dataChangeObservable?.cancel()
|
||
|
}
|
||
|
|
||
|
private func handleSettingsUpdates(_ updatedData: [SectionModel], initialLoad: Bool = false) {
|
||
|
// Ensure the first load runs without animations (if we don't do this the cells will animate
|
||
|
// in from a frame of CGRect.zero)
|
||
|
guard hasLoadedInitialSettingsData else {
|
||
|
hasLoadedInitialSettingsData = true
|
||
|
UIView.performWithoutAnimation { handleSettingsUpdates(updatedData, initialLoad: true) }
|
||
|
return
|
||
|
}
|
||
|
|
||
|
// Navigation bar
|
||
|
updateNavigation(updatedData)
|
||
|
|
||
|
// Reload the table content (animate changes after the first load)
|
||
|
tableView.reload(
|
||
|
using: StagedChangeset(source: viewModel.settingsData, target: updatedData),
|
||
|
deleteSectionsAnimation: .none,
|
||
|
insertSectionsAnimation: .none,
|
||
|
reloadSectionsAnimation: .none,
|
||
|
deleteRowsAnimation: .bottom,
|
||
|
insertRowsAnimation: .none,
|
||
|
reloadRowsAnimation: .none,
|
||
|
interrupt: { $0.changeCount > 100 } // Prevent too many changes from causing performance issues
|
||
|
) { [weak self] updatedData in
|
||
|
self?.viewModel.updateSettings(updatedData)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
private func updateNavigation(_ data: [SectionModel]) {
|
||
|
guard
|
||
|
case .listSelection(_, _, let shouldAutoSave, _) = data.first?.elements.first?.action,
|
||
|
!shouldAutoSave
|
||
|
else {
|
||
|
navigationItem.leftBarButtonItem = nil
|
||
|
navigationItem.rightBarButtonItem = nil
|
||
|
return
|
||
|
}
|
||
|
|
||
|
let isStoredSelected: Bool = (data.first?.elements ?? []).contains { info in
|
||
|
switch info.action {
|
||
|
case .listSelection(let isSelected, let storedSelection, _, _):
|
||
|
return (isSelected() && storedSelection)
|
||
|
|
||
|
default: return false
|
||
|
}
|
||
|
}
|
||
|
|
||
|
let cancelButton: UIBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelButtonPressed))
|
||
|
cancelButton.themeTintColor = .textPrimary
|
||
|
navigationItem.leftBarButtonItem = cancelButton
|
||
|
|
||
|
let saveButton: UIBarButtonItem = UIBarButtonItem(barButtonSystemItem: .save, target: self, action: #selector(saveButtonPressed))
|
||
|
saveButton.themeTintColor = .textPrimary
|
||
|
navigationItem.rightBarButtonItem = (isStoredSelected ? nil : saveButton)
|
||
|
}
|
||
|
|
||
|
// MARK: - UITableViewDataSource
|
||
|
|
||
|
func numberOfSections(in tableView: UITableView) -> Int {
|
||
|
return self.viewModel.settingsData.count
|
||
|
}
|
||
|
|
||
|
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||
|
return self.viewModel.settingsData[section].elements.count
|
||
|
}
|
||
|
|
||
|
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||
|
let section: SectionModel = viewModel.settingsData[indexPath.section]
|
||
|
let settingInfo: SettingInfo<SettingItem> = section.elements[indexPath.row]
|
||
|
|
||
|
let cell: SettingsCell = tableView.dequeue(type: SettingsCell.self, for: indexPath)
|
||
|
cell.update(
|
||
|
title: settingInfo.title,
|
||
|
subtitle: settingInfo.subtitle,
|
||
|
action: settingInfo.action,
|
||
|
extraActionTitle: settingInfo.extraActionTitle,
|
||
|
onExtraAction: settingInfo.onExtraAction,
|
||
|
isFirstInSection: (indexPath.row == 0),
|
||
|
isLastInSection: (indexPath.row == (section.elements.count - 1))
|
||
|
)
|
||
|
|
||
|
return cell
|
||
|
}
|
||
|
|
||
|
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
||
|
let section: SectionModel = viewModel.settingsData[section]
|
||
|
let view: SettingHeaderView = tableView.dequeueHeaderFooterView(type: SettingHeaderView.self)
|
||
|
view.update(with: section.model.title)
|
||
|
|
||
|
return view
|
||
|
}
|
||
|
|
||
|
// MARK: - UITableViewDelegate
|
||
|
|
||
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||
|
tableView.deselectRow(at: indexPath, animated: true)
|
||
|
|
||
|
let section: SectionModel = self.viewModel.settingsData[indexPath.section]
|
||
|
let settingInfo: SettingInfo<SettingItem> = section.elements[indexPath.row]
|
||
|
|
||
|
switch settingInfo.action {
|
||
|
case .trigger(let action):
|
||
|
action()
|
||
|
|
||
|
case .rightButtonModal(_, let createModal):
|
||
|
let viewController: UIViewController = createModal()
|
||
|
present(viewController, animated: true, completion: nil)
|
||
|
|
||
|
case .userDefaultsBool(let defaults, let key, let onChange):
|
||
|
defaults.set(!defaults.bool(forKey: key), forKey: key)
|
||
|
manuallyReload(indexPath: indexPath, section: section, settingInfo: settingInfo)
|
||
|
onChange?()
|
||
|
|
||
|
case .settingBool(let key):
|
||
|
Storage.shared.write { db in db[key] = !db[key] }
|
||
|
manuallyReload(indexPath: indexPath, section: section, settingInfo: settingInfo)
|
||
|
|
||
|
case .push(let createDestination), .dangerPush(let createDestination),
|
||
|
.settingEnum(_, _, let createDestination):
|
||
|
let viewController: UIViewController = createDestination()
|
||
|
navigationController?.pushViewController(viewController, animated: true)
|
||
|
|
||
|
case .listSelection(_, _, let shouldAutoSave, let selectValue):
|
||
|
let maybeOldSelection: (Int, SettingInfo<SettingItem>)? = section.elements
|
||
|
.enumerated()
|
||
|
.first(where: { index, info in
|
||
|
switch info.action {
|
||
|
case .listSelection(let isSelected, _, _, _): return isSelected()
|
||
|
default: return false
|
||
|
}
|
||
|
})
|
||
|
|
||
|
selectValue()
|
||
|
updateNavigation(viewModel.settingsData)
|
||
|
manuallyReload(indexPath: indexPath, section: section, settingInfo: settingInfo)
|
||
|
|
||
|
// Update the old selection as well
|
||
|
if let oldSelection: (index: Int, info: SettingInfo<SettingItem>) = maybeOldSelection {
|
||
|
manuallyReload(
|
||
|
indexPath: IndexPath(
|
||
|
row: oldSelection.index,
|
||
|
section: indexPath.section
|
||
|
),
|
||
|
section: section,
|
||
|
settingInfo: oldSelection.info
|
||
|
)
|
||
|
}
|
||
|
|
||
|
guard shouldAutoSave else { return }
|
||
|
|
||
|
navigationController?.popViewController(animated: true)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
private func manuallyReload(
|
||
|
indexPath: IndexPath,
|
||
|
section: SectionModel,
|
||
|
settingInfo: SettingInfo<SettingItem>
|
||
|
) {
|
||
|
// Try update the existing cell to have a nice animation instead of reloading the cell
|
||
|
if let existingCell: SettingsCell = tableView.cellForRow(at: indexPath) as? SettingsCell {
|
||
|
existingCell.update(
|
||
|
title: settingInfo.title,
|
||
|
subtitle: settingInfo.subtitle,
|
||
|
action: settingInfo.action,
|
||
|
extraActionTitle: settingInfo.extraActionTitle,
|
||
|
onExtraAction: settingInfo.onExtraAction,
|
||
|
isFirstInSection: (indexPath.row == 0),
|
||
|
isLastInSection: (indexPath.row == (section.elements.count - 1))
|
||
|
)
|
||
|
}
|
||
|
else {
|
||
|
tableView.reloadRows(at: [indexPath], with: .none)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// MARK: - NavigationActions
|
||
|
|
||
|
@objc private func cancelButtonPressed() {
|
||
|
navigationController?.popViewController(animated: true)
|
||
|
}
|
||
|
|
||
|
@objc private func saveButtonPressed() {
|
||
|
viewModel.saveChanges()
|
||
|
|
||
|
navigationController?.popViewController(animated: true)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// MARK: - SettingHeaderView
|
||
|
|
||
|
class SettingHeaderView: UITableViewHeaderFooterView {
|
||
|
// MARK: - UI
|
||
|
|
||
|
private let stackView: UIStackView = {
|
||
|
let result: UIStackView = UIStackView()
|
||
|
result.translatesAutoresizingMaskIntoConstraints = false
|
||
|
result.axis = .vertical
|
||
|
result.distribution = .fill
|
||
|
result.alignment = .fill
|
||
|
result.isLayoutMarginsRelativeArrangement = true
|
||
|
result.layoutMargins = UIEdgeInsets(
|
||
|
top: Values.mediumSpacing,
|
||
|
left: Values.largeSpacing,
|
||
|
bottom: Values.smallSpacing,
|
||
|
right: Values.largeSpacing
|
||
|
)
|
||
|
|
||
|
return result
|
||
|
}()
|
||
|
|
||
|
private let titleLabel: UILabel = {
|
||
|
let result: UILabel = UILabel()
|
||
|
result.translatesAutoresizingMaskIntoConstraints = false
|
||
|
result.font = .systemFont(ofSize: Values.mediumFontSize)
|
||
|
result.themeTextColor = .textSecondary
|
||
|
|
||
|
return result
|
||
|
}()
|
||
|
|
||
|
private let separator: UIView = UIView.separator()
|
||
|
|
||
|
// MARK: - Initialization
|
||
|
|
||
|
override init(reuseIdentifier: String?) {
|
||
|
super.init(reuseIdentifier: reuseIdentifier)
|
||
|
|
||
|
self.backgroundView = UIView()
|
||
|
self.backgroundView?.themeBackgroundColor = .backgroundPrimary
|
||
|
|
||
|
addSubview(stackView)
|
||
|
addSubview(separator)
|
||
|
|
||
|
stackView.addArrangedSubview(titleLabel)
|
||
|
|
||
|
setupLayout()
|
||
|
}
|
||
|
|
||
|
required init?(coder: NSCoder) {
|
||
|
fatalError("init(coder:) has not been implemented")
|
||
|
}
|
||
|
|
||
|
private func setupLayout() {
|
||
|
self.heightAnchor.constraint(greaterThanOrEqualToConstant: Values.mediumSpacing).isActive = true
|
||
|
|
||
|
stackView.pin(to: self)
|
||
|
|
||
|
separator.pin(.left, to: .left, of: self)
|
||
|
separator.pin(.right, to: .right, of: self)
|
||
|
separator.pin(.bottom, to: .bottom, of: self)
|
||
|
}
|
||
|
|
||
|
// MARK: - Content
|
||
|
|
||
|
fileprivate func update(with title: String) {
|
||
|
titleLabel.text = title
|
||
|
titleLabel.isHidden = title.isEmpty
|
||
|
}
|
||
|
}
|