From 751c3342ebebc0080e9a19b7c48c6c45d24db1e4 Mon Sep 17 00:00:00 2001 From: Jon Petersson Date: Thu, 15 Aug 2024 13:45:21 +0200 Subject: [PATCH] Rework the Restore Purchases button to decrease user confusion --- ios/MullvadVPN.xcodeproj/project.pbxproj | 10 ++- .../Coordinators/AccountCoordinator.swift | 39 +++++++++ .../Account/AccountContentView.swift | 41 +++------- .../Account/AccountViewController.swift | 17 ++-- .../Account/RestorePurchasesView.swift | 82 +++++++++++++++++++ .../Alert/AlertViewController.swift | 44 +++++----- 6 files changed, 173 insertions(+), 60 deletions(-) create mode 100644 ios/MullvadVPN/View controllers/Account/RestorePurchasesView.swift diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 73e239060ac6..ecacf94eb6a1 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -533,6 +533,7 @@ 7A7907332BC0280A00B61F81 /* InterceptibleNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A7907322BC0280A00B61F81 /* InterceptibleNavigationController.swift */; }; 7A7AD14F2BF21EF200B30B3C /* NameInputFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A7AD14D2BF21DCE00B30B3C /* NameInputFormatter.swift */; }; 7A7AD28D29DC677800480EF1 /* FirstTimeLaunch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A7AD28C29DC677800480EF1 /* FirstTimeLaunch.swift */; }; + 7A7B3AB62C6DE4DA00D4BCCE /* RestorePurchasesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A7B3AB52C6DE4DA00D4BCCE /* RestorePurchasesView.swift */; }; 7A818F1F29F0305800C7F0F4 /* RootConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A818F1E29F0305800C7F0F4 /* RootConfiguration.swift */; }; 7A83A0C62B29A750008B5CE7 /* APIAccessMethodsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A83A0C52B29A750008B5CE7 /* APIAccessMethodsTests.swift */; }; 7A83C3FF2A55B72E00DFB83A /* MullvadVPNApp.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = 7A83C3FE2A55B72E00DFB83A /* MullvadVPNApp.xctestplan */; }; @@ -1846,6 +1847,7 @@ 7A7907322BC0280A00B61F81 /* InterceptibleNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterceptibleNavigationController.swift; sourceTree = ""; }; 7A7AD14D2BF21DCE00B30B3C /* NameInputFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameInputFormatter.swift; sourceTree = ""; }; 7A7AD28C29DC677800480EF1 /* FirstTimeLaunch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirstTimeLaunch.swift; sourceTree = ""; }; + 7A7B3AB52C6DE4DA00D4BCCE /* RestorePurchasesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorePurchasesView.swift; sourceTree = ""; }; 7A818F1E29F0305800C7F0F4 /* RootConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootConfiguration.swift; sourceTree = ""; }; 7A83A0C52B29A750008B5CE7 /* APIAccessMethodsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIAccessMethodsTests.swift; sourceTree = ""; }; 7A83C3FE2A55B72E00DFB83A /* MullvadVPNApp.xctestplan */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = MullvadVPNApp.xctestplan; sourceTree = ""; }; @@ -2883,14 +2885,15 @@ isa = PBXGroup; children = ( 5896CEF126972DEB00B0FAE8 /* AccountContentView.swift */, + F0DA87482A9CBA9F006044F1 /* AccountDeviceRow.swift */, + F0DA87462A9CB9A2006044F1 /* AccountExpiryRow.swift */, 5878A27029091CF20096FC88 /* AccountInteractor.swift */, + F0DA874A2A9CBACB006044F1 /* AccountNumberRow.swift */, 58CCA01722426713004F3011 /* AccountViewController.swift */, 7A1A26422A2612AE00B978AA /* PaymentAlertPresenter.swift */, 5867771329097BCD006F721F /* PaymentState.swift */, 5867771529097C5B006F721F /* ProductState.swift */, - F0DA87462A9CB9A2006044F1 /* AccountExpiryRow.swift */, - F0DA87482A9CBA9F006044F1 /* AccountDeviceRow.swift */, - F0DA874A2A9CBACB006044F1 /* AccountNumberRow.swift */, + 7A7B3AB52C6DE4DA00D4BCCE /* RestorePurchasesView.swift */, ); path = Account; sourceTree = ""; @@ -5640,6 +5643,7 @@ E1187ABC289BBB850024E748 /* OutOfTimeViewController.swift in Sources */, 586C0D872B03D39600E7CDD7 /* AccessMethodCellReuseIdentifier.swift in Sources */, 7A9CCCBD2A96302800DD6A34 /* LoginCoordinator.swift in Sources */, + 7A7B3AB62C6DE4DA00D4BCCE /* RestorePurchasesView.swift in Sources */, 58293FB125124117005D0BB5 /* CustomTextField.swift in Sources */, F09A29822A9F8AD200EA3B6F /* RedeemVoucherInteractor.swift in Sources */, 58138E61294871C600684F0C /* DeviceDataThrottling.swift in Sources */, diff --git a/ios/MullvadVPN/Coordinators/AccountCoordinator.swift b/ios/MullvadVPN/Coordinators/AccountCoordinator.swift index 839126f4fc47..9ef6572261ec 100644 --- a/ios/MullvadVPN/Coordinators/AccountCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/AccountCoordinator.swift @@ -65,6 +65,8 @@ final class AccountCoordinator: Coordinator, Presentable, Presenting { navigateToRedeemVoucher() case .navigateToDeleteAccount: navigateToDeleteAccount() + case .restorePurchaseInfo: + showRestorePurchaseInfo() } } @@ -188,4 +190,41 @@ final class AccountCoordinator: Coordinator, Presentable, Presenting { let presenter = AlertPresenter(context: self) presenter.showAlert(presentation: presentation, animated: true) } + + private func showRestorePurchaseInfo() { + let message = NSLocalizedString( + "RESTORE_PURCHASES_DIALOG_MESSAGE", + tableName: "Account", + value: """ + You can use the “restore purchases” function to check for any in-app payments \ + made via Apple services. If there is a payment that has not been credited, it will \ + add the time to the currently logged in Mullvad account. + """, + comment: "" + ) + + let presentation = AlertPresentation( + id: "account-device-info-alert", + icon: .info, + title: NSLocalizedString( + "RESTORE_PURCHASES_DIALOG_TITLE", + tableName: "Account", + value: "If you haven’t received additional VPN time after purchasing", + comment: "" + ), + message: message, + buttons: [AlertAction( + title: NSLocalizedString( + "RESTORE_PURCHASES_DIALOG_OK_ACTION", + tableName: "Account", + value: "Got it!", + comment: "" + ), + style: .default + )] + ) + + let presenter = AlertPresenter(context: self) + presenter.showAlert(presentation: presentation, animated: true) + } } diff --git a/ios/MullvadVPN/View controllers/Account/AccountContentView.swift b/ios/MullvadVPN/View controllers/Account/AccountContentView.swift index 3d8ae8151265..8e82b3115b5a 100644 --- a/ios/MullvadVPN/View controllers/Account/AccountContentView.swift +++ b/ios/MullvadVPN/View controllers/Account/AccountContentView.swift @@ -11,27 +11,12 @@ import UIKit class AccountContentView: UIView { let purchaseButton: InAppPurchaseButton = { let button = InAppPurchaseButton() - button.translatesAutoresizingMaskIntoConstraints = false button.accessibilityIdentifier = .purchaseButton return button }() - let restorePurchasesButton: AppButton = { - let button = AppButton(style: .default) - button.translatesAutoresizingMaskIntoConstraints = false - button.accessibilityIdentifier = .restorePurchasesButton - button.setTitle(NSLocalizedString( - "RESTORE_PURCHASES_BUTTON_TITLE", - tableName: "Account", - value: "Restore purchases", - comment: "" - ), for: .normal) - return button - }() - let redeemVoucherButton: AppButton = { let button = AppButton(style: .success) - button.translatesAutoresizingMaskIntoConstraints = false button.accessibilityIdentifier = .redeemVoucherButton button.setTitle(NSLocalizedString( "REDEEM_VOUCHER_BUTTON_TITLE", @@ -44,7 +29,6 @@ class AccountContentView: UIView { let logoutButton: AppButton = { let button = AppButton(style: .danger) - button.translatesAutoresizingMaskIntoConstraints = false button.accessibilityIdentifier = .logoutButton button.setTitle(NSLocalizedString( "LOGOUT_BUTTON_TITLE", @@ -57,7 +41,6 @@ class AccountContentView: UIView { let deleteButton: AppButton = { let button = AppButton(style: .danger) - button.translatesAutoresizingMaskIntoConstraints = false button.accessibilityIdentifier = .deleteButton button.setTitle(NSLocalizedString( "DELETE_BUTTON_TITLE", @@ -69,21 +52,19 @@ class AccountContentView: UIView { }() let accountDeviceRow: AccountDeviceRow = { - let view = AccountDeviceRow() - view.translatesAutoresizingMaskIntoConstraints = false - return view + return AccountDeviceRow() }() let accountTokenRowView: AccountNumberRow = { - let view = AccountNumberRow() - view.translatesAutoresizingMaskIntoConstraints = false - return view + return AccountNumberRow() }() let accountExpiryRowView: AccountExpiryRow = { - let view = AccountExpiryRow() - view.translatesAutoresizingMaskIntoConstraints = false - return view + return AccountExpiryRow() + }() + + let restorePurchasesView: RestorePurchasesView = { + return RestorePurchasesView() }() lazy var contentStackView: UIStackView = { @@ -92,10 +73,11 @@ class AccountContentView: UIView { accountDeviceRow, accountTokenRowView, accountExpiryRowView, + restorePurchasesView, ]) - stackView.translatesAutoresizingMaskIntoConstraints = false stackView.axis = .vertical - stackView.spacing = UIMetrics.padding8 + stackView.spacing = UIMetrics.padding24 + stackView.setCustomSpacing(UIMetrics.padding8, after: accountExpiryRowView) return stackView }() @@ -106,15 +88,12 @@ class AccountContentView: UIView { #endif arrangedSubviews.append(contentsOf: [ purchaseButton, - restorePurchasesButton, logoutButton, deleteButton, ]) let stackView = UIStackView(arrangedSubviews: arrangedSubviews) - stackView.translatesAutoresizingMaskIntoConstraints = false stackView.axis = .vertical stackView.spacing = UIMetrics.padding16 - stackView.setCustomSpacing(UIMetrics.interButtonSpacing, after: restorePurchasesButton) return stackView }() diff --git a/ios/MullvadVPN/View controllers/Account/AccountViewController.swift b/ios/MullvadVPN/View controllers/Account/AccountViewController.swift index 888fcd8d05b4..70eb27159208 100644 --- a/ios/MullvadVPN/View controllers/Account/AccountViewController.swift +++ b/ios/MullvadVPN/View controllers/Account/AccountViewController.swift @@ -20,6 +20,7 @@ enum AccountViewControllerAction { case logOut case navigateToVoucher case navigateToDeleteAccount + case restorePurchaseInfo } class AccountViewController: UIViewController { @@ -81,6 +82,14 @@ class AccountViewController: UIViewController { self?.actionHandler?(.deviceInfo) } + contentView.restorePurchasesView.restoreButtonAction = { [weak self] in + self?.restorePurchases() + } + + contentView.restorePurchasesView.infoButtonAction = { [weak self] in + self?.actionHandler?(.restorePurchaseInfo) + } + interactor.didReceiveDeviceState = { [weak self] deviceState in self?.updateView(from: deviceState) } @@ -126,16 +135,12 @@ class AccountViewController: UIViewController { for: .touchUpInside ) - contentView.restorePurchasesButton.addTarget( - self, - action: #selector(restorePurchases), - for: .touchUpInside - ) contentView.purchaseButton.addTarget( self, action: #selector(doPurchase), for: .touchUpInside ) + contentView.logoutButton.addTarget(self, action: #selector(logOut), for: .touchUpInside) contentView.deleteButton.addTarget(self, action: #selector(deleteAccount), for: .touchUpInside) @@ -193,7 +198,7 @@ class AccountViewController: UIViewController { purchaseButton.isEnabled = productState.isReceived && isInteractionEnabled contentView.accountDeviceRow.setButtons(enabled: isInteractionEnabled) contentView.accountTokenRowView.setButtons(enabled: isInteractionEnabled) - contentView.restorePurchasesButton.isEnabled = isInteractionEnabled + contentView.restorePurchasesView.setButtons(enabled: isInteractionEnabled) contentView.logoutButton.isEnabled = isInteractionEnabled contentView.redeemVoucherButton.isEnabled = isInteractionEnabled contentView.deleteButton.isEnabled = isInteractionEnabled diff --git a/ios/MullvadVPN/View controllers/Account/RestorePurchasesView.swift b/ios/MullvadVPN/View controllers/Account/RestorePurchasesView.swift new file mode 100644 index 000000000000..c683772126c4 --- /dev/null +++ b/ios/MullvadVPN/View controllers/Account/RestorePurchasesView.swift @@ -0,0 +1,82 @@ +// +// RestorePurchasesView.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-08-15. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +class RestorePurchasesView: UIView { + var restoreButtonAction: (() -> Void)? + var infoButtonAction: (() -> Void)? + + private lazy var contentView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [ + restoreButton, + infoButton, + UIView(), // Pushes the other views to the left. + ]) + stackView.spacing = UIMetrics.padding8 + return stackView + }() + + private lazy var restoreButton: UILabel = { + let label = UILabel() + label.accessibilityIdentifier = .restorePurchasesButton + label.attributedText = makeAttributedString() + label.isUserInteractionEnabled = true + label.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapRestoreButton))) + return label + }() + + private lazy var infoButton: UIButton = { + let button = IncreasedHitButton(type: .custom) + button.setImage(UIImage(resource: .iconInfo), for: .normal) + button.tintColor = .white + button.addTarget(self, action: #selector(didTapInfoButton), for: .touchUpInside) + return button + }() + + override init(frame: CGRect) { + super.init(frame: frame) + + addConstrainedSubviews([contentView]) { + contentView.pinEdgesToSuperview() + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setButtons(enabled: Bool) { + restoreButton.isUserInteractionEnabled = enabled + restoreButton.alpha = enabled ? 1 : 0.5 + infoButton.isEnabled = enabled + } + + private func makeAttributedString() -> NSAttributedString { + let text = NSLocalizedString( + "RESTORE_PURCHASES_BUTTON_TITLE", + tableName: "Account", + value: "Restore purchases", + comment: "" + ) + + return NSAttributedString(string: text, attributes: [ + .font: UIFont.systemFont(ofSize: 13, weight: .semibold), + .foregroundColor: UIColor.white, + .underlineStyle: NSUnderlineStyle.single.rawValue, + ]) + } + + @objc private func didTapRestoreButton() { + restoreButtonAction?() + } + + @objc private func didTapInfoButton() { + infoButtonAction?() + } +} diff --git a/ios/MullvadVPN/View controllers/Alert/AlertViewController.swift b/ios/MullvadVPN/View controllers/Alert/AlertViewController.swift index cc717a618053..9a4d26ae14b2 100644 --- a/ios/MullvadVPN/View controllers/Alert/AlertViewController.swift +++ b/ios/MullvadVPN/View controllers/Alert/AlertViewController.swift @@ -161,9 +161,6 @@ class AlertViewController: UIViewController { viewContainer.centerXAnchor.constraint(equalTo: view.centerXAnchor) viewContainer.centerYAnchor.constraint(equalTo: view.centerYAnchor) - viewContainer.widthAnchor - .constraint(lessThanOrEqualToConstant: UIMetrics.preferredFormSheetContentSize.width) - viewContainer.topAnchor .constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.topAnchor) .withPriority(.defaultHigh) @@ -172,13 +169,20 @@ class AlertViewController: UIViewController { .constraint(greaterThanOrEqualTo: viewContainer.bottomAnchor) .withPriority(.defaultHigh) - viewContainer.leadingAnchor + let leadingConstraint = viewContainer.leadingAnchor .constraint(equalTo: view.layoutMarginsGuide.leadingAnchor) - .withPriority(.defaultHigh) - - view.layoutMarginsGuide.trailingAnchor + let trailingConstraint = view.layoutMarginsGuide.trailingAnchor .constraint(equalTo: viewContainer.trailingAnchor) - .withPriority(.defaultHigh) + + if traitCollection.userInterfaceIdiom == .pad { + viewContainer.widthAnchor + .constraint(lessThanOrEqualToConstant: UIMetrics.preferredFormSheetContentSize.width) + leadingConstraint.withPriority(.defaultHigh) + trailingConstraint.withPriority(.defaultHigh) + } else { + leadingConstraint + trailingConstraint + } } } @@ -195,18 +199,18 @@ class AlertViewController: UIViewController { } private func addHeader(_ title: String) { - let header = UILabel() - - header.text = title - header.font = .preferredFont(forTextStyle: .largeTitle, weight: .bold) - header.textColor = .white - header.adjustsFontForContentSizeCategory = true - header.textAlignment = .center - header.numberOfLines = 0 - header.accessibilityIdentifier = .alertTitle - - contentView.addArrangedSubview(header) - contentView.setCustomSpacing(16, after: header) + let label = UILabel() + + label.text = title + label.font = .preferredFont(forTextStyle: .largeTitle, weight: .bold) + label.textColor = .white + label.adjustsFontForContentSizeCategory = true + label.textAlignment = .center + label.numberOfLines = 0 + label.accessibilityIdentifier = .alertTitle + + contentView.addArrangedSubview(label) + contentView.setCustomSpacing(16, after: label) } private func addTitle(_ title: String) {