diff options
Diffstat (limited to 'Sources/WireGuardApp/UI/iOS/View')
-rw-r--r-- | Sources/WireGuardApp/UI/iOS/View/BorderedTextButton.swift | 51 | ||||
-rw-r--r-- | Sources/WireGuardApp/UI/iOS/View/ButtonCell.swift | 55 | ||||
-rw-r--r-- | Sources/WireGuardApp/UI/iOS/View/CheckmarkCell.swift | 31 | ||||
-rw-r--r-- | Sources/WireGuardApp/UI/iOS/View/ChevronCell.swift | 30 | ||||
-rw-r--r-- | Sources/WireGuardApp/UI/iOS/View/EditableTextCell.swift | 71 | ||||
-rw-r--r-- | Sources/WireGuardApp/UI/iOS/View/KeyValueCell.swift | 225 | ||||
-rw-r--r-- | Sources/WireGuardApp/UI/iOS/View/SwitchCell.swift | 56 | ||||
-rw-r--r-- | Sources/WireGuardApp/UI/iOS/View/TextCell.swift | 34 | ||||
-rw-r--r-- | Sources/WireGuardApp/UI/iOS/View/TunnelEditKeyValueCell.swift | 48 | ||||
-rw-r--r-- | Sources/WireGuardApp/UI/iOS/View/TunnelListCell.swift | 162 |
10 files changed, 763 insertions, 0 deletions
diff --git a/Sources/WireGuardApp/UI/iOS/View/BorderedTextButton.swift b/Sources/WireGuardApp/UI/iOS/View/BorderedTextButton.swift new file mode 100644 index 0000000..6f9d55c --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/View/BorderedTextButton.swift @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit + +class BorderedTextButton: UIView { + let button: UIButton = { + let button = UIButton(type: .system) + button.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body) + button.titleLabel?.adjustsFontForContentSizeCategory = true + return button + }() + + override var intrinsicContentSize: CGSize { + let buttonSize = button.intrinsicContentSize + return CGSize(width: buttonSize.width + 32, height: buttonSize.height + 16) + } + + var title: String { + get { return button.title(for: .normal) ?? "" } + set(value) { button.setTitle(value, for: .normal) } + } + + var onTapped: (() -> Void)? + + init() { + super.init(frame: CGRect.zero) + + layer.borderWidth = 1 + layer.cornerRadius = 5 + layer.borderColor = button.tintColor.cgColor + + addSubview(button) + button.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + button.centerXAnchor.constraint(equalTo: centerXAnchor), + button.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) + + button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc func buttonTapped() { + onTapped?() + } + +} diff --git a/Sources/WireGuardApp/UI/iOS/View/ButtonCell.swift b/Sources/WireGuardApp/UI/iOS/View/ButtonCell.swift new file mode 100644 index 0000000..ae7a144 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/View/ButtonCell.swift @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit + +class ButtonCell: UITableViewCell { + var buttonText: String { + get { return button.title(for: .normal) ?? "" } + set(value) { button.setTitle(value, for: .normal) } + } + var hasDestructiveAction: Bool { + get { return button.tintColor == .systemRed } + set(value) { button.tintColor = value ? .systemRed : buttonStandardTintColor } + } + var onTapped: (() -> Void)? + + let button: UIButton = { + let button = UIButton(type: .system) + button.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body) + button.titleLabel?.adjustsFontForContentSizeCategory = true + return button + }() + + var buttonStandardTintColor: UIColor + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + buttonStandardTintColor = button.tintColor + super.init(style: style, reuseIdentifier: reuseIdentifier) + + contentView.addSubview(button) + button.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + button.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor), + contentView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: button.bottomAnchor), + button.centerXAnchor.constraint(equalTo: contentView.centerXAnchor) + ]) + + button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) + } + + @objc func buttonTapped() { + onTapped?() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + buttonText = "" + onTapped = nil + hasDestructiveAction = false + } +} diff --git a/Sources/WireGuardApp/UI/iOS/View/CheckmarkCell.swift b/Sources/WireGuardApp/UI/iOS/View/CheckmarkCell.swift new file mode 100644 index 0000000..fedef53 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/View/CheckmarkCell.swift @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit + +class CheckmarkCell: UITableViewCell { + var message: String { + get { return textLabel?.text ?? "" } + set(value) { textLabel!.text = value } + } + var isChecked: Bool { + didSet { + accessoryType = isChecked ? .checkmark : .none + } + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + isChecked = false + super.init(style: .default, reuseIdentifier: reuseIdentifier) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + message = "" + isChecked = false + } +} diff --git a/Sources/WireGuardApp/UI/iOS/View/ChevronCell.swift b/Sources/WireGuardApp/UI/iOS/View/ChevronCell.swift new file mode 100644 index 0000000..0429fb9 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/View/ChevronCell.swift @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit + +class ChevronCell: UITableViewCell { + var message: String { + get { return textLabel?.text ?? "" } + set(value) { textLabel?.text = value } + } + + var detailMessage: String { + get { return detailTextLabel?.text ?? "" } + set(value) { detailTextLabel?.text = value } + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: .value1, reuseIdentifier: reuseIdentifier) + accessoryType = .disclosureIndicator + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + message = "" + } +} diff --git a/Sources/WireGuardApp/UI/iOS/View/EditableTextCell.swift b/Sources/WireGuardApp/UI/iOS/View/EditableTextCell.swift new file mode 100644 index 0000000..e065b4f --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/View/EditableTextCell.swift @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit + +class EditableTextCell: UITableViewCell { + var message: String { + get { return valueTextField.text ?? "" } + set(value) { valueTextField.text = value } + } + + var placeholder: String? { + get { return valueTextField.placeholder } + set(value) { valueTextField.placeholder = value } + } + + let valueTextField: UITextField = { + let valueTextField = UITextField() + valueTextField.textAlignment = .left + valueTextField.isEnabled = true + valueTextField.font = UIFont.preferredFont(forTextStyle: .body) + valueTextField.adjustsFontForContentSizeCategory = true + valueTextField.autocapitalizationType = .none + valueTextField.autocorrectionType = .no + valueTextField.spellCheckingType = .no + return valueTextField + }() + + var onValueBeingEdited: ((String) -> Void)? + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + valueTextField.delegate = self + contentView.addSubview(valueTextField) + valueTextField.translatesAutoresizingMaskIntoConstraints = false + // Reduce the bottom margin by 0.5pt to maintain the default cell height (44pt) + let bottomAnchorConstraint = contentView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: valueTextField.bottomAnchor, constant: -0.5) + bottomAnchorConstraint.priority = .defaultLow + NSLayoutConstraint.activate([ + valueTextField.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + contentView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: valueTextField.trailingAnchor), + contentView.layoutMarginsGuide.topAnchor.constraint(equalTo: valueTextField.topAnchor), + bottomAnchorConstraint + ]) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func beginEditing() { + valueTextField.becomeFirstResponder() + } + + override func prepareForReuse() { + super.prepareForReuse() + message = "" + placeholder = nil + } +} + +extension EditableTextCell: UITextFieldDelegate { + func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + if let onValueBeingEdited = onValueBeingEdited { + let modifiedText = ((textField.text ?? "") as NSString).replacingCharacters(in: range, with: string) + onValueBeingEdited(modifiedText) + } + return true + } +} diff --git a/Sources/WireGuardApp/UI/iOS/View/KeyValueCell.swift b/Sources/WireGuardApp/UI/iOS/View/KeyValueCell.swift new file mode 100644 index 0000000..ab1a71f --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/View/KeyValueCell.swift @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit + +class KeyValueCell: UITableViewCell { + + let keyLabel: UILabel = { + let keyLabel = UILabel() + keyLabel.font = UIFont.preferredFont(forTextStyle: .body) + keyLabel.adjustsFontForContentSizeCategory = true + keyLabel.textColor = .label + keyLabel.textAlignment = .left + return keyLabel + }() + + let valueLabelScrollView: UIScrollView = { + let scrollView = UIScrollView(frame: .zero) + scrollView.isDirectionalLockEnabled = true + scrollView.showsHorizontalScrollIndicator = false + scrollView.showsVerticalScrollIndicator = false + return scrollView + }() + + let valueTextField: UITextField = { + let valueTextField = KeyValueCellTextField() + valueTextField.textAlignment = .right + valueTextField.isEnabled = false + valueTextField.font = UIFont.preferredFont(forTextStyle: .body) + valueTextField.adjustsFontForContentSizeCategory = true + valueTextField.autocapitalizationType = .none + valueTextField.autocorrectionType = .no + valueTextField.spellCheckingType = .no + valueTextField.textColor = .secondaryLabel + return valueTextField + }() + + var copyableGesture = true + + var key: String { + get { return keyLabel.text ?? "" } + set(value) { keyLabel.text = value } + } + var value: String { + get { return valueTextField.text ?? "" } + set(value) { valueTextField.text = value } + } + var placeholderText: String { + get { return valueTextField.placeholder ?? "" } + set(value) { valueTextField.placeholder = value } + } + var keyboardType: UIKeyboardType { + get { return valueTextField.keyboardType } + set(value) { valueTextField.keyboardType = value } + } + + var isValueValid = true { + didSet { + if isValueValid { + keyLabel.textColor = .label + } else { + keyLabel.textColor = .systemRed + } + } + } + + var isStackedHorizontally = false + var isStackedVertically = false + var contentSizeBasedConstraints = [NSLayoutConstraint]() + + var onValueChanged: ((String, String) -> Void)? + var onValueBeingEdited: ((String) -> Void)? + + var observationToken: AnyObject? + + private var textFieldValueOnBeginEditing: String = "" + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + contentView.addSubview(keyLabel) + keyLabel.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + keyLabel.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + keyLabel.topAnchor.constraint(equalToSystemSpacingBelow: contentView.layoutMarginsGuide.topAnchor, multiplier: 0.5) + ]) + + valueTextField.delegate = self + valueLabelScrollView.addSubview(valueTextField) + valueTextField.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + valueTextField.leadingAnchor.constraint(equalTo: valueLabelScrollView.contentLayoutGuide.leadingAnchor), + valueTextField.topAnchor.constraint(equalTo: valueLabelScrollView.contentLayoutGuide.topAnchor), + valueTextField.bottomAnchor.constraint(equalTo: valueLabelScrollView.contentLayoutGuide.bottomAnchor), + valueTextField.trailingAnchor.constraint(equalTo: valueLabelScrollView.contentLayoutGuide.trailingAnchor), + valueTextField.heightAnchor.constraint(equalTo: valueLabelScrollView.heightAnchor) + ]) + let expandToFitValueLabelConstraint = NSLayoutConstraint(item: valueTextField, attribute: .width, relatedBy: .equal, toItem: valueLabelScrollView, attribute: .width, multiplier: 1, constant: 0) + expandToFitValueLabelConstraint.priority = .defaultLow + 1 + expandToFitValueLabelConstraint.isActive = true + + contentView.addSubview(valueLabelScrollView) + valueLabelScrollView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + valueLabelScrollView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), + contentView.layoutMarginsGuide.bottomAnchor.constraint(equalToSystemSpacingBelow: valueLabelScrollView.bottomAnchor, multiplier: 0.5) + ]) + + keyLabel.setContentCompressionResistancePriority(.defaultHigh + 1, for: .horizontal) + keyLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + valueLabelScrollView.setContentHuggingPriority(.defaultLow, for: .horizontal) + + let gestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTapGesture(_:))) + addGestureRecognizer(gestureRecognizer) + isUserInteractionEnabled = true + + configureForContentSize() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configureForContentSize() { + var constraints = [NSLayoutConstraint]() + if traitCollection.preferredContentSizeCategory.isAccessibilityCategory { + // Stack vertically + if !isStackedVertically { + constraints = [ + valueLabelScrollView.topAnchor.constraint(equalToSystemSpacingBelow: keyLabel.bottomAnchor, multiplier: 0.5), + valueLabelScrollView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + keyLabel.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor) + ] + isStackedVertically = true + isStackedHorizontally = false + } + } else { + // Stack horizontally + if !isStackedHorizontally { + constraints = [ + contentView.layoutMarginsGuide.bottomAnchor.constraint(equalToSystemSpacingBelow: keyLabel.bottomAnchor, multiplier: 0.5), + valueLabelScrollView.leadingAnchor.constraint(equalToSystemSpacingAfter: keyLabel.trailingAnchor, multiplier: 1), + valueLabelScrollView.topAnchor.constraint(equalToSystemSpacingBelow: contentView.layoutMarginsGuide.topAnchor, multiplier: 0.5) + ] + isStackedHorizontally = true + isStackedVertically = false + } + } + if !constraints.isEmpty { + NSLayoutConstraint.deactivate(contentSizeBasedConstraints) + NSLayoutConstraint.activate(constraints) + contentSizeBasedConstraints = constraints + } + } + + @objc func handleTapGesture(_ recognizer: UIGestureRecognizer) { + if !copyableGesture { + return + } + guard recognizer.state == .recognized else { return } + + if let recognizerView = recognizer.view, + let recognizerSuperView = recognizerView.superview, recognizerView.becomeFirstResponder() { + let menuController = UIMenuController.shared + menuController.setTargetRect(detailTextLabel?.frame ?? recognizerView.frame, in: detailTextLabel?.superview ?? recognizerSuperView) + menuController.setMenuVisible(true, animated: true) + } + } + + override var canBecomeFirstResponder: Bool { + return true + } + + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + return (action == #selector(UIResponderStandardEditActions.copy(_:))) + } + + override func copy(_ sender: Any?) { + UIPasteboard.general.string = valueTextField.text + } + + override func prepareForReuse() { + super.prepareForReuse() + copyableGesture = true + placeholderText = "" + isValueValid = true + keyboardType = .default + onValueChanged = nil + onValueBeingEdited = nil + observationToken = nil + key = "" + value = "" + configureForContentSize() + } +} + +extension KeyValueCell: UITextFieldDelegate { + + func textFieldDidBeginEditing(_ textField: UITextField) { + textFieldValueOnBeginEditing = textField.text ?? "" + isValueValid = true + } + + func textFieldDidEndEditing(_ textField: UITextField) { + let isModified = textField.text ?? "" != textFieldValueOnBeginEditing + guard isModified else { return } + onValueChanged?(textFieldValueOnBeginEditing, textField.text ?? "") + } + + func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + if let onValueBeingEdited = onValueBeingEdited { + let modifiedText = ((textField.text ?? "") as NSString).replacingCharacters(in: range, with: string) + onValueBeingEdited(modifiedText) + } + return true + } + +} + +class KeyValueCellTextField: UITextField { + override func placeholderRect(forBounds bounds: CGRect) -> CGRect { + // UIKit renders the placeholder label 0.5pt higher + return super.placeholderRect(forBounds: bounds).integral.offsetBy(dx: 0, dy: -0.5) + } +} diff --git a/Sources/WireGuardApp/UI/iOS/View/SwitchCell.swift b/Sources/WireGuardApp/UI/iOS/View/SwitchCell.swift new file mode 100644 index 0000000..4bedbba --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/View/SwitchCell.swift @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit + +class SwitchCell: UITableViewCell { + var message: String { + get { return textLabel?.text ?? "" } + set(value) { textLabel?.text = value } + } + var isOn: Bool { + get { return switchView.isOn } + set(value) { switchView.isOn = value } + } + var isEnabled: Bool { + get { return switchView.isEnabled } + set(value) { + switchView.isEnabled = value + textLabel?.textColor = value ? .label : .secondaryLabel + } + } + + var onSwitchToggled: ((Bool) -> Void)? + + var statusObservationToken: AnyObject? + var isOnDemandEnabledObservationToken: AnyObject? + var hasOnDemandRulesObservationToken: AnyObject? + + let switchView = UISwitch() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: .default, reuseIdentifier: reuseIdentifier) + + accessoryView = switchView + switchView.addTarget(self, action: #selector(switchToggled), for: .valueChanged) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc func switchToggled() { + onSwitchToggled?(switchView.isOn) + } + + override func prepareForReuse() { + super.prepareForReuse() + onSwitchToggled = nil + isEnabled = true + message = "" + isOn = false + statusObservationToken = nil + isOnDemandEnabledObservationToken = nil + hasOnDemandRulesObservationToken = nil + } +} diff --git a/Sources/WireGuardApp/UI/iOS/View/TextCell.swift b/Sources/WireGuardApp/UI/iOS/View/TextCell.swift new file mode 100644 index 0000000..3024b46 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/View/TextCell.swift @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit + +class TextCell: UITableViewCell { + var message: String { + get { return textLabel?.text ?? "" } + set(value) { textLabel!.text = value } + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: .default, reuseIdentifier: reuseIdentifier) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setTextColor(_ color: UIColor) { + textLabel?.textColor = color + } + + func setTextAlignment(_ alignment: NSTextAlignment) { + textLabel?.textAlignment = alignment + } + + override func prepareForReuse() { + super.prepareForReuse() + message = "" + setTextColor(.label) + setTextAlignment(.left) + } +} diff --git a/Sources/WireGuardApp/UI/iOS/View/TunnelEditKeyValueCell.swift b/Sources/WireGuardApp/UI/iOS/View/TunnelEditKeyValueCell.swift new file mode 100644 index 0000000..d151402 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/View/TunnelEditKeyValueCell.swift @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit + +class TunnelEditKeyValueCell: KeyValueCell { + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + keyLabel.textAlignment = .right + valueTextField.textAlignment = .left + + let widthRatioConstraint = NSLayoutConstraint(item: keyLabel, attribute: .width, relatedBy: .equal, toItem: self, attribute: .width, multiplier: 0.4, constant: 0) + // In case the key doesn't fit into 0.4 * width, + // set a CR priority > the 0.4-constraint's priority. + widthRatioConstraint.priority = .defaultHigh + 1 + widthRatioConstraint.isActive = true + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + +class TunnelEditEditableKeyValueCell: TunnelEditKeyValueCell { + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + copyableGesture = false + valueTextField.textColor = .label + valueTextField.isEnabled = true + valueLabelScrollView.isScrollEnabled = false + valueTextField.widthAnchor.constraint(equalTo: valueLabelScrollView.widthAnchor).isActive = true + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + copyableGesture = false + } + +} diff --git a/Sources/WireGuardApp/UI/iOS/View/TunnelListCell.swift b/Sources/WireGuardApp/UI/iOS/View/TunnelListCell.swift new file mode 100644 index 0000000..71ee6d8 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/View/TunnelListCell.swift @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit + +class TunnelListCell: UITableViewCell { + var tunnel: TunnelContainer? { + didSet { + // Bind to the tunnel's name + nameLabel.text = tunnel?.name ?? "" + nameObservationToken = tunnel?.observe(\.name) { [weak self] tunnel, _ in + self?.nameLabel.text = tunnel.name + } + // Bind to the tunnel's status + update(from: tunnel, animated: false) + statusObservationToken = tunnel?.observe(\.status) { [weak self] tunnel, _ in + self?.update(from: tunnel, animated: true) + } + // Bind to tunnel's on-demand settings + isOnDemandEnabledObservationToken = tunnel?.observe(\.isActivateOnDemandEnabled) { [weak self] tunnel, _ in + self?.update(from: tunnel, animated: true) + } + hasOnDemandRulesObservationToken = tunnel?.observe(\.hasOnDemandRules) { [weak self] tunnel, _ in + self?.update(from: tunnel, animated: true) + } + } + } + var onSwitchToggled: ((Bool) -> Void)? + + let nameLabel: UILabel = { + let nameLabel = UILabel() + nameLabel.font = UIFont.preferredFont(forTextStyle: .body) + nameLabel.adjustsFontForContentSizeCategory = true + nameLabel.numberOfLines = 0 + return nameLabel + }() + + let onDemandLabel: UILabel = { + let label = UILabel() + label.text = "" + label.font = UIFont.preferredFont(forTextStyle: .caption2) + label.adjustsFontForContentSizeCategory = true + label.numberOfLines = 1 + label.textColor = .secondaryLabel + return label + }() + + let busyIndicator: UIActivityIndicatorView = { + let busyIndicator: UIActivityIndicatorView + busyIndicator = UIActivityIndicatorView(style: .medium) + busyIndicator.hidesWhenStopped = true + return busyIndicator + }() + + let statusSwitch = UISwitch() + + private var nameObservationToken: NSKeyValueObservation? + private var statusObservationToken: NSKeyValueObservation? + private var isOnDemandEnabledObservationToken: NSKeyValueObservation? + private var hasOnDemandRulesObservationToken: NSKeyValueObservation? + + private var subTitleLabelBottomConstraint: NSLayoutConstraint? + private var nameLabelBottomConstraint: NSLayoutConstraint? + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + accessoryType = .disclosureIndicator + + for subview in [statusSwitch, busyIndicator, onDemandLabel, nameLabel] { + subview.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(subview) + } + + nameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + onDemandLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + + let nameLabelBottomConstraint = + contentView.layoutMarginsGuide.bottomAnchor.constraint(equalToSystemSpacingBelow: nameLabel.bottomAnchor, multiplier: 1) + nameLabelBottomConstraint.priority = .defaultLow + + NSLayoutConstraint.activate([ + statusSwitch.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + statusSwitch.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), + statusSwitch.leadingAnchor.constraint(equalToSystemSpacingAfter: busyIndicator.trailingAnchor, multiplier: 1), + statusSwitch.leadingAnchor.constraint(equalToSystemSpacingAfter: onDemandLabel.trailingAnchor, multiplier: 1), + + nameLabel.topAnchor.constraint(equalToSystemSpacingBelow: contentView.layoutMarginsGuide.topAnchor, multiplier: 1), + nameLabel.leadingAnchor.constraint(equalToSystemSpacingAfter: contentView.layoutMarginsGuide.leadingAnchor, multiplier: 1), + nameLabel.trailingAnchor.constraint(lessThanOrEqualTo: statusSwitch.leadingAnchor), + nameLabelBottomConstraint, + + onDemandLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + onDemandLabel.leadingAnchor.constraint(equalToSystemSpacingAfter: nameLabel.trailingAnchor, multiplier: 1), + + busyIndicator.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + busyIndicator.leadingAnchor.constraint(greaterThanOrEqualToSystemSpacingAfter: nameLabel.trailingAnchor, multiplier: 1) + ]) + + statusSwitch.addTarget(self, action: #selector(switchToggled), for: .valueChanged) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + reset(animated: false) + } + + override func setEditing(_ editing: Bool, animated: Bool) { + super.setEditing(editing, animated: animated) + statusSwitch.isEnabled = !editing + } + + @objc private func switchToggled() { + onSwitchToggled?(statusSwitch.isOn) + } + + private func update(from tunnel: TunnelContainer?, animated: Bool) { + guard let tunnel = tunnel else { + reset(animated: animated) + return + } + let status = tunnel.status + let isOnDemandEngaged = tunnel.isActivateOnDemandEnabled + + let shouldSwitchBeOn = ((status != .deactivating && status != .inactive) || isOnDemandEngaged) + statusSwitch.setOn(shouldSwitchBeOn, animated: true) + + if isOnDemandEngaged && !(status == .activating || status == .active) { + statusSwitch.onTintColor = UIColor.systemYellow + } else { + statusSwitch.onTintColor = UIColor.systemGreen + } + + statusSwitch.isUserInteractionEnabled = (status == .inactive || status == .active) + + if tunnel.hasOnDemandRules { + onDemandLabel.text = isOnDemandEngaged ? tr("tunnelListCaptionOnDemand") : "" + busyIndicator.stopAnimating() + statusSwitch.isUserInteractionEnabled = true + } else { + onDemandLabel.text = "" + if status == .inactive || status == .active { + busyIndicator.stopAnimating() + } else { + busyIndicator.startAnimating() + } + statusSwitch.isUserInteractionEnabled = (status == .inactive || status == .active) + } + + } + + private func reset(animated: Bool) { + statusSwitch.thumbTintColor = nil + statusSwitch.setOn(false, animated: animated) + statusSwitch.isUserInteractionEnabled = false + busyIndicator.stopAnimating() + } +} |