diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2023-06-20 14:00:48 +0200 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2023-06-20 14:00:48 +0200 |
| commit | 50f3227de1d3a57751da6066e8edb8508d01fed1 (patch) | |
| tree | 8d28b55d475f601700509b7e968f30bcf4d6b0eb | |
| parent | 989124e088bae04748c6f0cc0e95d2d3e0117131 (diff) | |
| parent | fad51b6305a3c709d0556ab4e4f222c3109b7920 (diff) | |
| download | mullvadvpn-50f3227de1d3a57751da6066e8edb8508d01fed1.tar.xz mullvadvpn-50f3227de1d3a57751da6066e8edb8508d01fed1.zip | |
Merge branch 'implement-redeeming-voucher-ui-part-ios-193'
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 + } +} |
