summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2023-06-20 14:00:48 +0200
committerAndrej Mihajlov <and@mullvad.net>2023-06-20 14:00:48 +0200
commit50f3227de1d3a57751da6066e8edb8508d01fed1 (patch)
tree8d28b55d475f601700509b7e968f30bcf4d6b0eb
parent989124e088bae04748c6f0cc0e95d2d3e0117131 (diff)
parentfad51b6305a3c709d0556ab4e4f222c3109b7920 (diff)
downloadmullvadvpn-50f3227de1d3a57751da6066e8edb8508d01fed1.tar.xz
mullvadvpn-50f3227de1d3a57751da6066e8edb8508d01fed1.zip
Merge branch 'implement-redeeming-voucher-ui-part-ios-193'
-rw-r--r--ios/MullvadREST/RESTError.swift2
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj32
-rw-r--r--ios/MullvadVPN/AppDelegate.swift3
-rw-r--r--ios/MullvadVPN/Classes/InputTextFormatter.swift7
-rw-r--r--ios/MullvadVPN/Coordinators/App/AccountCoordinator.swift30
-rw-r--r--ios/MullvadVPN/Coordinators/App/ApplicationCoordinator.swift4
-rw-r--r--ios/MullvadVPN/Presentation controllers/FormsheetPresentationController.swift66
-rw-r--r--ios/MullvadVPN/Presentation controllers/SecondaryContextPresentationController.swift4
-rw-r--r--ios/MullvadVPN/TunnelManager/TunnelManager.swift33
-rw-r--r--ios/MullvadVPN/UI appearance/UIMetrics.swift17
-rw-r--r--ios/MullvadVPN/View controllers/Account/AccountContentView.swift15
-rw-r--r--ios/MullvadVPN/View controllers/Account/AccountInteractor.swift2
-rw-r--r--ios/MullvadVPN/View controllers/Account/AccountViewController.swift18
-rw-r--r--ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherContentView.swift292
-rw-r--r--ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherCoordinator.swift57
-rw-r--r--ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherInteractor.swift26
-rw-r--r--ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherSucceededViewController.swift141
-rw-r--r--ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherViewController.swift100
-rw-r--r--ios/MullvadVPN/View controllers/RedeemVoucher/VoucherTextField.swift65
19 files changed, 883 insertions, 31 deletions
diff --git a/ios/MullvadREST/RESTError.swift b/ios/MullvadREST/RESTError.swift
index 90a5cb6be1..3b4ef3d77e 100644
--- a/ios/MullvadREST/RESTError.swift
+++ b/ios/MullvadREST/RESTError.swift
@@ -113,6 +113,8 @@ extension REST {
public static let deviceNotFound = ServerResponseCode(rawValue: "DEVICE_NOT_FOUND")
public static let serviceUnavailable = ServerResponseCode(rawValue: "SERVICE_UNAVAILABLE")
public static let tooManyRequests = ServerResponseCode(rawValue: "TOO_MANY_REQUESTS")
+ public static let invalidVoucher = ServerResponseCode(rawValue: "INVALID_VOUCHER")
+ public static let usedVoucher = ServerResponseCode(rawValue: "VOUCHER_USED")
public let rawValue: String
public init(rawValue: String) {
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj
index 2ef6d15c82..7876b34591 100644
--- a/ios/MullvadVPN.xcodeproj/project.pbxproj
+++ b/ios/MullvadVPN.xcodeproj/project.pbxproj
@@ -411,7 +411,13 @@
E1187ABD289BBB850024E748 /* OutOfTimeContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1187ABB289BBB850024E748 /* OutOfTimeContentView.swift */; };
E158B360285381C60002F069 /* String+AccountFormatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = E158B35F285381C60002F069 /* String+AccountFormatting.swift */; };
E1FD0DF528AA7CE400299DB4 /* StatusActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FD0DF428AA7CE400299DB4 /* StatusActivityView.swift */; };
+ F028A5492A336E8500C0CAA3 /* VoucherTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = F028A5482A336E8500C0CAA3 /* VoucherTextField.swift */; };
+ F028A54B2A3370FA00C0CAA3 /* RedeemVoucherContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F028A54A2A3370FA00C0CAA3 /* RedeemVoucherContentView.swift */; };
+ F028A56A2A34D4E700C0CAA3 /* RedeemVoucherViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F028A5692A34D4E700C0CAA3 /* RedeemVoucherViewController.swift */; };
+ F028A56C2A34D8E600C0CAA3 /* RedeemVoucherSucceededViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F028A56B2A34D8E600C0CAA3 /* RedeemVoucherSucceededViewController.swift */; };
+ F028A56E2A34DCC600C0CAA3 /* RedeemVoucherInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F028A56D2A34DCC600C0CAA3 /* RedeemVoucherInteractor.swift */; };
F03580252A13842C00E5DAFD /* IncreasedHitButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F03580242A13842C00E5DAFD /* IncreasedHitButton.swift */; };
+ F041CD562A38B0B7001B703B /* RedeemVoucherCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F041CD552A38B0B7001B703B /* RedeemVoucherCoordinator.swift */; };
F07BF2582A26112D00042943 /* InputTextFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F07BF2572A26112D00042943 /* InputTextFormatterTests.swift */; };
F07BF2622A26279100042943 /* RedeemVoucherOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = F07BF2612A26279100042943 /* RedeemVoucherOperation.swift */; };
F07CFF2029F2720E008C0343 /* RegisteredDeviceInAppNotificationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F07CFF1F29F2720E008C0343 /* RegisteredDeviceInAppNotificationProvider.swift */; };
@@ -1133,7 +1139,13 @@
E1187ABB289BBB850024E748 /* OutOfTimeContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutOfTimeContentView.swift; sourceTree = "<group>"; };
E158B35F285381C60002F069 /* String+AccountFormatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+AccountFormatting.swift"; sourceTree = "<group>"; };
E1FD0DF428AA7CE400299DB4 /* StatusActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActivityView.swift; sourceTree = "<group>"; };
+ F028A5482A336E8500C0CAA3 /* VoucherTextField.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VoucherTextField.swift; sourceTree = "<group>"; };
+ F028A54A2A3370FA00C0CAA3 /* RedeemVoucherContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RedeemVoucherContentView.swift; sourceTree = "<group>"; };
+ F028A5692A34D4E700C0CAA3 /* RedeemVoucherViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RedeemVoucherViewController.swift; sourceTree = "<group>"; };
+ F028A56B2A34D8E600C0CAA3 /* RedeemVoucherSucceededViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RedeemVoucherSucceededViewController.swift; sourceTree = "<group>"; };
+ F028A56D2A34DCC600C0CAA3 /* RedeemVoucherInteractor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RedeemVoucherInteractor.swift; sourceTree = "<group>"; };
F03580242A13842C00E5DAFD /* IncreasedHitButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncreasedHitButton.swift; sourceTree = "<group>"; };
+ F041CD552A38B0B7001B703B /* RedeemVoucherCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedeemVoucherCoordinator.swift; sourceTree = "<group>"; };
F07BF2572A26112D00042943 /* InputTextFormatterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InputTextFormatterTests.swift; sourceTree = "<group>"; };
F07BF2612A26279100042943 /* RedeemVoucherOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RedeemVoucherOperation.swift; sourceTree = "<group>"; };
F07CFF1F29F2720E008C0343 /* RegisteredDeviceInAppNotificationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisteredDeviceInAppNotificationProvider.swift; sourceTree = "<group>"; };
@@ -1457,6 +1469,7 @@
583FE01B29C19786006E85F9 /* OutOfTime */,
583FE01A29C19777006E85F9 /* Preferences */,
583FE01929C19760006E85F9 /* ProblemReport */,
+ F028A5472A336E1900C0CAA3 /* RedeemVoucher */,
583FE01C29C19793006E85F9 /* RevokedDevice */,
583FE01729C196F3006E85F9 /* SelectLocation */,
583FE01829C19709006E85F9 /* Settings */,
@@ -2161,6 +2174,19 @@
path = MullvadTransport;
sourceTree = "<group>";
};
+ F028A5472A336E1900C0CAA3 /* RedeemVoucher */ = {
+ isa = PBXGroup;
+ children = (
+ F028A54A2A3370FA00C0CAA3 /* RedeemVoucherContentView.swift */,
+ F041CD552A38B0B7001B703B /* RedeemVoucherCoordinator.swift */,
+ F028A56D2A34DCC600C0CAA3 /* RedeemVoucherInteractor.swift */,
+ F028A56B2A34D8E600C0CAA3 /* RedeemVoucherSucceededViewController.swift */,
+ F028A5692A34D4E700C0CAA3 /* RedeemVoucherViewController.swift */,
+ F028A5482A336E8500C0CAA3 /* VoucherTextField.swift */,
+ );
+ path = RedeemVoucher;
+ sourceTree = "<group>";
+ };
/* End PBXGroup section */
/* Begin PBXHeadersBuildPhase section */
@@ -2923,6 +2949,7 @@
58BA693123EADA6A009DC256 /* SimulatorTunnelProvider.swift in Sources */,
587B753B2666467500DEF7E9 /* NotificationBannerView.swift in Sources */,
58B993B12608A34500BA7811 /* LoginContentView.swift in Sources */,
+ F041CD562A38B0B7001B703B /* RedeemVoucherCoordinator.swift in Sources */,
5878A27529093A310096FC88 /* StorePaymentEvent.swift in Sources */,
7A7AD28D29DC677800480EF1 /* FirstTimeLaunch.swift in Sources */,
58B26E2A2943545A00D5980C /* NotificationManagerDelegate.swift in Sources */,
@@ -2934,6 +2961,7 @@
58F185AA298A3E3E00075977 /* TunnelCoordinator.swift in Sources */,
F03580252A13842C00E5DAFD /* IncreasedHitButton.swift in Sources */,
58F8AC0E25D3F8CE002BE0ED /* ProblemReportReviewViewController.swift in Sources */,
+ F028A56E2A34DCC600C0CAA3 /* RedeemVoucherInteractor.swift in Sources */,
5878A27129091CF20096FC88 /* AccountInteractor.swift in Sources */,
5878F4FC29CDA2E4003D4BE2 /* ChangeLogViewController.swift in Sources */,
068CE5742927B7A400A068BB /* Migration.swift in Sources */,
@@ -2961,6 +2989,7 @@
58F2E14C276A61C000A79513 /* RotateKeyOperation.swift in Sources */,
5871FB96254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift in Sources */,
068CE57029278F5300A068BB /* MigrationFromV1ToV2.swift in Sources */,
+ F028A54B2A3370FA00C0CAA3 /* RedeemVoucherContentView.swift in Sources */,
58FEEB58260B662E00A621A8 /* AutomaticKeyboardResponder.swift in Sources */,
5846227326E22A160035F7C2 /* StorePaymentObserver.swift in Sources */,
58F2E146276A2C9900A79513 /* StopTunnelOperation.swift in Sources */,
@@ -3020,6 +3049,7 @@
5891BF1C25E3E3EB006D6FB0 /* Bundle+ProductVersion.swift in Sources */,
5878A26F2907E7E00096FC88 /* ProblemReportInteractor.swift in Sources */,
7AE47E522A17972A000418DA /* CustomAlertViewController.swift in Sources */,
+ F028A56A2A34D4E700C0CAA3 /* RedeemVoucherViewController.swift in Sources */,
58E11188292FA11F009FCA84 /* SettingsMigrationUIHandler.swift in Sources */,
58CAFA002983FF0200BE19F7 /* LoginInteractor.swift in Sources */,
5807E2C02432038B00F5FF30 /* String+Split.swift in Sources */,
@@ -3062,8 +3092,10 @@
58FB865526E8BF3100F188BC /* StorePaymentManagerError.swift in Sources */,
58FD5BF42428C67600112C88 /* InAppPurchaseButton.swift in Sources */,
587D9676288989DB00CD8F1C /* NSLayoutConstraint+Helpers.swift in Sources */,
+ F028A56C2A34D8E600C0CAA3 /* RedeemVoucherSucceededViewController.swift in Sources */,
58293FAE2510CA58005D0BB5 /* ProblemReportViewController.swift in Sources */,
5878F50429CDC547003D4BE2 /* ChangeLogContentView.swift in Sources */,
+ F028A5492A336E8500C0CAA3 /* VoucherTextField.swift in Sources */,
58B9EB152489139B00095626 /* RESTError+Display.swift in Sources */,
587B753F2668E5A700DEF7E9 /* NotificationContainerView.swift in Sources */,
58F2E144276A13F300A79513 /* StartTunnelOperation.swift in Sources */,
diff --git a/ios/MullvadVPN/AppDelegate.swift b/ios/MullvadVPN/AppDelegate.swift
index 0906f376ed..fd3d401ef6 100644
--- a/ios/MullvadVPN/AppDelegate.swift
+++ b/ios/MullvadVPN/AppDelegate.swift
@@ -81,7 +81,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
tunnelStore: tunnelStore,
relayCacheTracker: relayCacheTracker,
accountsProxy: accountsProxy,
- devicesProxy: devicesProxy
+ devicesProxy: devicesProxy,
+ apiProxy: apiProxy
)
storePaymentManager = StorePaymentManager(
diff --git a/ios/MullvadVPN/Classes/InputTextFormatter.swift b/ios/MullvadVPN/Classes/InputTextFormatter.swift
index b92b63c367..ad1e5ea62d 100644
--- a/ios/MullvadVPN/Classes/InputTextFormatter.swift
+++ b/ios/MullvadVPN/Classes/InputTextFormatter.swift
@@ -27,7 +27,7 @@ class InputTextFormatter: NSObject, UITextFieldDelegate, UITextPasteDelegate {
var groupSize: UInt8
/// Maximum number of groups of characters allowed.
- var maxGroups: UInt
+ var maxGroups: UInt8
}
var configuration: Configuration {
@@ -259,11 +259,12 @@ class InputTextFormatter: NSObject, UITextFieldDelegate, UITextPasteDelegate {
}
private func isAllowed(_ character: Character) -> Bool {
+ guard character.isASCII else { return false }
switch configuration.allowedInput {
case .numeric:
- return character.isASCII && character.isNumber
+ return character.isNumber
case .alphanumeric:
- return character.isASCII && character.isLetter
+ return character.isLetter || character.isNumber
}
}
}
diff --git a/ios/MullvadVPN/Coordinators/App/AccountCoordinator.swift b/ios/MullvadVPN/Coordinators/App/AccountCoordinator.swift
index 13ca7bcb5d..cf2d0f4647 100644
--- a/ios/MullvadVPN/Coordinators/App/AccountCoordinator.swift
+++ b/ios/MullvadVPN/Coordinators/App/AccountCoordinator.swift
@@ -48,6 +48,36 @@ final class AccountCoordinator: Coordinator, Presentable, Presenting {
}
extension AccountCoordinator: AccountViewControllerDelegate {
+ func accountViewController(
+ _ controller: AccountViewController,
+ didRequestRoutePresentation route: AccountsNavigationRoute
+ ) {
+ switch route {
+ case .redeemVoucher:
+ let coordinator = RedeemVoucherCoordinator(
+ navigationController: CustomNavigationController(),
+ interactor: RedeemVoucherInteractor(tunnelManager: interactor.tunnelManager)
+ )
+ coordinator.didFinish = { redeemVoucherCoordinator in
+ redeemVoucherCoordinator.dismiss(animated: true)
+ }
+ coordinator.didCancel = { redeemVoucherCoordinator in
+ redeemVoucherCoordinator.dismiss(animated: true)
+ }
+
+ coordinator.start()
+ presentChild(
+ coordinator,
+ animated: true,
+ configuration: ModalPresentationConfiguration(
+ preferredContentSize: UIMetrics.RedeemVoucher.preferredContentSize,
+ modalPresentationStyle: .custom,
+ transitioningDelegate: FormSheetTransitioningDelegate()
+ )
+ )
+ }
+ }
+
func accountViewControllerDidFinish(_ controller: AccountViewController) {
didFinish?(self, .none)
}
diff --git a/ios/MullvadVPN/Coordinators/App/ApplicationCoordinator.swift b/ios/MullvadVPN/Coordinators/App/ApplicationCoordinator.swift
index 117c8ea20c..74d99cd59c 100644
--- a/ios/MullvadVPN/Coordinators/App/ApplicationCoordinator.swift
+++ b/ios/MullvadVPN/Coordinators/App/ApplicationCoordinator.swift
@@ -408,7 +408,7 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo
NotificationCenter.default.addObserver(
self,
selector: #selector(formSheetControllerWillChangeFullscreenPresentation(_:)),
- name: FormsheetPresentationController.willChangeFullScreenPresentation,
+ name: FormSheetPresentationController.willChangeFullScreenPresentation,
object: secondaryNavigationContainer
)
}
@@ -419,7 +419,7 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo
private func removeSecondaryContextPresentationStyleObserver() {
NotificationCenter.default.removeObserver(
self,
- name: FormsheetPresentationController.willChangeFullScreenPresentation,
+ name: FormSheetPresentationController.willChangeFullScreenPresentation,
object: secondaryNavigationContainer
)
}
diff --git a/ios/MullvadVPN/Presentation controllers/FormsheetPresentationController.swift b/ios/MullvadVPN/Presentation controllers/FormsheetPresentationController.swift
index cd5612f707..d88202231d 100644
--- a/ios/MullvadVPN/Presentation controllers/FormsheetPresentationController.swift
+++ b/ios/MullvadVPN/Presentation controllers/FormsheetPresentationController.swift
@@ -1,5 +1,5 @@
//
-// FormsheetPresentationController.swift
+// FormSheetPresentationController.swift
// MullvadVPN
//
// Created by pronebird on 18/02/2023.
@@ -8,19 +8,15 @@
import UIKit
-private let dimmingViewOpacity: CGFloat = 0.5
-private let presentedViewCornerRadius: CGFloat = 8
-private let animationDuration: TimeInterval = 0.5
-
/**
Custom implementation of a formsheet presentation controller.
*/
-class FormsheetPresentationController: UIPresentationController {
+class FormSheetPresentationController: UIPresentationController {
/**
Name of notification posted when fullscreen presentation changes, including during initial presentation.
*/
static let willChangeFullScreenPresentation = Notification
- .Name(rawValue: "FormsheetPresentationControllerWillChangeFullScreenPresentation")
+ .Name(rawValue: "FormSheetPresentationControllerWillChangeFullScreenPresentation")
/**
User info key passed along with `willChangeFullScreenPresentation` notification that contains boolean value that
@@ -33,9 +29,11 @@ class FormsheetPresentationController: UIPresentationController {
*/
private var lastKnownIsInFullScreen: Bool?
+ private var keyboardResponder: AutomaticKeyboardResponder?
+
private let dimmingView: UIView = {
let dimmingView = UIView()
- dimmingView.backgroundColor = .black
+ dimmingView.backgroundColor = UIMetrics.DimmingView.backgroundColor
return dimmingView
}()
@@ -57,6 +55,11 @@ class FormsheetPresentationController: UIPresentationController {
traitCollection.horizontalSizeClass == .compact
}
+ override init(presentedViewController: UIViewController, presenting presentingViewController: UIViewController?) {
+ super.init(presentedViewController: presentedViewController, presenting: presentingViewController)
+ addKeyboardResponder()
+ }
+
override var frameOfPresentedViewInContainerView: CGRect {
guard let containerView else {
return super.frameOfPresentedViewInContainerView
@@ -88,11 +91,11 @@ class FormsheetPresentationController: UIPresentationController {
dimmingView.alpha = 0
containerView?.addSubview(dimmingView)
- presentedView?.layer.cornerRadius = presentedViewCornerRadius
+ presentedView?.layer.cornerRadius = UIMetrics.DimmingView.cornerRadius
presentedView?.clipsToBounds = true
let revealDimmingView = {
- self.dimmingView.alpha = dimmingViewOpacity
+ self.dimmingView.alpha = UIMetrics.DimmingView.opacity
}
if let transitionCoordinator = presentingViewController.transitionCoordinator {
@@ -151,21 +154,44 @@ class FormsheetPresentationController: UIPresentationController {
userInfo: [Self.isFullScreenUserInfoKey: NSNumber(booleanLiteral: currentIsInFullScreen)]
)
}
+
+ private func addKeyboardResponder() {
+ guard let presentedView else { return }
+ keyboardResponder = AutomaticKeyboardResponder(
+ targetView: presentedView,
+ handler: { [weak self] view, adjustment in
+ guard let self,
+ let containerView,
+ !isInFullScreenPresentation else { return }
+ let frame = view.frame
+ let bottomMarginFromKeyboard = adjustment > 0 ? UIMetrics.sectionSpacing : 0
+ view.frame = CGRect(
+ origin: CGPoint(
+ x: frame.origin.x,
+ y: containerView.bounds.midY - presentedViewController.preferredContentSize
+ .height * 0.5 - adjustment - bottomMarginFromKeyboard
+ ),
+ size: frame.size
+ )
+ view.layoutIfNeeded()
+ }
+ )
+ }
}
-class FormsheetTransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate {
+class FormSheetTransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate {
func animationController(
forPresented presented: UIViewController,
presenting: UIViewController,
source: UIViewController
) -> UIViewControllerAnimatedTransitioning? {
- return FormsheetPresentationAnimator()
+ return FormSheetPresentationAnimator()
}
func animationController(forDismissed dismissed: UIViewController)
-> UIViewControllerAnimatedTransitioning?
{
- return FormsheetPresentationAnimator()
+ return FormSheetPresentationAnimator()
}
func presentationController(
@@ -173,18 +199,18 @@ class FormsheetTransitioningDelegate: NSObject, UIViewControllerTransitioningDel
presenting: UIViewController?,
source: UIViewController
) -> UIPresentationController? {
- return FormsheetPresentationController(
+ return FormSheetPresentationController(
presentedViewController: presented,
presenting: source
)
}
}
-class FormsheetPresentationAnimator: NSObject, UIViewControllerAnimatedTransitioning {
+class FormSheetPresentationAnimator: NSObject, UIViewControllerAnimatedTransitioning {
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?)
-> TimeInterval
{
- return (transitionContext?.isAnimated ?? true) ? animationDuration : 0
+ return (transitionContext?.isAnimated ?? true) ? UIMetrics.FormSheetTransition.duration : 0
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
@@ -212,8 +238,8 @@ class FormsheetPresentationAnimator: NSObject, UIViewControllerAnimatedTransitio
if transitionContext.isAnimated {
UIView.animate(
withDuration: duration,
- delay: 0,
- options: [.curveEaseInOut],
+ delay: UIMetrics.FormSheetTransition.delay,
+ options: UIMetrics.FormSheetTransition.animationOptions,
animations: {
destinationView.frame = transitionContext.finalFrame(for: destinationController)
},
@@ -238,8 +264,8 @@ class FormsheetPresentationAnimator: NSObject, UIViewControllerAnimatedTransitio
if transitionContext.isAnimated {
UIView.animate(
withDuration: duration,
- delay: 0,
- options: [.curveEaseInOut],
+ delay: UIMetrics.FormSheetTransition.delay,
+ options: UIMetrics.FormSheetTransition.animationOptions,
animations: {
sourceView.frame = initialFrame
},
diff --git a/ios/MullvadVPN/Presentation controllers/SecondaryContextPresentationController.swift b/ios/MullvadVPN/Presentation controllers/SecondaryContextPresentationController.swift
index 872dfe8955..b0a7686b4f 100644
--- a/ios/MullvadVPN/Presentation controllers/SecondaryContextPresentationController.swift
+++ b/ios/MullvadVPN/Presentation controllers/SecondaryContextPresentationController.swift
@@ -12,7 +12,7 @@ import UIKit
This is a presentation controller class used for presentation of secondary navigation context
in application coordinator.
*/
-class SecondaryContextPresentationController: FormsheetPresentationController {
+class SecondaryContextPresentationController: FormSheetPresentationController {
override func presentationTransitionWillBegin() {
super.presentationTransitionWillBegin()
@@ -49,7 +49,7 @@ class SecondaryContextPresentationController: FormsheetPresentationController {
}
}
-class SecondaryContextTransitioningDelegate: FormsheetTransitioningDelegate {
+class SecondaryContextTransitioningDelegate: FormSheetTransitioningDelegate {
override func presentationController(
forPresented presented: UIViewController,
presenting: UIViewController?,
diff --git a/ios/MullvadVPN/TunnelManager/TunnelManager.swift b/ios/MullvadVPN/TunnelManager/TunnelManager.swift
index 3cbe936a41..49f1bc18e8 100644
--- a/ios/MullvadVPN/TunnelManager/TunnelManager.swift
+++ b/ios/MullvadVPN/TunnelManager/TunnelManager.swift
@@ -48,6 +48,7 @@ final class TunnelManager: StorePaymentObserver {
private let relayCacheTracker: RelayCacheTracker
private let accountsProxy: REST.AccountsProxy
private let devicesProxy: REST.DevicesProxy
+ private let apiProxy: REST.APIProxy
private let logger = Logger(label: "TunnelManager")
private var nslock = NSRecursiveLock()
@@ -82,13 +83,15 @@ final class TunnelManager: StorePaymentObserver {
tunnelStore: TunnelStore,
relayCacheTracker: RelayCacheTracker,
accountsProxy: REST.AccountsProxy,
- devicesProxy: REST.DevicesProxy
+ devicesProxy: REST.DevicesProxy,
+ apiProxy: REST.APIProxy
) {
self.application = application
self.tunnelStore = tunnelStore
self.relayCacheTracker = relayCacheTracker
self.accountsProxy = accountsProxy
self.devicesProxy = devicesProxy
+ self.apiProxy = apiProxy
self.operationQueue.name = "TunnelManager.operationQueue"
self.operationQueue.underlyingQueue = internalQueue
@@ -400,6 +403,34 @@ final class TunnelManager: StorePaymentObserver {
operationQueue.addOperation(operation)
}
+ func redeemVoucher(
+ _ voucherCode: String,
+ completion: ((Result<REST.SubmitVoucherResponse, Error>) -> Void)? = nil
+ ) -> Cancellable {
+ let operation = RedeemVoucherOperation(
+ dispatchQueue: internalQueue,
+ interactor: TunnelInteractorProxy(self),
+ voucherCode: voucherCode,
+ apiProxy: apiProxy
+ )
+
+ operation.completionQueue = .main
+ operation.completionHandler = completion
+
+ operation.addObserver(
+ BackgroundObserver(
+ application: application,
+ name: "Redeem voucher",
+ cancelUponExpiration: true
+ )
+ )
+
+ operation.addCondition(MutuallyExclusive(category: OperationCategory.deviceStateUpdate.category))
+
+ operationQueue.addOperation(operation)
+ return operation
+ }
+
func updateDeviceData(_ completionHandler: ((Error?) -> Void)? = nil) {
let operation = UpdateDeviceDataOperation(
dispatchQueue: internalQueue,
diff --git a/ios/MullvadVPN/UI appearance/UIMetrics.swift b/ios/MullvadVPN/UI appearance/UIMetrics.swift
index 7e8ac30da7..c8f1704973 100644
--- a/ios/MullvadVPN/UI appearance/UIMetrics.swift
+++ b/ios/MullvadVPN/UI appearance/UIMetrics.swift
@@ -21,6 +21,23 @@ enum UIMetrics {
/// Spacing between views in container (main view) in `CustomAlertViewController`
static let containerSpacing: CGFloat = 16
}
+
+ enum DimmingView {
+ static let opacity: CGFloat = 0.5
+ static let cornerRadius: CGFloat = 8
+ static let backgroundColor: UIColor = .black
+ }
+
+ enum FormSheetTransition {
+ static let duration: TimeInterval = 0.5
+ static let delay: TimeInterval = .zero
+ static let animationOptions: UIView.AnimationOptions = [.curveEaseInOut]
+ }
+
+ enum RedeemVoucher {
+ static let cornerRadius = 8.0
+ static let preferredContentSize = CGSize(width: 292, height: 263)
+ }
}
extension UIMetrics {
diff --git a/ios/MullvadVPN/View controllers/Account/AccountContentView.swift b/ios/MullvadVPN/View controllers/Account/AccountContentView.swift
index 120d43af16..127926dd5d 100644
--- a/ios/MullvadVPN/View controllers/Account/AccountContentView.swift
+++ b/ios/MullvadVPN/View controllers/Account/AccountContentView.swift
@@ -28,6 +28,19 @@ class AccountContentView: UIView {
return button
}()
+ let redeemVoucherButton: AppButton = {
+ let button = AppButton(style: .success)
+ button.translatesAutoresizingMaskIntoConstraints = false
+ button.accessibilityIdentifier = "redeemVoucherButton"
+ button.setTitle(NSLocalizedString(
+ "REDEEM_VOUCHER_BUTTON_TITLE",
+ tableName: "Account",
+ value: "Redeem voucher",
+ comment: ""
+ ), for: .normal)
+ return button
+ }()
+
let logoutButton: AppButton = {
let button = AppButton(style: .danger)
button.translatesAutoresizingMaskIntoConstraints = false
@@ -74,7 +87,7 @@ class AccountContentView: UIView {
lazy var buttonStackView: UIStackView = {
let stackView =
- UIStackView(arrangedSubviews: [purchaseButton, restorePurchasesButton, logoutButton])
+ UIStackView(arrangedSubviews: [redeemVoucherButton, purchaseButton, restorePurchasesButton, logoutButton])
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
stackView.spacing = UIMetrics.interButtonSpacing
diff --git a/ios/MullvadVPN/View controllers/Account/AccountInteractor.swift b/ios/MullvadVPN/View controllers/Account/AccountInteractor.swift
index 6d8de2958b..52060df828 100644
--- a/ios/MullvadVPN/View controllers/Account/AccountInteractor.swift
+++ b/ios/MullvadVPN/View controllers/Account/AccountInteractor.swift
@@ -14,7 +14,7 @@ import StoreKit
final class AccountInteractor {
private let storePaymentManager: StorePaymentManager
- private let tunnelManager: TunnelManager
+ let tunnelManager: TunnelManager
var didReceivePaymentEvent: ((StorePaymentEvent) -> Void)?
var didReceiveDeviceState: ((DeviceState) -> Void)?
diff --git a/ios/MullvadVPN/View controllers/Account/AccountViewController.swift b/ios/MullvadVPN/View controllers/Account/AccountViewController.swift
index a733483099..73d16ca854 100644
--- a/ios/MullvadVPN/View controllers/Account/AccountViewController.swift
+++ b/ios/MullvadVPN/View controllers/Account/AccountViewController.swift
@@ -13,9 +13,17 @@ import Operations
import StoreKit
import UIKit
+enum AccountsNavigationRoute {
+ case redeemVoucher
+}
+
protocol AccountViewControllerDelegate: AnyObject {
func accountViewControllerDidFinish(_ controller: AccountViewController)
func accountViewControllerDidLogout(_ controller: AccountViewController)
+ func accountViewController(
+ _ controller: AccountViewController,
+ didRequestRoutePresentation route: AccountsNavigationRoute
+ )
}
class AccountViewController: UIViewController {
@@ -90,6 +98,12 @@ class AccountViewController: UIViewController {
self?.copyAccountToken()
}
+ contentView.redeemVoucherButton.addTarget(
+ self,
+ action: #selector(redeemVoucher),
+ for: .touchUpInside
+ )
+
contentView.restorePurchasesButton.addTarget(
self,
action: #selector(restorePurchases),
@@ -278,6 +292,10 @@ class AccountViewController: UIViewController {
// MARK: - Actions
+ @objc private func redeemVoucher() {
+ delegate?.accountViewController(self, didRequestRoutePresentation: .redeemVoucher)
+ }
+
@objc private func logout() {
let alertController = CustomAlertViewController(
icon: .spinner
diff --git a/ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherContentView.swift b/ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherContentView.swift
new file mode 100644
index 0000000000..865a28b9ac
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherContentView.swift
@@ -0,0 +1,292 @@
+//
+// RedeemVoucherContentView.swift
+// MullvadVPN
+//
+// Created by Andreas Lif on 2022-08-05.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+import MullvadREST
+import UIKit
+
+enum RedeemVoucherState {
+ case initial
+ case success
+ case verifying
+ case failure(Error)
+}
+
+final class RedeemVoucherContentView: UIView {
+ // MARK: - private
+
+ private let titleLabel: UILabel = {
+ let label = UILabel()
+ label.font = UIFont.systemFont(ofSize: 17)
+ label.text = NSLocalizedString(
+ "REDEEM_VOUCHER_INSTRUCTION",
+ tableName: "RedeemVoucher",
+ value: "Enter voucher code",
+ comment: ""
+ )
+ label.textColor = .white
+ label.translatesAutoresizingMaskIntoConstraints = false
+ label.numberOfLines = 0
+ return label
+ }()
+
+ private let textField: VoucherTextField = {
+ let textField = VoucherTextField()
+ textField.font = UIFont.monospacedSystemFont(ofSize: 15, weight: .regular)
+ textField.placeholder = Array(repeating: "XXXX", count: 4).joined(separator: "-")
+ textField.placeholderTextColor = .lightGray
+ textField.backgroundColor = .white
+ textField.cornerRadius = UIMetrics.RedeemVoucher.cornerRadius
+ textField.keyboardType = .asciiCapable
+ textField.autocapitalizationType = .allCharacters
+ textField.returnKeyType = .done
+ textField.autocorrectionType = .no
+ return textField
+ }()
+
+ private let activityIndicator: SpinnerActivityIndicatorView = {
+ let activityIndicator = SpinnerActivityIndicatorView(style: .medium)
+ activityIndicator.tintColor = .white
+ return activityIndicator
+ }()
+
+ private let statusLabel: UILabel = {
+ let label = UILabel()
+ label.font = UIFont.systemFont(ofSize: 17)
+ label.textColor = .white
+ label.numberOfLines = .zero
+ label.lineBreakMode = .byWordWrapping
+ if #available(iOS 14.0, *) {
+ // See: https://stackoverflow.com/q/46200027/351305
+ label.lineBreakStrategy = []
+ }
+ return label
+ }()
+
+ private let redeemButton: AppButton = {
+ let button = AppButton(style: .success)
+ button.setTitle(NSLocalizedString(
+ "REDEEM_VOUCHER_REDEEM_BUTTON",
+ tableName: "RedeemVoucher",
+ value: "Redeem",
+ comment: ""
+ ), for: .normal)
+ return button
+ }()
+
+ private let cancelButton: AppButton = {
+ let button = AppButton(style: .default)
+ button.setTitle(NSLocalizedString(
+ "REDEEM_VOUCHER_CANCEL_BUTTON",
+ tableName: "RedeemVoucher",
+ value: "Cancel",
+ comment: ""
+ ), for: .normal)
+ return button
+ }()
+
+ private lazy var statusStack: UIStackView = {
+ let stackView = UIStackView(arrangedSubviews: [activityIndicator, statusLabel])
+ stackView.translatesAutoresizingMaskIntoConstraints = false
+ stackView.axis = .horizontal
+ stackView.spacing = UIMetrics.interButtonSpacing
+ return stackView
+ }()
+
+ private lazy var voucherCodeStackView: UIStackView = {
+ let stackView = UIStackView(arrangedSubviews: [
+ titleLabel,
+ textField,
+ statusStack,
+ ])
+ stackView.translatesAutoresizingMaskIntoConstraints = false
+ stackView.axis = .vertical
+ stackView.spacing = UIMetrics.interButtonSpacing
+ return stackView
+ }()
+
+ private lazy var actionsStackView: UIStackView = {
+ let stackView = UIStackView(arrangedSubviews: [redeemButton, cancelButton])
+ stackView.translatesAutoresizingMaskIntoConstraints = false
+ stackView.axis = .vertical
+ stackView.spacing = UIMetrics.interButtonSpacing
+ return stackView
+ }()
+
+ private var text: String {
+ switch state {
+ case let .failure(error):
+ guard let restError = error as? REST.Error else {
+ return error.localizedDescription
+ }
+ return restError.description
+ case .verifying:
+ return NSLocalizedString(
+ "REDEEM_VOUCHER_STATUS_WAITING",
+ tableName: "RedeemVoucher",
+ value: "Verifying voucher...",
+ comment: ""
+ )
+ default: return ""
+ }
+ }
+
+ private var isRedeemButtonEnabled: Bool {
+ switch state {
+ case .initial, .failure:
+ return true
+ case .success, .verifying:
+ return false
+ }
+ }
+
+ private var textColor: UIColor {
+ switch state {
+ case .failure:
+ return .dangerColor
+ default:
+ return .white
+ }
+ }
+
+ private var isLoading: Bool {
+ switch state {
+ case .verifying:
+ return true
+ default:
+ return false
+ }
+ }
+
+ // MARK: - public
+
+ var redeemAction: ((String) -> Void)?
+ var cancelAction: (() -> Void)?
+
+ var state: RedeemVoucherState = .initial {
+ didSet {
+ updateUI()
+ }
+ }
+
+ var isEditing = false {
+ didSet {
+ if isEditing {
+ textField.becomeFirstResponder()
+ } else {
+ textField.resignFirstResponder()
+ }
+ }
+ }
+
+ init() {
+ super.init(frame: .zero)
+ setup()
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ private func setup() {
+ setupAppearance()
+ configureUI()
+ addButtonHandlers()
+ addTextFieldObserver()
+ updateUI()
+ }
+
+ private func configureUI() {
+ addSubview(voucherCodeStackView)
+ addSubview(actionsStackView)
+ addConstraints()
+ }
+
+ private func setupAppearance() {
+ translatesAutoresizingMaskIntoConstraints = false
+ backgroundColor = .secondaryColor
+ directionalLayoutMargins = UIMetrics.contentLayoutMargins
+ }
+
+ private func addConstraints() {
+ addConstrainedSubviews([voucherCodeStackView, actionsStackView]) {
+ voucherCodeStackView.pinEdgesToSuperviewMargins(.all().excluding(.bottom))
+ actionsStackView.pinEdgesToSuperviewMargins(.all().excluding(.top))
+
+ actionsStackView.topAnchor.constraint(
+ greaterThanOrEqualTo: voucherCodeStackView.bottomAnchor,
+ constant: UIMetrics.interButtonSpacing
+ )
+ }
+ }
+
+ private func addTextFieldObserver() {
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(textDidChange),
+ name: UITextField.textDidChangeNotification,
+ object: textField
+ )
+ }
+
+ private func addButtonHandlers() {
+ cancelButton.addTarget(
+ self,
+ action: #selector(cancelButtonTapped),
+ for: .touchUpInside
+ )
+
+ redeemButton.addTarget(
+ self,
+ action: #selector(redeemButtonTapped),
+ for: .touchUpInside
+ )
+ }
+
+ private func updateUI() {
+ isLoading ? activityIndicator.startAnimating() : activityIndicator.stopAnimating()
+ redeemButton.isEnabled = isRedeemButtonEnabled && textField.isVoucherLengthSatisfied
+ statusLabel.text = text
+ statusLabel.textColor = textColor
+ }
+
+ @objc private func cancelButtonTapped(_ sender: AppButton) {
+ cancelAction?()
+ }
+
+ @objc private func redeemButtonTapped(_ sender: AppButton) {
+ guard let code = textField.text, !code.isEmpty else {
+ return
+ }
+ redeemAction?(code)
+ }
+
+ @objc private func textDidChange() {
+ updateUI()
+ }
+}
+
+private extension REST.Error {
+ var description: String {
+ if compareErrorCode(.invalidVoucher) {
+ return NSLocalizedString(
+ "REDEEM_VOUCHER_STATUS_FAILURE",
+ tableName: "RedeemVoucher",
+ value: "Voucher code is invalid.",
+ comment: ""
+ )
+ } else if compareErrorCode(.usedVoucher) {
+ return NSLocalizedString(
+ "REDEEM_VOUCHER_STATUS_FAILURE",
+ tableName: "RedeemVoucher",
+ value: "This voucher code has already been used.",
+ comment: ""
+ )
+ }
+ return displayErrorDescription ?? ""
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherCoordinator.swift b/ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherCoordinator.swift
new file mode 100644
index 0000000000..d7b63107a5
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherCoordinator.swift
@@ -0,0 +1,57 @@
+//
+// RedeemVoucherCoordinator.swift
+// MullvadVPN
+//
+// Created by Mojgan on 2023-06-13.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+import MullvadREST
+import UIKit
+
+final class RedeemVoucherCoordinator: Coordinator, Presentable {
+ private let navigationController: UINavigationController
+ private let viewController: RedeemVoucherViewController
+ var didFinish: ((RedeemVoucherCoordinator) -> Void)?
+ var didCancel: ((RedeemVoucherCoordinator) -> Void)?
+
+ init(
+ navigationController: UINavigationController,
+ interactor: RedeemVoucherInteractor
+ ) {
+ self.navigationController = navigationController
+ viewController = RedeemVoucherViewController(interactor: interactor)
+ }
+
+ var presentedViewController: UIViewController {
+ return navigationController
+ }
+
+ func start() {
+ navigationController.navigationBar.isHidden = true
+ viewController.delegate = self
+ navigationController.pushViewController(viewController, animated: true)
+ }
+}
+
+extension RedeemVoucherCoordinator: RedeemVoucherViewControllerDelegate {
+ func redeemVoucherDidSucceed(
+ _ controller: RedeemVoucherViewController,
+ with response: REST.SubmitVoucherResponse
+ ) {
+ let viewController = RedeemVoucherSucceededViewController(timeAddedComponents: response.dateComponents)
+ viewController.delegate = self
+ navigationController.pushViewController(viewController, animated: true)
+ }
+
+ func redeemVoucherDidCancel(_ controller: RedeemVoucherViewController) {
+ didCancel?(self)
+ }
+}
+
+extension RedeemVoucherCoordinator: RedeemVoucherSucceededViewControllerDelegate {
+ func redeemVoucherSucceededViewControllerDidFinish(_ controller: RedeemVoucherSucceededViewController) {
+ didFinish?(self)
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherInteractor.swift b/ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherInteractor.swift
new file mode 100644
index 0000000000..1fa5a2c6da
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherInteractor.swift
@@ -0,0 +1,26 @@
+//
+// RedeemVoucherInteractor.swift
+// MullvadVPN
+//
+// Created by Mojgan on 2023-05-24.
+// Copyright © 2023 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+import MullvadREST
+import MullvadTypes
+
+final class RedeemVoucherInteractor {
+ private let tunnelManager: TunnelManager
+
+ init(tunnelManager: TunnelManager) {
+ self.tunnelManager = tunnelManager
+ }
+
+ func redeemVoucher(
+ code: String,
+ completion: @escaping ((Result<REST.SubmitVoucherResponse, Error>) -> Void)
+ ) -> Cancellable {
+ tunnelManager.redeemVoucher(code, completion: completion)
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherSucceededViewController.swift b/ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherSucceededViewController.swift
new file mode 100644
index 0000000000..9ec894734e
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherSucceededViewController.swift
@@ -0,0 +1,141 @@
+//
+// RedeemVoucherSucceededViewController.swift
+// MullvadVPN
+//
+// Created by Sajad Vishkai on 2022-09-23.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+import UIKit
+
+protocol RedeemVoucherSucceededViewControllerDelegate: AnyObject {
+ func redeemVoucherSucceededViewControllerDidFinish(
+ _ controller: RedeemVoucherSucceededViewController
+ )
+}
+
+class RedeemVoucherSucceededViewController: UIViewController {
+ private let statusImageView: StatusImageView = {
+ let statusImageView = StatusImageView(style: .success)
+ statusImageView.translatesAutoresizingMaskIntoConstraints = false
+ return statusImageView
+ }()
+
+ private let titleLabel: UILabel = {
+ let label = UILabel()
+ label.font = UIFont.boldSystemFont(ofSize: 20)
+ label.text = NSLocalizedString(
+ "REDEEM_VOUCHER_SUCCESS_TITLE",
+ tableName: "RedeemVoucher",
+ value: "Voucher was successfully redeemed.",
+ comment: ""
+ )
+ label.textColor = .white
+ label.numberOfLines = 0
+ label.translatesAutoresizingMaskIntoConstraints = false
+ return label
+ }()
+
+ private let messageLabel: UILabel = {
+ let label = UILabel()
+ label.font = UIFont.systemFont(ofSize: 17)
+ label.textColor = UIColor.white.withAlphaComponent(0.6)
+ label.numberOfLines = 0
+ label.translatesAutoresizingMaskIntoConstraints = false
+ return label
+ }()
+
+ private let dismissButton: AppButton = {
+ let button = AppButton(style: .default)
+ button.setTitle(NSLocalizedString(
+ "REDEEM_VOUCHER_DISMISS_BUTTON",
+ tableName: "RedeemVoucher",
+ value: "Got it!",
+ comment: ""
+ ), for: .normal)
+ button.translatesAutoresizingMaskIntoConstraints = false
+ return button
+ }()
+
+ override var preferredStatusBarStyle: UIStatusBarStyle {
+ return .lightContent
+ }
+
+ weak var delegate: RedeemVoucherSucceededViewControllerDelegate?
+
+ init(timeAddedComponents: DateComponents) {
+ super.init(nibName: nil, bundle: nil)
+
+ view.backgroundColor = .secondaryColor
+ view.directionalLayoutMargins = UIMetrics.contentLayoutMargins
+
+ messageLabel.text = String(
+ format: NSLocalizedString(
+ "REDEEM_VOUCHER_SUCCESS_MESSAGE",
+ tableName: "RedeemVoucher",
+ value: "%@ were added to your account.",
+ comment: ""
+ ),
+ timeAddedComponents.formattedAddedDay
+ )
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ addSubviews()
+ addConstraints()
+ addDismissButtonHandler()
+ }
+
+ private func addSubviews() {
+ for subview in [statusImageView, titleLabel, messageLabel, dismissButton] {
+ view.addSubview(subview)
+ }
+ }
+
+ private func addConstraints() {
+ view.addConstrainedSubviews([statusImageView, titleLabel, messageLabel, dismissButton]) {
+ statusImageView.pinEdgesToSuperviewMargins(PinnableEdges([.top(0)]))
+ statusImageView.centerXAnchor.constraint(equalTo: view.centerXAnchor)
+
+ titleLabel.pinEdgesToSuperviewMargins(PinnableEdges([.leading(0), .trailing(0)]))
+ titleLabel.topAnchor.constraint(
+ equalTo: statusImageView.bottomAnchor,
+ constant: UIMetrics.sectionSpacing
+ )
+
+ messageLabel.topAnchor.constraint(
+ equalTo: titleLabel.layoutMarginsGuide.bottomAnchor,
+ constant: UIMetrics.interButtonSpacing
+ )
+ messageLabel.pinEdgesToSuperviewMargins(PinnableEdges([.leading(0), .trailing(0)]))
+
+ dismissButton.pinEdgesToSuperviewMargins(.all().excluding(.top))
+ }
+ }
+
+ private func addDismissButtonHandler() {
+ dismissButton.addTarget(
+ self,
+ action: #selector(handleDismissTap),
+ for: .touchUpInside
+ )
+ }
+
+ @objc private func handleDismissTap() {
+ delegate?.redeemVoucherSucceededViewControllerDidFinish(self)
+ }
+}
+
+private extension DateComponents {
+ var formattedAddedDay: String {
+ let formatter = DateComponentsFormatter()
+ formatter.allowedUnits = [.day]
+ formatter.unitsStyle = .full
+ return formatter.string(from: self) ?? ""
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherViewController.swift b/ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherViewController.swift
new file mode 100644
index 0000000000..1bbab10643
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherViewController.swift
@@ -0,0 +1,100 @@
+//
+// RedeemVoucherViewController.swift
+// MullvadVPN
+//
+// Created by Andreas Lif on 2022-08-05.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+import MullvadREST
+import MullvadTypes
+import UIKit
+
+protocol RedeemVoucherViewControllerDelegate: AnyObject {
+ func redeemVoucherDidSucceed(
+ _ controller: RedeemVoucherViewController,
+ with response: REST.SubmitVoucherResponse
+ )
+ func redeemVoucherDidCancel(_ controller: RedeemVoucherViewController)
+}
+
+class RedeemVoucherViewController: UIViewController, UINavigationControllerDelegate {
+ private let contentView = RedeemVoucherContentView()
+ private var voucherTask: Cancellable?
+ private var interactor: RedeemVoucherInteractor?
+
+ weak var delegate: RedeemVoucherViewControllerDelegate?
+
+ init(interactor: RedeemVoucherInteractor) {
+ super.init(nibName: nil, bundle: nil)
+ self.interactor = interactor
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override var preferredStatusBarStyle: UIStatusBarStyle {
+ return .lightContent
+ }
+
+ // MARK: - Life Cycle
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ configureUI()
+ addActions()
+ }
+
+ override func viewWillAppear(_ animated: Bool) {
+ super.viewWillAppear(animated)
+ enableEditing()
+ }
+
+ // MARK: - private functions
+
+ private func enableEditing() {
+ guard !contentView.isEditing else { return }
+ contentView.isEditing = true
+ }
+
+ private func addActions() {
+ contentView.redeemAction = { [weak self] code in
+ self?.submit(code: code)
+ }
+
+ contentView.cancelAction = { [weak self] in
+ self?.cancel()
+ }
+ }
+
+ private func configureUI() {
+ view.addSubview(contentView)
+ view.addConstrainedSubviews([contentView]) {
+ contentView.pinEdgesToSuperview(.all())
+ }
+ }
+
+ private func submit(code: String) {
+ contentView.state = .verifying
+ voucherTask = interactor?.redeemVoucher(code: code, completion: { [weak self] result in
+ guard let self else { return }
+ switch result {
+ case let .success(value):
+ contentView.state = .success
+ contentView.isEditing = false
+ delegate?.redeemVoucherDidSucceed(self, with: value)
+ case let .failure(error):
+ contentView.state = .failure(error)
+ }
+ })
+ }
+
+ private func cancel() {
+ contentView.isEditing = false
+
+ voucherTask?.cancel()
+
+ delegate?.redeemVoucherDidCancel(self)
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/RedeemVoucher/VoucherTextField.swift b/ios/MullvadVPN/View controllers/RedeemVoucher/VoucherTextField.swift
new file mode 100644
index 0000000000..01cf02477f
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/RedeemVoucher/VoucherTextField.swift
@@ -0,0 +1,65 @@
+//
+// VoucherTextField.swift
+// MullvadVPN
+//
+// Created by Andreas Lif on 2022-08-05.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+import UIKit
+
+class VoucherTextField: CustomTextField, UITextFieldDelegate {
+ private let inputFormatter = InputTextFormatter(configuration: InputTextFormatter.Configuration(
+ allowedInput: .alphanumeric(isUpperCase: true),
+ groupSeparator: "-",
+ groupSize: 4,
+ maxGroups: 4
+ ))
+
+ private var voucherLength: UInt8 {
+ let maxGroups = inputFormatter.configuration.maxGroups
+ let groupSize = inputFormatter.configuration.groupSize
+ return maxGroups * groupSize + (maxGroups - 1)
+ }
+
+ var isVoucherLengthSatisfied: Bool {
+ let length = text?.count ?? 0
+ return length >= voucherLength
+ }
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ delegate = self
+ autocorrectionType = .no
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
+ if #available(iOS 15.0, *),
+ action == #selector(captureTextFromCamera(_:)) { return false }
+ return super.canPerformAction(action, withSender: sender)
+ }
+
+ // MARK: - UITextFieldDelegate
+
+ func textField(
+ _ textField: UITextField,
+ shouldChangeCharactersIn range: NSRange,
+ replacementString string: String
+ ) -> Bool {
+ return inputFormatter.textField(
+ textField,
+ shouldChangeCharactersIn: range,
+ replacementString: string
+ )
+ }
+
+ func textFieldShouldReturn(_ textField: UITextField) -> Bool {
+ textField.resignFirstResponder()
+ return true
+ }
+}