// // DeviceManagementContentView.swift // MullvadVPN // // Created by pronebird on 19/07/2022. // Copyright © 2025 Mullvad VPN AB. All rights reserved. // import UIKit class DeviceManagementContentView: UIView { private let scrollView: UIScrollView = { let scrollView = UIScrollView() scrollView.translatesAutoresizingMaskIntoConstraints = false return scrollView }() let scrollContentView: UIView = { let view = UIView() view.directionalLayoutMargins = UIMetrics.contentLayoutMargins view.translatesAutoresizingMaskIntoConstraints = false return view }() let statusImageView: StatusImageView = { let imageView = StatusImageView(style: .failure) imageView.translatesAutoresizingMaskIntoConstraints = false return imageView }() let titleLabel: UILabel = { let textLabel = UILabel() textLabel.font = .mullvadLarge textLabel.adjustsFontForContentSizeCategory = true textLabel.textColor = .white textLabel.translatesAutoresizingMaskIntoConstraints = false return textLabel }() let messageLabel: UILabel = { let textLabel = UILabel() textLabel.font = .mullvadSmall textLabel.adjustsFontForContentSizeCategory = true textLabel.textColor = .white textLabel.translatesAutoresizingMaskIntoConstraints = false textLabel.numberOfLines = 0 textLabel.lineBreakStrategy = [] return textLabel }() let deviceStackView: UIStackView = { let stackView = UIStackView(arrangedSubviews: []) stackView.translatesAutoresizingMaskIntoConstraints = false stackView.axis = .vertical stackView.spacing = 1 stackView.clipsToBounds = true stackView.distribution = .fillEqually return stackView }() let continueButton: AppButton = { let button = AppButton(style: .success) button.translatesAutoresizingMaskIntoConstraints = false button.setTitle( NSLocalizedString("Continue with login", comment: ""), for: .normal ) button.isEnabled = false button.setAccessibilityIdentifier(.continueWithLoginButton) return button }() let cancelButton: AppButton = { let button = AppButton(style: .default) button.translatesAutoresizingMaskIntoConstraints = false button.setTitle( NSLocalizedString("Cancel", comment: ""), for: .normal ) return button }() private lazy var buttonStackView: UIStackView = { let stackView = UIStackView(arrangedSubviews: [continueButton, cancelButton]) stackView.translatesAutoresizingMaskIntoConstraints = false stackView.axis = .vertical stackView.distribution = .fillEqually stackView.spacing = UIMetrics.interButtonSpacing return stackView }() var handleDeviceDeletion: (@Sendable (DeviceViewModel, @escaping @Sendable () -> Void) -> Void)? private var currentDeviceModels = [DeviceViewModel]() var canContinue = false { didSet { updateView() } } override init(frame: CGRect) { super.init(frame: frame) addViews() constraintViews() updateView() setAccessibilityIdentifier(.deviceManagementView) } private func addViews() { try? [scrollView, buttonStackView].forEach(addSubview) scrollView.addSubview(scrollContentView) try? [statusImageView, titleLabel, messageLabel, deviceStackView] .forEach(scrollContentView.addSubview) } private func constraintViews() { NSLayoutConstraint.activate([ scrollView.topAnchor.constraint(equalTo: topAnchor, constant: 16), scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), buttonStackView.topAnchor.constraint( equalTo: scrollView.bottomAnchor, constant: UIMetrics.contentLayoutMargins.top ), buttonStackView.leadingAnchor.constraint( equalTo: leadingAnchor, constant: UIMetrics.contentLayoutMargins.leading ), buttonStackView.trailingAnchor.constraint( equalTo: trailingAnchor, constant: -UIMetrics.contentLayoutMargins.trailing ), buttonStackView.bottomAnchor.constraint( equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -UIMetrics.contentLayoutMargins.bottom ), scrollContentView.topAnchor.constraint(equalTo: scrollView.topAnchor), scrollContentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), scrollContentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), scrollContentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), scrollContentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor), statusImageView.topAnchor .constraint(equalTo: scrollContentView.topAnchor), statusImageView.centerXAnchor.constraint(equalTo: scrollContentView.centerXAnchor), titleLabel.topAnchor.constraint(equalTo: statusImageView.bottomAnchor, constant: 22), titleLabel.leadingAnchor .constraint(equalTo: scrollContentView.layoutMarginsGuide.leadingAnchor), titleLabel.trailingAnchor .constraint(equalTo: scrollContentView.layoutMarginsGuide.trailingAnchor), messageLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8), messageLabel.leadingAnchor .constraint(equalTo: scrollContentView.layoutMarginsGuide.leadingAnchor), messageLabel.trailingAnchor .constraint(equalTo: scrollContentView.layoutMarginsGuide.trailingAnchor), deviceStackView.topAnchor.constraint( equalTo: messageLabel.bottomAnchor, constant: UIMetrics.TableView.sectionSpacing ), deviceStackView.leadingAnchor.constraint(equalTo: scrollContentView.leadingAnchor), deviceStackView.trailingAnchor.constraint(equalTo: scrollContentView.trailingAnchor), deviceStackView.bottomAnchor.constraint(equalTo: scrollContentView.bottomAnchor), ]) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func setDeviceViewModels(_ newModels: [DeviceViewModel], animated: Bool) { let difference = newModels.difference(from: currentDeviceModels) { newModel, model in newModel.id == model.id } currentDeviceModels = newModels var viewsToAdd: [(view: UIView, offset: Int)] = [] var viewsToRemove: [UIView] = [] difference.forEach { change in switch change { case let .insert(offset, model, _): viewsToAdd.append((createDeviceRowView(from: model), offset)) case let .remove(offset, _, _): viewsToRemove.append(deviceStackView.arrangedSubviews[offset]) } } viewsToAdd.forEach { item in deviceStackView.insertArrangedSubview(item.view, at: item.offset) } // Layout inserted subviews before running animations to achieve a folding effect. if animated { UIView.performWithoutAnimation { deviceStackView.layoutIfNeeded() } } if animated { UIView.animate( withDuration: 0.25, delay: 0, options: [.curveEaseInOut], animations: { [weak self] in self?.showHideViews(viewsToAdd: viewsToAdd, viewsToRemove: viewsToRemove) self?.deviceStackView.layoutIfNeeded() }, completion: { [weak self] _ in self?.removeViews(viewsToRemove: viewsToRemove) } ) } else { showHideViews(viewsToAdd: viewsToAdd, viewsToRemove: viewsToRemove) removeViews(viewsToRemove: viewsToRemove) } } private func showHideViews(viewsToAdd: [(view: UIView, offset: Int)], viewsToRemove: [UIView]) { viewsToRemove.forEach { view in view.alpha = 0 view.isHidden = true } viewsToAdd.forEach { item in item.view.alpha = 1 item.view.isHidden = false } } private func removeViews(viewsToRemove: [UIView]) { viewsToRemove.forEach { view in view.removeFromSuperview() } } private func createDeviceRowView(from model: DeviceViewModel) -> DeviceRowView { let view = DeviceRowView(viewModel: model) view.isHidden = true view.alpha = 0 view.deleteHandler = { [weak self] _ in view.showsActivityIndicator = true self?.handleDeviceDeletion?(view.viewModel) { Task { @MainActor in view.showsActivityIndicator = false } } } return view } private func updateView() { titleLabel.text = titleText messageLabel.text = messageText continueButton.isEnabled = canContinue statusImageView.style = canContinue ? .success : .failure } private var titleText: String { if canContinue { return NSLocalizedString("Super!", comment: "") } else { return NSLocalizedString("Too many devices", comment: "") } } private var messageText: String { if canContinue { return NSLocalizedString("You can now continue logging in on this device.", comment: "") } else { return NSLocalizedString( """ Please log out of at least one by removing it from the list below. You can find \ the corresponding device name under the device’s Account settings. """, comment: "") } } }