diff options
| author | Andrew Bulhak <andrew.bulhak@mullvad.net> | 2025-08-18 16:12:41 +0200 |
|---|---|---|
| committer | Bug Magnet <marco.nikic@mullvad.net> | 2025-09-09 11:55:51 +0200 |
| commit | f605b90956897c87f6a18c1e90d8db2894de6ff8 (patch) | |
| tree | 575b032c60246cddeba751ae0d4853ad0f9dc961 /ios/MullvadVPN/View controllers | |
| parent | 1b702ff2c9f409cdfe455bbf41a81bca25b1f384 (diff) | |
| download | mullvadvpn-f605b90956897c87f6a18c1e90d8db2894de6ff8.tar.xz mullvadvpn-f605b90956897c87f6a18c1e90d8db2894de6ff8.zip | |
Implement SwiftUI-based AccountDeletionView and ActivityIndicator
Diffstat (limited to 'ios/MullvadVPN/View controllers')
5 files changed, 217 insertions, 514 deletions
diff --git a/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionContentView.swift b/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionContentView.swift deleted file mode 100644 index 3b8e04d258..0000000000 --- a/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionContentView.swift +++ /dev/null @@ -1,369 +0,0 @@ -// -// AccountDeletionContentView.swift -// MullvadVPN -// -// Created by Mojgan on 2023-07-13. -// Copyright © 2025 Mullvad VPN AB. All rights reserved. -// - -import MullvadREST -import UIKit - -protocol AccountDeletionContentViewDelegate: AnyObject { - func didTapDeleteButton(contentView: AccountDeletionContentView, button: AppButton) - func didTapCancelButton(contentView: AccountDeletionContentView, button: AppButton) -} - -class AccountDeletionContentView: UIView { - enum State { - case initial - case loading - case failure(Error) - } - - private let scrollView: UIScrollView = { - let scrollView = UIScrollView() - return scrollView - }() - - private let contentHolderView: UIView = { - let contentHolderView = UIView() - return contentHolderView - }() - - private let titleLabel: UILabel = { - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false - label.font = .mullvadLarge - label.numberOfLines = .zero - label.adjustsFontForContentSizeCategory = true - label.lineBreakMode = .byWordWrapping - label.textColor = .white - label.text = NSLocalizedString("Account deletion", comment: "") - return label - }() - - private let messageLabel: UILabel = { - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false - label.font = .mullvadSmallSemiBold - label.numberOfLines = .zero - label.adjustsFontForContentSizeCategory = true - label.lineBreakMode = .byWordWrapping - label.textColor = .white - return label - }() - - private let tipLabel: UILabel = { - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false - label.font = .mullvadMiniSemiBold - label.numberOfLines = .zero - label.adjustsFontForContentSizeCategory = true - label.lineBreakMode = .byWordWrapping - label.textColor = .white - label.text = NSLocalizedString( - """ - This logs out all devices using this account and all \ - VPN access will be denied even if there is time left on the account. \ - Enter the last 4 digits of the account number and hit "Delete account" \ - if you really want to delete the account: - """, - comment: "" - ) - return label - }() - - private lazy var accountTextField: AccountTextField = { - let groupingStyle = AccountTextField.GroupingStyle.lastPart - let textField = AccountTextField(groupingStyle: groupingStyle) - textField.setAccessibilityIdentifier(.deleteAccountTextField) - textField.font = .mullvadSmallSemiBold - textField.placeholder = Array(repeating: "X", count: 4).joined() - textField.placeholderTextColor = .lightGray - textField.textContentType = .username - textField.autocorrectionType = .no - textField.smartDashesType = .no - textField.smartInsertDeleteType = .no - textField.smartQuotesType = .no - textField.spellCheckingType = .no - textField.keyboardType = .numberPad - textField.returnKeyType = .done - textField.enablesReturnKeyAutomatically = false - textField.adjustsFontForContentSizeCategory = true - textField.backgroundColor = .white - textField.borderStyle = .line - return textField - }() - - private let deleteButton: AppButton = { - let button = AppButton(style: .danger) - button.setAccessibilityIdentifier(.deleteButton) - button.setTitle(NSLocalizedString("Delete Account", comment: ""), for: .normal) - return button - }() - - private let cancelButton: AppButton = { - let button = AppButton(style: .default) - button.setAccessibilityIdentifier(.cancelButton) - button.setTitle(NSLocalizedString("Cancel", comment: ""), for: .normal) - return button - }() - - private lazy var textsStack: UIStackView = { - let stackView = UIStackView(arrangedSubviews: [ - titleLabel, - messageLabel, - tipLabel, - accountTextField, - statusStack, - ]) - stackView.setCustomSpacing(UIMetrics.padding8, after: titleLabel) - stackView.setCustomSpacing(UIMetrics.padding16, after: messageLabel) - stackView.setCustomSpacing(UIMetrics.padding8, after: tipLabel) - stackView.setCustomSpacing(UIMetrics.padding4, after: accountTextField) - stackView.setContentHuggingPriority(.defaultLow, for: .vertical) - stackView.axis = .vertical - return stackView - }() - - private lazy var buttonsStack: UIStackView = { - let stackView = UIStackView(arrangedSubviews: [deleteButton, cancelButton]) - stackView.axis = .vertical - stackView.spacing = UIMetrics.padding16 - stackView.setContentCompressionResistancePriority(.required, for: .vertical) - return stackView - }() - - private let activityIndicator: SpinnerActivityIndicatorView = { - let activityIndicator = SpinnerActivityIndicatorView(style: .medium) - activityIndicator.translatesAutoresizingMaskIntoConstraints = false - activityIndicator.tintColor = .white - activityIndicator.setContentHuggingPriority(.defaultHigh, for: .horizontal) - activityIndicator.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) - return activityIndicator - }() - - private let statusLabel: UILabel = { - let label = UILabel() - label.font = .preferredFont(forTextStyle: .body) - label.numberOfLines = 2 - label.adjustsFontForContentSizeCategory = true - label.lineBreakMode = .byWordWrapping - label.textColor = .red - label.setContentHuggingPriority(.defaultLow, for: .horizontal) - label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - label.lineBreakStrategy = [] - return label - }() - - private lazy var statusStack: UIStackView = { - let stackView = UIStackView(arrangedSubviews: [activityIndicator, statusLabel]) - stackView.axis = .horizontal - stackView.spacing = UIMetrics.padding8 - return stackView - }() - - private var keyboardResponder: AutomaticKeyboardResponder? - private var bottomsOfButtonsConstraint: NSLayoutConstraint? - - var state: State = .initial { - didSet { - updateUI() - } - } - - var isEditing: Bool { - get { - accountTextField.isEditing - } - set { - guard accountTextField.isFirstResponder != newValue else { return } - if newValue { - accountTextField.becomeFirstResponder() - } else { - accountTextField.resignFirstResponder() - } - } - } - - var viewModel: AccountDeletionViewModel? { - didSet { - updateData() - } - } - - var lastPartOfAccountNumber: String { - accountTextField.parsedToken - } - - private var text: String { - switch state { - case let .failure(error): - return error.localizedDescription - case .loading: - return NSLocalizedString("Deleting account...", comment: "") - default: return "" - } - } - - private var isDeleteButtonEnabled: Bool { - switch state { - case .initial, .failure: - return true - case .loading: - return false - } - } - - private var textColor: UIColor { - switch state { - case .failure: - return .dangerColor - default: - return .white - } - } - - private var isLoading: Bool { - switch state { - case .loading: - return true - default: - return false - } - } - - private var isInputValid: Bool { - guard let input = accountTextField.text, - let accountNumber = viewModel?.accountNumber, - !accountNumber.isEmpty - else { - return false - } - - let inputLengthIsValid = input.count == 4 - let inputMatchesAccountNumber = accountNumber.suffix(4) == input - - return inputLengthIsValid && inputMatchesAccountNumber - } - - weak var delegate: AccountDeletionContentViewDelegate? - - override init(frame: CGRect) { - super.init(frame: .zero) - commonInit() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - commonInit() - } - - private func commonInit() { - setupAppearance() - configureUI() - addActions() - updateUI() - addKeyboardResponder() - addObservers() - } - - private func configureUI() { - addConstrainedSubviews([scrollView]) { - scrollView.pinEdgesToSuperviewMargins() - } - - scrollView.addConstrainedSubviews([contentHolderView]) { - contentHolderView.pinEdgesToSuperview() - contentHolderView.widthAnchor.constraint(equalTo: scrollView.widthAnchor, multiplier: 1.0) - contentHolderView.heightAnchor.constraint(greaterThanOrEqualTo: scrollView.heightAnchor, multiplier: 1.0) - } - contentHolderView.addConstrainedSubviews([textsStack, buttonsStack]) { - textsStack.pinEdgesToSuperview(.all().excluding(.bottom)) - buttonsStack.pinEdgesToSuperview(PinnableEdges([.leading(.zero), .trailing(.zero)])) - textsStack.bottomAnchor.constraint( - lessThanOrEqualTo: buttonsStack.topAnchor, - constant: -UIMetrics.padding16 - ) - } - bottomsOfButtonsConstraint = buttonsStack.pinEdgesToSuperview(PinnableEdges([.bottom(.zero)])).first - bottomsOfButtonsConstraint?.isActive = true - } - - private func addActions() { - [deleteButton, cancelButton].forEach { $0.addTarget( - self, - action: #selector(didPress(button:)), - for: .touchUpInside - ) } - } - - private func updateData() { - viewModel.flatMap { viewModel in - let text = NSLocalizedString( - """ - Are you sure you want to delete account **\(viewModel.accountNumber)**? - """, - comment: "" - ) - messageLabel.attributedText = NSAttributedString( - markdownString: text, - options: MarkdownStylingOptions(font: .preferredFont(forTextStyle: .body)) - ) - } - } - - private func updateUI() { - if isLoading { - activityIndicator.startAnimating() - } else { - activityIndicator.stopAnimating() - } - deleteButton.isEnabled = isDeleteButtonEnabled && isInputValid - statusLabel.text = text - statusLabel.textColor = textColor - } - - private func setupAppearance() { - setAccessibilityIdentifier(.deleteAccountView) - translatesAutoresizingMaskIntoConstraints = false - backgroundColor = .secondaryColor - directionalLayoutMargins = UIMetrics.contentLayoutMargins - } - - private func addKeyboardResponder() { - keyboardResponder = AutomaticKeyboardResponder( - targetView: self, - handler: { [weak self] _, offset in - guard let self else { return } - self.bottomsOfButtonsConstraint?.constant = isEditing ? -offset : 0 - self.layoutIfNeeded() - self.scrollView.flashScrollIndicators() - } - ) - } - - private func addObservers() { - NotificationCenter.default.addObserver( - self, - selector: #selector(textDidChange), - name: UITextField.textDidChangeNotification, - object: accountTextField - ) - } - - @objc private func didPress(button: AppButton) { - switch button.accessibilityIdentifier { - case AccessibilityIdentifier.deleteButton.asString: - delegate?.didTapDeleteButton(contentView: self, button: button) - case AccessibilityIdentifier.cancelButton.asString: - delegate?.didTapCancelButton(contentView: self, button: button) - default: return - } - } - - @objc private func textDidChange() { - updateUI() - } -} diff --git a/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionInteractor.swift b/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionInteractor.swift deleted file mode 100644 index 7935ead5bb..0000000000 --- a/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionInteractor.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// AccountDeletionInteractor.swift -// MullvadVPN -// -// Created by Mojgan on 2023-07-13. -// Copyright © 2025 Mullvad VPN AB. All rights reserved. -// - -import Foundation -import MullvadREST -import MullvadTypes - -enum AccountDeletionError: LocalizedError { - case invalidInput - - var errorDescription: String? { - switch self { - case .invalidInput: - return NSLocalizedString("Last four digits of the account number are incorrect", comment: "") - } - } -} - -final class AccountDeletionInteractor: Sendable { - private let tunnelManager: TunnelManager - var viewModel: AccountDeletionViewModel { - AccountDeletionViewModel( - accountNumber: tunnelManager.deviceState.accountData?.number.formattedAccountNumber ?? "" - ) - } - - init(tunnelManager: TunnelManager) { - self.tunnelManager = tunnelManager - } - - func validate(input: String) -> Result<String, Error> { - if let accountNumber = tunnelManager.deviceState.accountData?.number, - let fourLastDigits = accountNumber.split(every: 4).last, - fourLastDigits == input { - return .success(accountNumber) - } else { - return .failure(AccountDeletionError.invalidInput) - } - } - - func delete(accountNumber: String) async throws { - try await tunnelManager.deleteAccount(accountNumber: accountNumber) - } -} diff --git a/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionView.swift b/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionView.swift new file mode 100644 index 0000000000..73fd1a7cb3 --- /dev/null +++ b/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionView.swift @@ -0,0 +1,80 @@ +// +// AccountDeletionView.swift +// MullvadVPN +// +// Created by Andrew Bulhak on 2025-08-13. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +struct AccountDeletionView: View { + @ObservedObject var viewModel: AccountDeletionViewModel + + @ScaledMetric var spinnerSize = 20.0 + @ScaledMetric var spinnerStatusGap = 10.0 + + var body: some View { + ScrollView { + VStack(alignment: .leading) { + Text("Account deletion") + .font(.mullvadLarge) + .foregroundStyle(Color.white) + .padding(.bottom, 8) + + Text(viewModel.messageText) + .foregroundStyle(Color.white) + .padding(.bottom, 8) + + Text( + """ + This logs out all devices using this account and all \ + VPN access will be denied even if there is time left on the account. \ + Enter the last 4 digits of the account number and hit "Delete account" \ + if you really want to delete the account: + """ + ) + .font(.mullvadSmallSemiBold) + .foregroundStyle(Color.white) + .padding(.bottom, 8) + + // accountTextField + MullvadPrimaryTextField( + label: "Last 4 digits", placeholder: "XXXX", text: $viewModel.enteredAccountNumberSuffix, + keyboardType: .numberPad + ) + .padding(.bottom, 4) + + // Status information + HStack { + if viewModel.isWorking { + ProgressView() + .progressViewStyle(MullvadProgressViewStyle(size: spinnerSize)) + Spacer().frame(width: spinnerStatusGap) + } + + Text(viewModel.statusText) + .font(.mullvadSmall) + .foregroundStyle(Color.white) + } + + Spacer() + + MainButton(text: "Delete Account", style: .danger) { + viewModel.deleteButtonTapped() + } + .disabled(!viewModel.canDelete) + + MainButton(text: "Cancel", style: .default) { + viewModel.cancelButtonTapped() + } + } + } + .padding(16) + .background(Color.mullvadBackground) + } +} + +#Preview { + AccountDeletionView(viewModel: AccountDeletionViewModel(mockAccountNumber: "1234567890123456")) +} diff --git a/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionViewController.swift b/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionViewController.swift deleted file mode 100644 index ac59ac0917..0000000000 --- a/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionViewController.swift +++ /dev/null @@ -1,94 +0,0 @@ -// -// AccountDeletionViewController.swift -// MullvadVPN -// -// Created by Mojgan on 2023-07-13. -// Copyright © 2025 Mullvad VPN AB. All rights reserved. -// - -import MullvadTypes -import UIKit - -protocol AccountDeletionViewControllerDelegate: AnyObject { - func deleteAccountDidSucceed(controller: AccountDeletionViewController) - func deleteAccountDidCancel(controller: AccountDeletionViewController) -} - -@MainActor -class AccountDeletionViewController: UIViewController { - private lazy var contentView: AccountDeletionContentView = { - let view = AccountDeletionContentView() - view.delegate = self - return view - }() - - weak var delegate: AccountDeletionViewControllerDelegate? - let interactor: AccountDeletionInteractor - - init(interactor: AccountDeletionInteractor) { - self.interactor = interactor - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - configureUI() - contentView.viewModel = interactor.viewModel - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - contentView.isEditing = true - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - contentView.isEditing = false - } - - override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - contentView.isEditing = false - super.viewWillTransition(to: size, with: coordinator) - } - - private func configureUI() { - view.addConstrainedSubviews([contentView]) { - contentView.pinEdgesToSuperview(.all()) - } - } - - private func submit(accountNumber: String) { - contentView.state = .loading - Task { [weak self] in - guard let self else { return } - do { - try await interactor.delete(accountNumber: accountNumber) - self.contentView.state = .initial - self.delegate?.deleteAccountDidSucceed(controller: self) - } catch { - self.contentView.state = .failure(error) - } - } - } -} - -extension AccountDeletionViewController: @preconcurrency AccountDeletionContentViewDelegate { - func didTapCancelButton(contentView: AccountDeletionContentView, button: AppButton) { - contentView.isEditing = false - delegate?.deleteAccountDidCancel(controller: self) - } - - func didTapDeleteButton(contentView: AccountDeletionContentView, button: AppButton) { - switch interactor.validate(input: contentView.lastPartOfAccountNumber) { - case let .success(accountNumber): - contentView.isEditing = false - submit(accountNumber: accountNumber) - case let .failure(error): - contentView.state = .failure(error) - } - } -} diff --git a/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionViewModel.swift b/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionViewModel.swift index 3497d706ce..31ee4bd6ab 100644 --- a/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionViewModel.swift +++ b/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionViewModel.swift @@ -7,7 +7,142 @@ // import Foundation +import SwiftUICore -struct AccountDeletionViewModel { - let accountNumber: String +protocol AccountDeletionBackEnd { + var accountNumber: String? { get } + + func deleteAccount(accountNumber: String) async throws +} + +struct TunnelManagerAccountDeletionBackEnd: AccountDeletionBackEnd { + let tunnelManager: TunnelManager + + var accountNumber: String? { + tunnelManager.deviceState.accountData?.number + } + + func deleteAccount(accountNumber: String) async throws { + try await tunnelManager.deleteAccount(accountNumber: accountNumber) + } +} + +struct MockAccountDeletionBackEnd: AccountDeletionBackEnd { + let accountNumber: String? + + func deleteAccount(accountNumber: String) async throws {} +} + +class AccountDeletionViewModel: ObservableObject { + enum State { + case initial + case working + case failure(Swift.Error) + } + + enum Error: LocalizedError { + case invalidInput + + var errorDescription: String? { + switch self { + case .invalidInput: + return NSLocalizedString("Last four digits of the account number are incorrect", comment: "") + } + } + } + + @Published var accountNumber: String + @Published var enteredAccountNumberSuffix = "" + @Published var state: State = .initial + + private let backEnd: AccountDeletionBackEnd + + var onConclusion: ((Bool) -> Void)? + + var tunnelManagerAccountNumber: String { + backEnd.accountNumber ?? "" + } + + var accountNumberSuffix: Substring { + accountNumber.suffix(4) + } + + init(tunnelManager: TunnelManager, onConclusion: ((Bool) -> Void)? = nil) { + self.backEnd = TunnelManagerAccountDeletionBackEnd(tunnelManager: tunnelManager) + self.accountNumber = tunnelManager.deviceState.accountData?.number.formattedAccountNumber ?? "" + self.onConclusion = onConclusion + } + + // for SwiftUI previews + init(mockAccountNumber: String?) { + self.backEnd = MockAccountDeletionBackEnd(accountNumber: mockAccountNumber) + self.accountNumber = mockAccountNumber ?? "" + self.onConclusion = nil + } + + var messageText: AttributedString { + .fromMarkdown( + """ + Are you sure you want to delete the account **\(accountNumber)**? + """ + ) + } + + var statusText: LocalizedStringKey { + switch state { + case let .failure(error): + return LocalizedStringKey(error.localizedDescription) + case .working: + return LocalizedStringKey("Deleting account...") + default: return LocalizedStringKey("") + } + } + + var canDelete: Bool { + !isWorking && enteredAccountNumberSuffix.count == 4 && accountNumberSuffix == enteredAccountNumberSuffix + } + + var isWorking: Bool { + switch state { + case .working: true + default: false + } + } + + func validate(input: String) -> Result<String, Error> { + if let deviceAccountNumber = backEnd.accountNumber, + let fourLastDigits = deviceAccountNumber.split(every: 4).last, + fourLastDigits == input { + return .success(deviceAccountNumber) + } else { + return .failure(Error.invalidInput) + } + } + + @MainActor func deleteButtonTapped() { + switch validate(input: enteredAccountNumberSuffix) { + case let .success(accountNumber): + doDelete(accountNumber: accountNumber) + case let .failure(error): + state = .failure(error) + } + } + + func cancelButtonTapped() { + self.onConclusion?(false) + } + + @MainActor func doDelete(accountNumber: String) { + state = .working + Task { [weak self] in + guard let self else { return } + do { + try await backEnd.deleteAccount(accountNumber: accountNumber) + self.state = State.initial + self.onConclusion?(true) + } catch { + self.state = State.failure(error) + } + } + } } |
