summaryrefslogtreecommitdiffhomepage
path: root/ios/MullvadVPN/View controllers
diff options
context:
space:
mode:
authorAndrew Bulhak <andrew.bulhak@mullvad.net>2025-08-18 16:12:41 +0200
committerBug Magnet <marco.nikic@mullvad.net>2025-09-09 11:55:51 +0200
commitf605b90956897c87f6a18c1e90d8db2894de6ff8 (patch)
tree575b032c60246cddeba751ae0d4853ad0f9dc961 /ios/MullvadVPN/View controllers
parent1b702ff2c9f409cdfe455bbf41a81bca25b1f384 (diff)
downloadmullvadvpn-f605b90956897c87f6a18c1e90d8db2894de6ff8.tar.xz
mullvadvpn-f605b90956897c87f6a18c1e90d8db2894de6ff8.zip
Implement SwiftUI-based AccountDeletionView and ActivityIndicator
Diffstat (limited to 'ios/MullvadVPN/View controllers')
-rw-r--r--ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionContentView.swift369
-rw-r--r--ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionInteractor.swift49
-rw-r--r--ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionView.swift80
-rw-r--r--ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionViewController.swift94
-rw-r--r--ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionViewModel.swift139
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)
+ }
+ }
+ }
}