summaryrefslogtreecommitdiffhomepage
path: root/ios
diff options
context:
space:
mode:
Diffstat (limited to 'ios')
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj20
-rw-r--r--ios/MullvadVPN/Coordinators/AccountCoordinator.swift17
-rw-r--r--ios/MullvadVPN/Coordinators/AccountDeletionCoordinator.swift28
-rw-r--r--ios/MullvadVPN/Extensions/AttributedString+Helpers.swift16
-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
-rw-r--r--ios/MullvadVPN/Views/MullvadPrimaryTextField.swift33
-rw-r--r--ios/MullvadVPN/Views/MullvadProgressViewStyle.swift8
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(