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 | |
| parent | 1b702ff2c9f409cdfe455bbf41a81bca25b1f384 (diff) | |
| download | mullvadvpn-f605b90956897c87f6a18c1e90d8db2894de6ff8.tar.xz mullvadvpn-f605b90956897c87f6a18c1e90d8db2894de6ff8.zip | |
Implement SwiftUI-based AccountDeletionView and ActivityIndicator
11 files changed, 290 insertions, 563 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index ad8a27b108..b5c06eb70b 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -95,6 +95,8 @@ 44E1F75A2D3FDCCA003A60FF /* DestinationDescriberTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44E1F7592D3FDCBA003A60FF /* DestinationDescriberTests.swift */; }; 44E1F75B2D3FEC81003A60FF /* DestinationDescriber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44E1F7572D3EA82C003A60FF /* DestinationDescriber.swift */; }; 44EC6C5A2E3BB3F60087F54A /* PersistentProxyConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44EC6C592E3BB3EF0087F54A /* PersistentProxyConfiguration.swift */; }; + 44EE8E062E4CB8B80025196E /* AccountDeletionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44EE8E052E4CB8B30025196E /* AccountDeletionView.swift */; }; + 44EE8E0A2E58A8D50025196E /* AttributedString+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44EE8E092E58A8C30025196E /* AttributedString+Helpers.swift */; }; 5803B4B02940A47300C23744 /* TunnelConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5803B4AF2940A47300C23744 /* TunnelConfiguration.swift */; }; 5803B4B22940A48700C23744 /* TunnelStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5803B4B12940A48700C23744 /* TunnelStore.swift */; }; 5807E2C02432038B00F5FF30 /* String+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5807E2BF2432038B00F5FF30 /* String+Helpers.swift */; }; @@ -1101,10 +1103,7 @@ F0E8CC0A2A4EE127007ED3B4 /* SetupAccountCompletedContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E8CC092A4EE127007ED3B4 /* SetupAccountCompletedContentView.swift */; }; F0E8CC0C2A4EE672007ED3B4 /* SetupAccountCompletedController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E8CC0B2A4EE672007ED3B4 /* SetupAccountCompletedController.swift */; }; F0E8E4BB2A56C9F100ED26A3 /* WelcomeInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E8E4BA2A56C9F100ED26A3 /* WelcomeInteractor.swift */; }; - F0E8E4C12A602CCB00ED26A3 /* AccountDeletionContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E8E4C02A602CCB00ED26A3 /* AccountDeletionContentView.swift */; }; F0E8E4C32A602E0D00ED26A3 /* AccountDeletionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E8E4C22A602E0D00ED26A3 /* AccountDeletionViewModel.swift */; }; - F0E8E4C52A60499100ED26A3 /* AccountDeletionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E8E4C42A60499100ED26A3 /* AccountDeletionViewController.swift */; }; - F0E8E4C92A604E7400ED26A3 /* AccountDeletionInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E8E4C82A604E7400ED26A3 /* AccountDeletionInteractor.swift */; }; F0EF50D52A949F8E0031E8DF /* ChangeLogViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0EF50D42A949F8E0031E8DF /* ChangeLogViewModel.swift */; }; F0F146942D9462E100BF78E7 /* RustProblemReportRequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0F146912D94491200BF78E7 /* RustProblemReportRequestTests.swift */; }; F0F316192BF3572B0078DBCF /* RelaySelectorResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0F316182BF3572B0078DBCF /* RelaySelectorResult.swift */; }; @@ -1684,6 +1683,8 @@ 44E1F7572D3EA82C003A60FF /* DestinationDescriber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestinationDescriber.swift; sourceTree = "<group>"; }; 44E1F7592D3FDCBA003A60FF /* DestinationDescriberTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestinationDescriberTests.swift; sourceTree = "<group>"; }; 44EC6C592E3BB3EF0087F54A /* PersistentProxyConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistentProxyConfiguration.swift; sourceTree = "<group>"; }; + 44EE8E052E4CB8B30025196E /* AccountDeletionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDeletionView.swift; sourceTree = "<group>"; }; + 44EE8E092E58A8C30025196E /* AttributedString+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttributedString+Helpers.swift"; sourceTree = "<group>"; }; 5802EBC42A8E44AC00E5CE4C /* AppRoutes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRoutes.swift; sourceTree = "<group>"; }; 5802EBC62A8E457A00E5CE4C /* AppRouteProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRouteProtocol.swift; sourceTree = "<group>"; }; 5802EBC82A8E45BA00E5CE4C /* ApplicationRouterDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationRouterDelegate.swift; sourceTree = "<group>"; }; @@ -2558,10 +2559,7 @@ F0E8CC092A4EE127007ED3B4 /* SetupAccountCompletedContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupAccountCompletedContentView.swift; sourceTree = "<group>"; }; F0E8CC0B2A4EE672007ED3B4 /* SetupAccountCompletedController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupAccountCompletedController.swift; sourceTree = "<group>"; }; F0E8E4BA2A56C9F100ED26A3 /* WelcomeInteractor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WelcomeInteractor.swift; sourceTree = "<group>"; }; - F0E8E4C02A602CCB00ED26A3 /* AccountDeletionContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDeletionContentView.swift; sourceTree = "<group>"; }; F0E8E4C22A602E0D00ED26A3 /* AccountDeletionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDeletionViewModel.swift; sourceTree = "<group>"; }; - F0E8E4C42A60499100ED26A3 /* AccountDeletionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDeletionViewController.swift; sourceTree = "<group>"; }; - F0E8E4C82A604E7400ED26A3 /* AccountDeletionInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDeletionInteractor.swift; sourceTree = "<group>"; }; F0EE6E772E4A321B0089DFF3 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/InfoPlist.strings; sourceTree = "<group>"; }; F0EEFB9E2D8D60E1007FE4B3 /* RustProblemReportRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RustProblemReportRequest.swift; sourceTree = "<group>"; }; F0EF50D42A949F8E0031E8DF /* ChangeLogViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeLogViewModel.swift; sourceTree = "<group>"; }; @@ -3447,6 +3445,7 @@ 583FE02329C1AC9F006E85F9 /* Extensions */ = { isa = PBXGroup; children = ( + 44EE8E092E58A8C30025196E /* AttributedString+Helpers.swift */, 5891BF1B25E3E3EB006D6FB0 /* Bundle+ProductVersion.swift */, F06200092CB7EB42002E6DB9 /* CGSize+Helpers.swift */, 587EB669270EFACB00123C75 /* CharacterSet+IPAddress.swift */, @@ -4934,9 +4933,7 @@ F0E8E4BF2A602C7D00ED26A3 /* AccountDeletion */ = { isa = PBXGroup; children = ( - F0E8E4C02A602CCB00ED26A3 /* AccountDeletionContentView.swift */, - F0E8E4C82A604E7400ED26A3 /* AccountDeletionInteractor.swift */, - F0E8E4C42A60499100ED26A3 /* AccountDeletionViewController.swift */, + 44EE8E052E4CB8B30025196E /* AccountDeletionView.swift */, F0E8E4C22A602E0D00ED26A3 /* AccountDeletionViewModel.swift */, ); path = AccountDeletion; @@ -6432,7 +6429,6 @@ 5846227126E229F20035F7C2 /* StoreSubscription.swift in Sources */, 58421030282D8A3C00F24E46 /* UpdateAccountDataOperation.swift in Sources */, 7A8A190A2CE5FFE9000BCB5B /* SettingsDAITAView.swift in Sources */, - F0E8E4C92A604E7400ED26A3 /* AccountDeletionInteractor.swift in Sources */, 449E9A6D2D283A2500F8574A /* ConnectionViewComponentPreview.swift in Sources */, F04789ED2E3A4FEB0011E3A5 /* ApplicationLanguage.swift in Sources */, 7A5869952B32E9C700640D27 /* LinkButton.swift in Sources */, @@ -6529,7 +6525,6 @@ 58F2E14C276A61C000A79513 /* RotateKeyOperation.swift in Sources */, 5871FB96254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift in Sources */, 440E5AB02CDBD67D00B09614 /* StatefulPreviewWrapper.swift in Sources */, - F0E8E4C52A60499100ED26A3 /* AccountDeletionViewController.swift in Sources */, 7A9CCCC12A96302800DD6A34 /* AccountCoordinator.swift in Sources */, 58FEEB58260B662E00A621A8 /* AutomaticKeyboardResponder.swift in Sources */, 5846227326E22A160035F7C2 /* StorePaymentObserver.swift in Sources */, @@ -6560,6 +6555,7 @@ 5823FA5426CE49F700283BF8 /* TunnelObserver.swift in Sources */, 5888AD87227B17950051EB06 /* LocationViewController.swift in Sources */, F006CCFC2B99CC8400C6C2AC /* EditLocationsCoordinator.swift in Sources */, + 44EE8E0A2E58A8D50025196E /* AttributedString+Helpers.swift in Sources */, 58293FB3251241B4005D0BB5 /* CustomTextView.swift in Sources */, 586A950E290125F3007BAF2B /* ProductsRequestOperation.swift in Sources */, 7AF9BE902A39F26000DBFEDB /* Collection+Sorting.swift in Sources */, @@ -6639,6 +6635,7 @@ 5891BF1C25E3E3EB006D6FB0 /* Bundle+ProductVersion.swift in Sources */, 5878A26F2907E7E00096FC88 /* ProblemReportInteractor.swift in Sources */, 7AB4CCBB2B691BBB006037F5 /* IPOverrideInteractor.swift in Sources */, + 44EE8E062E4CB8B80025196E /* AccountDeletionView.swift in Sources */, 7A3353912AAA014400F0A71C /* SimulatorVPNConnection.swift in Sources */, F02F41A22B9723AF00625A4F /* AddLocationsCoordinator.swift in Sources */, 7A27E3CD2CB814EF0088BCFF /* DAITAInfoView.swift in Sources */, @@ -6708,7 +6705,6 @@ 58EE2E3A272FF814003BFF93 /* SettingsDataSource.swift in Sources */, 58FF9FEA2B07653800E4C97D /* ButtonCellContentView.swift in Sources */, 7A5869AD2B5552E200640D27 /* IPOverrideViewController.swift in Sources */, - F0E8E4C12A602CCB00ED26A3 /* AccountDeletionContentView.swift in Sources */, 58B26E1E2943514300D5980C /* InAppNotificationDescriptor.swift in Sources */, 58421032282E42B000F24E46 /* UpdateDeviceDataOperation.swift in Sources */, 586C0D992B04E20200E7CDD7 /* AccessMethodViewModel+Persistent.swift in Sources */, diff --git a/ios/MullvadVPN/Coordinators/AccountCoordinator.swift b/ios/MullvadVPN/Coordinators/AccountCoordinator.swift index 9e5e1aa9eb..d13bec25ec 100644 --- a/ios/MullvadVPN/Coordinators/AccountCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/AccountCoordinator.swift @@ -181,19 +181,18 @@ final class AccountCoordinator: Coordinator, Presentable, Presenting, @unchecked private func navigateToDeleteAccount() { let coordinator = AccountDeletionCoordinator( navigationController: CustomNavigationController(), - interactor: AccountDeletionInteractor(tunnelManager: interactor.tunnelManager) + tunnelManager: interactor.tunnelManager ) coordinator.start() - coordinator.didCancel = { accountDeletionCoordinator in + coordinator.didConclude = { accountDeletionCoordinator, success in Task { @MainActor in - accountDeletionCoordinator.dismiss(animated: true) - } - } - - coordinator.didFinish = { @MainActor accountDeletionCoordinator in - accountDeletionCoordinator.dismiss(animated: true) { - self.didFinish?(self, .userLoggedOut) + accountDeletionCoordinator.dismiss( + animated: true, + completion: { + if success { self.didFinish?(self, .userLoggedOut) } + } + ) } } diff --git a/ios/MullvadVPN/Coordinators/AccountDeletionCoordinator.swift b/ios/MullvadVPN/Coordinators/AccountDeletionCoordinator.swift index ef01a3c831..22efb321f3 100644 --- a/ios/MullvadVPN/Coordinators/AccountDeletionCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/AccountDeletionCoordinator.swift @@ -8,14 +8,13 @@ import Foundation import Routing -import UIKit +import SwiftUI final class AccountDeletionCoordinator: Coordinator, Presentable { private let navigationController: UINavigationController - private let interactor: AccountDeletionInteractor + private let tunnelManager: TunnelManager - var didCancel: (@MainActor (AccountDeletionCoordinator) -> Void)? - var didFinish: (@MainActor (AccountDeletionCoordinator) -> Void)? + var didConclude: (@MainActor (AccountDeletionCoordinator, Bool) -> Void)? var presentedViewController: UIViewController { navigationController @@ -23,26 +22,23 @@ final class AccountDeletionCoordinator: Coordinator, Presentable { init( navigationController: UINavigationController, - interactor: AccountDeletionInteractor + tunnelManager: TunnelManager ) { self.navigationController = navigationController - self.interactor = interactor + self.tunnelManager = tunnelManager } func start() { navigationController.navigationBar.isHidden = true - let viewController = AccountDeletionViewController(interactor: interactor) - viewController.delegate = self + let viewModel = AccountDeletionViewModel( + tunnelManager: tunnelManager, + onConclusion: self.onConclusion(_:) + ) + let viewController = UIHostingController(rootView: AccountDeletionView(viewModel: viewModel)) navigationController.pushViewController(viewController, animated: true) } -} - -extension AccountDeletionCoordinator: @preconcurrency AccountDeletionViewControllerDelegate { - func deleteAccountDidSucceed(controller: AccountDeletionViewController) { - didFinish?(self) - } - func deleteAccountDidCancel(controller: AccountDeletionViewController) { - didCancel?(self) + private func onConclusion(_ succeeded: Bool) { + didConclude?(self, succeeded) } } diff --git a/ios/MullvadVPN/Extensions/AttributedString+Helpers.swift b/ios/MullvadVPN/Extensions/AttributedString+Helpers.swift new file mode 100644 index 0000000000..1e2bd4b5ac --- /dev/null +++ b/ios/MullvadVPN/Extensions/AttributedString+Helpers.swift @@ -0,0 +1,16 @@ +// +// AttributedString+Helpers.swift +// MullvadVPN +// +// Created by Andrew Bulhak on 2025-08-22. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +extension AttributedString { + /// Construct an AttributedString from text assumed to be in Markdown. If Markdown parsing fails, constructs one treating the text as plain text. + static func fromMarkdown(_ markdown: String) -> AttributedString { + (try? AttributedString(markdown: markdown)) ?? AttributedString(markdown) + } +} 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) + } + } + } } diff --git a/ios/MullvadVPN/Views/MullvadPrimaryTextField.swift b/ios/MullvadVPN/Views/MullvadPrimaryTextField.swift index 0ee481afab..a170e17392 100644 --- a/ios/MullvadVPN/Views/MullvadPrimaryTextField.swift +++ b/ios/MullvadVPN/Views/MullvadPrimaryTextField.swift @@ -6,19 +6,22 @@ struct MullvadPrimaryTextField: View { @Binding private var text: String @Binding private var suggestion: String? private let validate: ((String) -> Bool)? + private let keyboardType: UIKeyboardType? init( label: String, placeholder: String, text: Binding<String>, suggestion: Binding<String?>? = nil, - validate: ((String) -> Bool)? = nil + validate: ((String) -> Bool)? = nil, + keyboardType: UIKeyboardType? = nil ) { self.label = label self.placeholder = placeholder self._text = text self._suggestion = suggestion ?? .constant(nil) self.validate = validate + self.keyboardType = keyboardType } var isValid: Bool { @@ -38,22 +41,30 @@ struct MullvadPrimaryTextField: View { return false } + private var textFieldComponent: some View { + TextField( + placeholder, + text: $text, + prompt: Text(placeholder) + .foregroundColor( + isEnabled ? .MullvadTextField.inputPlaceholder : .MullvadTextField.textDisabled + ) + ) + .focused($isFocused) + .padding(.vertical, 12) + } + var body: some View { VStack(alignment: .leading) { Text(label) .foregroundColor(.MullvadTextField.label) VStack(spacing: 0) { HStack(spacing: 4) { - TextField( - placeholder, - text: $text, - prompt: Text(placeholder) - .foregroundColor( - isEnabled ? .MullvadTextField.inputPlaceholder : .MullvadTextField.textDisabled - ) - ) - .focused($isFocused) - .padding(.vertical, 12) + if let keyboardType { + textFieldComponent.keyboardType(keyboardType) + } else { + textFieldComponent + } if !text.isEmpty && isEnabled { Button { withAnimation { diff --git a/ios/MullvadVPN/Views/MullvadProgressViewStyle.swift b/ios/MullvadVPN/Views/MullvadProgressViewStyle.swift index 80a3b4bbef..e12a791bb9 100644 --- a/ios/MullvadVPN/Views/MullvadProgressViewStyle.swift +++ b/ios/MullvadVPN/Views/MullvadProgressViewStyle.swift @@ -1,11 +1,17 @@ import SwiftUI struct MullvadProgressViewStyle: ProgressViewStyle { + let size: CGFloat + + init(size: CGFloat = 48) { + self.size = size + } + @State var isAnimating = false func makeBody(configuration: Configuration) -> some View { Image.mullvadIconSpinner .resizable() - .frame(maxWidth: 48, maxHeight: 48) + .frame(maxWidth: size, maxHeight: size) .rotationEffect(.degrees(isAnimating ? 360 : 0)) .onAppear { withAnimation( |
