diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2020-03-10 16:21:54 +0100 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2020-03-30 12:19:11 +0200 |
| commit | f66a2870876c7b4127206373e90c8dea33bec63d (patch) | |
| tree | 22e3357f61af932bcf5c76e0d76b95e79918679b | |
| parent | 672da7a7c5eca2e6f0697cf3385bc94366440d1f (diff) | |
| download | mullvadvpn-f66a2870876c7b4127206373e90c8dea33bec63d.tar.xz mullvadvpn-f66a2870876c7b4127206373e90c8dea33bec63d.zip | |
Add in-app purchases UI
| -rw-r--r-- | ios/MullvadVPN.xcodeproj/project.pbxproj | 20 | ||||
| -rw-r--r-- | ios/MullvadVPN/AccountViewController.swift | 201 | ||||
| -rw-r--r-- | ios/MullvadVPN/Base.lproj/Main.storyboard | 120 | ||||
| -rw-r--r-- | ios/MullvadVPN/InAppPurchaseButton.swift | 47 | ||||
| -rw-r--r-- | ios/MullvadVPN/JsonRpc.swift | 4 | ||||
| -rw-r--r-- | ios/MullvadVPN/SKProduct+Formatting.swift | 22 | ||||
| -rw-r--r-- | ios/MullvadVPN/UserInterfaceInteractionRestriction.swift | 90 |
7 files changed, 463 insertions, 41 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index b6565b3dd6..9fb61664a7 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -28,10 +28,10 @@ 5845F842236CBACD00B2D93C /* PacketTunnelIpc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5845F841236CBACD00B2D93C /* PacketTunnelIpc.swift */; }; 5845F843236CBDAB00B2D93C /* PacketTunnelIpc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5845F841236CBACD00B2D93C /* PacketTunnelIpc.swift */; }; 584B26FF237435A90073B10E /* RelaySelector+RelayCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 584B26FD237435990073B10E /* RelaySelector+RelayCache.swift */; }; + 584E96BA240D791E00D3334F /* CancellableDelayPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 584E96B9240D791E00D3334F /* CancellableDelayPublisher.swift */; }; 584E96BC240FD4DA00D3334F /* Location.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58A1AA8623F43901009F7EA6 /* Location.swift */; }; 584E96BD240FD4DA00D3334F /* Location.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58A1AA8623F43901009F7EA6 /* Location.swift */; }; 584E96BE240FD4DB00D3334F /* Location.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58A1AA8623F43901009F7EA6 /* Location.swift */; }; - 584E96BA240D791E00D3334F /* CancellableDelayPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 584E96B9240D791E00D3334F /* CancellableDelayPublisher.swift */; }; 58561C99239A5D1500BD6B5E /* IPEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58561C98239A5D1500BD6B5E /* IPEndpoint.swift */; }; 58561C9A239A5D1500BD6B5E /* IPEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58561C98239A5D1500BD6B5E /* IPEndpoint.swift */; }; 5860F1C223A785C600CEA666 /* WireguardDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5860F1C123A785C600CEA666 /* WireguardDevice.swift */; }; @@ -126,6 +126,9 @@ 58FD5BE724192A2C00112C88 /* AppStoreReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BE624192A2B00112C88 /* AppStoreReceipt.swift */; }; 58FD5BE92419406000112C88 /* SKRequestPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BE82419406000112C88 /* SKRequestPublisher.swift */; }; 58FD5BEC2420F58A00112C88 /* SKPaymentQueuePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BEB2420F58A00112C88 /* SKPaymentQueuePublisher.swift */; }; + 58FD5BF024238EB300112C88 /* SKProduct+Formatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BEF24238EB300112C88 /* SKProduct+Formatting.swift */; }; + 58FD5BF22424F7D700112C88 /* UserInterfaceInteractionRestriction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BF12424F7D700112C88 /* UserInterfaceInteractionRestriction.swift */; }; + 58FD5BF42428C67600112C88 /* InAppPurchaseButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BF32428C67600112C88 /* InAppPurchaseButton.swift */; }; 58FD5BF624291F1A00112C88 /* AppStorePaymentPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BF524291F1A00112C88 /* AppStorePaymentPublisher.swift */; }; /* End PBXBuildFile section */ @@ -267,6 +270,9 @@ 58FD5BE624192A2B00112C88 /* AppStoreReceipt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreReceipt.swift; sourceTree = "<group>"; }; 58FD5BE82419406000112C88 /* SKRequestPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SKRequestPublisher.swift; sourceTree = "<group>"; }; 58FD5BEB2420F58A00112C88 /* SKPaymentQueuePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SKPaymentQueuePublisher.swift; sourceTree = "<group>"; }; + 58FD5BEF24238EB300112C88 /* SKProduct+Formatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SKProduct+Formatting.swift"; sourceTree = "<group>"; }; + 58FD5BF12424F7D700112C88 /* UserInterfaceInteractionRestriction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserInterfaceInteractionRestriction.swift; sourceTree = "<group>"; }; + 58FD5BF32428C67600112C88 /* InAppPurchaseButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppPurchaseButton.swift; sourceTree = "<group>"; }; 58FD5BF524291F1A00112C88 /* AppStorePaymentPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStorePaymentPublisher.swift; sourceTree = "<group>"; }; /* End PBXFileReference section */ @@ -359,19 +365,21 @@ 5868585424054096000B8131 /* AppButton.swift */, 58CE5E63224146200008646E /* AppDelegate.swift */, 58BFA5CB22A7CE1F00A6173D /* ApplicationConfiguration.swift */, - 58FD5BE624192A2B00112C88 /* AppStoreReceipt.swift */, 58DF28A42417CB4B00E836B0 /* AppStorePaymentManager.swift */, 58FD5BF524291F1A00112C88 /* AppStorePaymentPublisher.swift */, + 58FD5BE624192A2B00112C88 /* AppStoreReceipt.swift */, 58CE5E6A224146210008646E /* Assets.xcassets */, 5845F839236C6A7200B2D93C /* AutoDisposableSink.swift */, 589AB4F6227B64450039131E /* BasicTableViewCell.swift */, 58EC4E6B23915325003F5C5B /* Bundle+MullvadVersion.swift */, + 584E96B9240D791E00D3334F /* CancellableDelayPublisher.swift */, 58A1AA8B23F5584B009F7EA6 /* ConnectionPanelView.swift */, 58CCA00F224249A1004F3011 /* ConnectViewController.swift */, 58A99ED2240014A0006599E9 /* ConsentViewController.swift */, 582BB1B0229569620055B6EF /* CustomNavigationBar.swift */, 58C6B35D22BBBFE3003C19AD /* Data+HexCoding.swift */, 5873884C239E6D7E00E96C4E /* EmbeddedViewContainerView.swift */, + 58FD5BF32428C67600112C88 /* InAppPurchaseButton.swift */, 58CE5E6F224146210008646E /* Info.plist */, 5840250022B1124600E4CFEC /* IpAddress+Codable.swift */, 58C6B34E22BB7AC0003C19AD /* IPAddressRange.swift */, @@ -409,8 +417,9 @@ 58CCA01122424D11004F3011 /* SettingsViewController.swift */, 58BA693023EADA6A009DC256 /* SimulatorTunnelProvider.swift */, 587A01FB23F1F0BE00B68763 /* SimulatorTunnelProviderHost.swift */, - 58FD5BE82419406000112C88 /* SKRequestPublisher.swift */, 58FD5BEB2420F58A00112C88 /* SKPaymentQueuePublisher.swift */, + 58FD5BEF24238EB300112C88 /* SKProduct+Formatting.swift */, + 58FD5BE82419406000112C88 /* SKRequestPublisher.swift */, 58F19E34228C15BA00C7710B /* SpinnerActivityIndicatorView.swift */, 581CBCED229826FD00727D7F /* StaticTableViewDataSource.swift */, 5862805322428EF100F5A6E1 /* TranslucentButtonBlurView.swift */, @@ -422,11 +431,11 @@ 58A8BE8223A0F362006B74AC /* UIAlertController+Error.swift */, 587CBFE222807F530028DED3 /* UIColor+Helpers.swift */, 58CCA0152242560B004F3011 /* UIColor+Palette.swift */, + 58FD5BF12424F7D700112C88 /* UserInterfaceInteractionRestriction.swift */, 581CBCE52296B97300727D7F /* ViewControllerIdentifier.swift */, 58B8743122B25A7600015324 /* WireguardAssociatedAddresses.swift */, 5877152F23981F7B001F8237 /* WireguardKeysViewController.swift */, 58C6B35322BB87C4003C19AD /* WireguardPrivateKey.swift */, - 584E96B9240D791E00D3334F /* CancellableDelayPublisher.swift */, ); path = MullvadVPN; sourceTree = "<group>"; @@ -773,6 +782,7 @@ 58C6B35E22BBBFE3003C19AD /* Data+HexCoding.swift in Sources */, 58AEEF652344A36000C9BBD5 /* KeychainError.swift in Sources */, 58CCA01222424D11004F3011 /* SettingsViewController.swift in Sources */, + 58FD5BF42428C67600112C88 /* InAppPurchaseButton.swift in Sources */, 589AB4F7227B64450039131E /* BasicTableViewCell.swift in Sources */, 5888AD7F2279B6BF0051EB06 /* RelayStatusIndicatorView.swift in Sources */, 5867A51C2248F26A005513C0 /* SegueIdentifier.swift in Sources */, @@ -784,7 +794,9 @@ 588AE72F2362001F009F9F2E /* MutuallyExclusive.swift in Sources */, 5888AD89227B18C40051EB06 /* RelayList.swift in Sources */, 587AD7C623421D7000E93A53 /* TunnelConfiguration.swift in Sources */, + 58FD5BF024238EB300112C88 /* SKProduct+Formatting.swift in Sources */, 58561C99239A5D1500BD6B5E /* IPEndpoint.swift in Sources */, + 58FD5BF22424F7D700112C88 /* UserInterfaceInteractionRestriction.swift in Sources */, 5811DE50239014550011EB53 /* NEVPNStatus+Debug.swift in Sources */, 58AEEF682344A40800C9BBD5 /* TunnelConfigurationCoder.swift in Sources */, 58C3A4B222456F1B00340BDB /* AccountInputGroupView.swift in Sources */, diff --git a/ios/MullvadVPN/AccountViewController.swift b/ios/MullvadVPN/AccountViewController.swift index 58a35875cc..e7ed9b7ef3 100644 --- a/ios/MullvadVPN/AccountViewController.swift +++ b/ios/MullvadVPN/AccountViewController.swift @@ -7,34 +7,152 @@ // import Combine +import StoreKit import UIKit +import os class AccountViewController: UIViewController { @IBOutlet var accountTokenButton: UIButton! + @IBOutlet var purchaseButton: InAppPurchaseButton! + @IBOutlet var restoreButton: AppButton! + @IBOutlet var logoutButton: AppButton! @IBOutlet var expiryLabel: UILabel! + @IBOutlet var activityIndicator: SpinnerActivityIndicatorView! + private var accountExpirySubscriber: AnyCancellable? private var logoutSubscriber: AnyCancellable? private var copyToPasteboardSubscriber: AnyCancellable? + private var requestProductsSubscriber: AnyCancellable? + private var purchaseSubscriber: AnyCancellable? + private var restorePurchasesSubscriber: AnyCancellable? + + private lazy var purchaseButtonInteractionRestriction = + UserInterfaceInteractionRestriction(scheduler: DispatchQueue.main) { + [weak self] (enableUserInteraction, _) in + self?.purchaseButton.isEnabled = enableUserInteraction + } + + private lazy var viewControllerInteractionRestriction = + UserInterfaceInteractionRestriction(scheduler: DispatchQueue.main) { + [weak self] (enableUserInteraction, animated) in + self?.setEnableUserInteraction(enableUserInteraction, animated: true) + } + + private lazy var compoundInteractionRestriction = + CompoundUserInterfaceInteractionRestriction(restrictions: [ + purchaseButtonInteractionRestriction, viewControllerInteractionRestriction]) + + private var product: SKProduct? + + // MARK: - View lifecycle override func viewDidLoad() { super.viewDidLoad() + accountExpirySubscriber = NotificationCenter.default + .publisher(for: Account.didUpdateAccountExpiryNotification, object: Account.shared) + .receive(on: DispatchQueue.main) + .sink { [weak self] (notification) in + guard let newExpiryDate = notification + .userInfo?[Account.newAccountExpiryUserInfoKey] as? Date else { return } + + self?.updateAccountExpiry(expiryDate: newExpiryDate) + } + + // Make sure the buy button scales down the font size to fit the long labels. + // Changing baseline adjustment helps to prevent the text from being misaligned after + // being scaled down. + purchaseButton.titleLabel?.adjustsFontSizeToFitWidth = true + purchaseButton.titleLabel?.baselineAdjustment = .alignCenters + accountTokenButton.setTitle(Account.shared.token, for: .normal) if let expiryDate = Account.shared.expiry { - let accountExpiry = AccountExpiry(date: expiryDate) + updateAccountExpiry(expiryDate: expiryDate) + } + + requestStoreProducts() + } + + // MARK: - Private methods - if accountExpiry.isExpired { - expiryLabel.text = NSLocalizedString("OUT OF TIME", comment: "") - expiryLabel.textColor = .dangerColor - } else { - expiryLabel.text = accountExpiry.formattedDate - expiryLabel.textColor = .white - } + private func updateAccountExpiry(expiryDate: Date) { + let accountExpiry = AccountExpiry(date: expiryDate) + + if accountExpiry.isExpired { + expiryLabel.text = NSLocalizedString("OUT OF TIME", comment: "") + expiryLabel.textColor = .dangerColor + } else { + expiryLabel.text = accountExpiry.formattedDate + expiryLabel.textColor = .white } } + private func requestStoreProducts() { + purchaseButton.isLoading = true + + requestProductsSubscriber = AppStorePaymentManager.shared.requestProducts(with: [.thirtyDays]) + .retry(1) + .receive(on: DispatchQueue.main) + .restrictUserInterfaceInteraction(with: self.purchaseButtonInteractionRestriction, animated: true) + .sink(receiveCompletion: { [weak self] (completion) in + if case .finished = completion { + self?.purchaseButton.isLoading = false + } + }, receiveValue: { [weak self] (response) in + if let product = response.products.first { + self?.setProduct(product, animated: true) + } + }) + } + + private func setProduct(_ product: SKProduct, animated: Bool) { + self.product = product + + let localizedPrice = product.localizedPrice ?? "" + let title = String(format: NSLocalizedString("%@ (%@)", comment: ""), + product.localizedTitle, localizedPrice) + purchaseButton.setTitle(title, for: .normal) + } + + private func setEnableUserInteraction(_ enableUserInteraction: Bool, animated: Bool) { + // Disable all buttons + [restoreButton, logoutButton].forEach { (button) in + button?.isEnabled = enableUserInteraction + } + + // Disable any interaction within the view + view.isUserInteractionEnabled = enableUserInteraction + + // Prevent view controller from being swiped away by user + isModalInPresentation = !enableUserInteraction + + // Hide back button in navigation bar + navigationItem.setHidesBackButton(!enableUserInteraction, animated: animated) + + // Show/hide the spinner next to "Paid until" + if enableUserInteraction { + activityIndicator.stopAnimating() + } else { + activityIndicator.startAnimating() + } + } + + private func showTimeAddedConfirmationAlert( + with response: SendAppStoreReceiptResponse, + context: SendAppStoreReceiptResponse.Context) + { + let alertController = UIAlertController( + title: response.alertTitle(context: context), + message: response.alertMessage(context: context), + preferredStyle: .alert + ) + alertController.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: .cancel)) + + present(alertController, animated: true) + } + // MARK: - Actions @IBAction func doLogout() { @@ -79,4 +197,71 @@ class AccountViewController: UIViewController { }) } + @IBAction func doPurchase() { + guard let product = product else { return } + + let payment = SKPayment(product: product) + + purchaseSubscriber = AppStorePaymentManager.shared + .addPayment(payment, for: Account.shared.token!) + .receive(on: DispatchQueue.main) + .restrictUserInterfaceInteraction(with: compoundInteractionRestriction, animated: true) + .sink(receiveCompletion: { [weak self] (completion) in + if case .failure(let error) = completion { + self?.presentError(error, preferredStyle: .alert) + } + }, receiveValue: { [weak self] (response) in + self?.showTimeAddedConfirmationAlert(with: response, context: .purchase) + }) + } + + @IBAction func restorePurchases() { + restorePurchasesSubscriber = AppStorePaymentManager.shared + .restorePurchases(for: Account.shared.token!) + .receive(on: DispatchQueue.main) + .restrictUserInterfaceInteraction(with: compoundInteractionRestriction, animated: true) + .sink(receiveCompletion: { [weak self] (completion) in + if case .failure(let error) = completion { + self?.presentError(error, preferredStyle: .alert) + } + }, receiveValue: { [weak self] (response) in + self?.showTimeAddedConfirmationAlert(with: response, context: .restoration) + }) + } + +} + +private extension SendAppStoreReceiptResponse { + + enum Context { + case purchase + case restoration + } + + func alertTitle(context: Context) -> String { + switch context { + case .purchase: + return NSLocalizedString("Thanks for your purchase", comment: "") + case .restoration: + return NSLocalizedString("Restore purchases", comment: "") + } + } + + func alertMessage(context: Context) -> String { + switch context { + case .purchase: + return String( + format: NSLocalizedString("%@ have been added to your account", comment: ""), + formattedTimeAdded ?? "" + ) + case .restoration: + return timeAdded.isZero + ? NSLocalizedString( + "Your previous purchases have already been added to this account.", + comment: "") + : String( + format: NSLocalizedString("%@ have been added to your account", comment: ""), + formattedTimeAdded ?? "") + } + } } diff --git a/ios/MullvadVPN/Base.lproj/Main.storyboard b/ios/MullvadVPN/Base.lproj/Main.storyboard index ce028e4a78..45f4feed30 100644 --- a/ios/MullvadVPN/Base.lproj/Main.storyboard +++ b/ios/MullvadVPN/Base.lproj/Main.storyboard @@ -40,10 +40,10 @@ <rect key="frame" x="0.0" y="0.0" width="375" height="93"/> <subviews> <imageView userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="LogoIcon" translatesAutoresizingMaskIntoConstraints="NO" id="cKg-hE-JsS"> - <rect key="frame" x="12" y="31.5" width="49.000000000000014" height="50"/> + <rect key="frame" x="12" y="6.5" width="98" height="100"/> </imageView> <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="uXv-Tf-PET"> - <rect key="frame" x="335" y="44.5" width="24" height="24"/> + <rect key="frame" x="311" y="32.5" width="48" height="48"/> <accessibility key="accessibilityConfiguration" identifier="SettingsButton"/> <state key="normal" image="IconSettings"/> <connections> @@ -51,7 +51,7 @@ </connections> </button> <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="MULLVAD VPN" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="dqy-A0-TdV"> - <rect key="frame" x="69" y="42" width="168" height="29"/> + <rect key="frame" x="118" y="42" width="168" height="29"/> <fontDescription key="fontDescription" type="boldSystem" pointSize="24"/> <color key="textColor" white="1" alpha="0.80000000000000004" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <nil key="highlightedColor"/> @@ -113,7 +113,7 @@ </constraints> </view> <imageView clipsSubviews="YES" userInteractionEnabled="NO" alpha="0.0" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="IconSuccess" translatesAutoresizingMaskIntoConstraints="NO" id="7ux-Tb-Fzq"> - <rect key="frame" x="157.5" y="167" width="60" height="60"/> + <rect key="frame" x="127.5" y="137" width="120" height="120"/> </imageView> <view contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" translatesAutoresizingMaskIntoConstraints="NO" id="V3j-Lb-fSQ" userLabel="Form"> <rect key="frame" x="0.0" y="251" width="375" height="125.5"/> @@ -374,13 +374,13 @@ <autoresizingMask key="autoresizingMask"/> <subviews> <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Account" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Lve-Kd-qTr"> - <rect key="frame" x="16" y="11" width="63.5" height="21.5"/> + <rect key="frame" x="16" y="11" width="63.5" height="21"/> <fontDescription key="fontDescription" type="system" pointSize="17"/> <color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <nil key="highlightedColor"/> </label> <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="252" verticalHuggingPriority="251" text="A YEAR LEFT" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="QeD-EQ-Ruo"> - <rect key="frame" x="259" y="11" width="81" height="21.5"/> + <rect key="frame" x="259" y="11" width="81" height="21"/> <fontDescription key="fontDescription" type="system" weight="medium" pointSize="13"/> <color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <nil key="highlightedColor"/> @@ -412,13 +412,13 @@ <autoresizingMask key="autoresizingMask"/> <subviews> <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="App version" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="pYC-Zb-8N9"> - <rect key="frame" x="16" y="11" width="91" height="21.5"/> + <rect key="frame" x="16" y="11" width="91" height="21"/> <fontDescription key="fontDescription" type="system" pointSize="17"/> <color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <nil key="highlightedColor"/> </label> <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="252" verticalHuggingPriority="251" text="2018.3" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="sOr-vj-cg7"> - <rect key="frame" x="316.5" y="11" width="42.5" height="21.5"/> + <rect key="frame" x="316.5" y="11" width="42.5" height="21"/> <fontDescription key="fontDescription" type="system" weight="medium" pointSize="13"/> <color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <nil key="highlightedColor"/> @@ -447,7 +447,7 @@ <autoresizingMask key="autoresizingMask"/> <subviews> <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Amw-A3-ePS"> - <rect key="frame" x="16" y="11" width="324" height="21.5"/> + <rect key="frame" x="16" y="11" width="324" height="21"/> <fontDescription key="fontDescription" type="system" pointSize="17"/> <color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <nil key="highlightedColor"/> @@ -741,10 +741,10 @@ <rect key="frame" x="0.0" y="0.0" width="375" height="647"/> <subviews> <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="rkG-Xa-pEO" userLabel="Container"> - <rect key="frame" x="0.0" y="0.0" width="375" height="229.5"/> + <rect key="frame" x="0.0" y="0.0" width="375" height="349.5"/> <subviews> <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="nkx-Eb-7le" userLabel="Content"> - <rect key="frame" x="24" y="24" width="327" height="181.5"/> + <rect key="frame" x="24" y="24" width="327" height="301.5"/> <subviews> <view contentMode="scaleToFill" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="HzF-8Z-UBs" userLabel="Account number"> <rect key="frame" x="0.0" y="0.0" width="327" height="46"/> @@ -785,12 +785,40 @@ <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="NMg-f0-BTW"> <rect key="frame" x="0.0" y="0.0" width="327" height="45.5"/> <subviews> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Active until" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="nrG-9Q-lWI"> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="fyk-lv-ggt"> <rect key="frame" x="0.0" y="0.0" width="327" height="17"/> - <fontDescription key="fontDescription" type="system" pointSize="14"/> - <color key="textColor" white="1" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> - <nil key="highlightedColor"/> - </label> + <subviews> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="751" text="Paid until" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="nrG-9Q-lWI"> + <rect key="frame" x="0.0" y="0.0" width="59.5" height="17"/> + <fontDescription key="fontDescription" type="system" pointSize="14"/> + <color key="textColor" white="1" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <nil key="highlightedColor"/> + </label> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="20K-WW-5v6" customClass="SpinnerActivityIndicatorView" customModule="MullvadVPN" customModuleProvider="target"> + <rect key="frame" x="311" y="0.5" width="16" height="16"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstAttribute="width" constant="16" id="Hym-zs-PN7"/> + <constraint firstAttribute="height" constant="16" id="uys-o5-CZJ"/> + </constraints> + <userDefinedRuntimeAttributes> + <userDefinedRuntimeAttribute type="number" keyPath="thickness"> + <real key="value" value="2"/> + </userDefinedRuntimeAttribute> + </userDefinedRuntimeAttributes> + </view> + </subviews> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstItem="20K-WW-5v6" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="nrG-9Q-lWI" secondAttribute="trailing" constant="8" symbolic="YES" id="3uR-Ln-ICn"/> + <constraint firstItem="nrG-9Q-lWI" firstAttribute="top" secondItem="fyk-lv-ggt" secondAttribute="top" id="a1L-QD-n7C"/> + <constraint firstAttribute="bottom" secondItem="nrG-9Q-lWI" secondAttribute="bottom" id="cbS-5z-9b7"/> + <constraint firstItem="nrG-9Q-lWI" firstAttribute="leading" secondItem="fyk-lv-ggt" secondAttribute="leading" id="ii0-xx-bNC"/> + <constraint firstAttribute="trailing" secondItem="20K-WW-5v6" secondAttribute="trailing" id="wwh-w9-ngZ"/> + <constraint firstItem="20K-WW-5v6" firstAttribute="centerY" secondItem="fyk-lv-ggt" secondAttribute="centerY" id="z8g-E3-YGZ"/> + </constraints> + </view> <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" text="May 16, 2019" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsLetterSpacingToFitWidth="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="8Vg-dd-ZpW"> <rect key="frame" x="0.0" y="25" width="327" height="20.5"/> <fontDescription key="fontDescription" type="system" weight="medium" pointSize="17"/> @@ -807,8 +835,39 @@ <constraint firstItem="NMg-f0-BTW" firstAttribute="leading" secondItem="459-0n-9V2" secondAttribute="leading" id="vqI-Vt-8V6"/> </constraints> </view> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="12" translatesAutoresizingMaskIntoConstraints="NO" id="J7Z-sf-Cjx"> + <rect key="frame" x="0.0" y="139.5" width="327" height="96"/> + <subviews> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="Ja8-Zt-rQX" customClass="InAppPurchaseButton" customModule="MullvadVPN" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="327" height="42"/> + <accessibility key="accessibilityConfiguration" identifier="LogoutButton"/> + <constraints> + <constraint firstAttribute="height" constant="42" placeholder="YES" id="h63-ia-ihB"/> + </constraints> + <state key="normal" title="Display name for in-app purchase" backgroundImage="SuccessButton"> + <color key="titleColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </state> + <connections> + <action selector="doPurchase" destination="ruh-Q2-P39" eventType="touchUpInside" id="PHS-Qd-y9J"/> + </connections> + </button> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="h5f-yH-jeE" customClass="AppButton" customModule="MullvadVPN" customModuleProvider="target"> + <rect key="frame" x="0.0" y="54" width="327" height="42"/> + <accessibility key="accessibilityConfiguration" identifier="LogoutButton"/> + <constraints> + <constraint firstAttribute="height" constant="42" placeholder="YES" id="Zuv-DV-LSL"/> + </constraints> + <state key="normal" title="Restore purchases" backgroundImage="DefaultButton"> + <color key="titleColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </state> + <connections> + <action selector="restorePurchases" destination="ruh-Q2-P39" eventType="touchUpInside" id="ILp-fL-Ab5"/> + </connections> + </button> + </subviews> + </stackView> <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="QHr-Lz-v6t" customClass="AppButton" customModule="MullvadVPN" customModuleProvider="target"> - <rect key="frame" x="0.0" y="139.5" width="327" height="42"/> + <rect key="frame" x="0.0" y="259.5" width="327" height="42"/> <accessibility key="accessibilityConfiguration" identifier="LogoutButton"/> <constraints> <constraint firstAttribute="height" constant="42" placeholder="YES" id="VYx-GQ-CIz"/> @@ -822,15 +881,18 @@ </button> </subviews> <constraints> - <constraint firstItem="QHr-Lz-v6t" firstAttribute="top" secondItem="459-0n-9V2" secondAttribute="bottom" constant="24" id="6IV-09-erh"/> <constraint firstItem="QHr-Lz-v6t" firstAttribute="leading" secondItem="nkx-Eb-7le" secondAttribute="leading" id="EEA-bt-bSx"/> <constraint firstItem="459-0n-9V2" firstAttribute="leading" secondItem="nkx-Eb-7le" secondAttribute="leading" id="G86-ck-dqe"/> <constraint firstAttribute="trailing" secondItem="459-0n-9V2" secondAttribute="trailing" id="HUb-T5-Wkk"/> + <constraint firstItem="J7Z-sf-Cjx" firstAttribute="top" secondItem="459-0n-9V2" secondAttribute="bottom" constant="24" id="LFm-ye-Fog"/> <constraint firstItem="459-0n-9V2" firstAttribute="top" secondItem="HzF-8Z-UBs" secondAttribute="bottom" constant="24" id="Ttn-aK-Cj0"/> + <constraint firstItem="QHr-Lz-v6t" firstAttribute="top" secondItem="J7Z-sf-Cjx" secondAttribute="bottom" constant="24" id="Zfk-OU-5Ka"/> <constraint firstItem="HzF-8Z-UBs" firstAttribute="leading" secondItem="nkx-Eb-7le" secondAttribute="leading" id="bCL-Z9-nk4"/> <constraint firstAttribute="trailing" secondItem="QHr-Lz-v6t" secondAttribute="trailing" id="eBz-Is-dHp"/> <constraint firstAttribute="bottom" secondItem="QHr-Lz-v6t" secondAttribute="bottom" id="fRA-bC-3eO"/> + <constraint firstItem="J7Z-sf-Cjx" firstAttribute="leading" secondItem="nkx-Eb-7le" secondAttribute="leading" id="nNI-7C-xEi"/> <constraint firstAttribute="trailing" secondItem="HzF-8Z-UBs" secondAttribute="trailing" id="pVC-Ci-c98"/> + <constraint firstAttribute="trailing" secondItem="J7Z-sf-Cjx" secondAttribute="trailing" id="tgl-YH-hLZ"/> <constraint firstItem="HzF-8Z-UBs" firstAttribute="top" secondItem="nkx-Eb-7le" secondAttribute="top" id="vsH-Ee-fch"/> </constraints> </view> @@ -864,14 +926,18 @@ <navigationItem key="navigationItem" title="Account" id="rL3-Y8-3g8"/> <connections> <outlet property="accountTokenButton" destination="XNH-JJ-9gR" id="yCU-t3-ayW"/> + <outlet property="activityIndicator" destination="20K-WW-5v6" id="DKS-1x-8oF"/> <outlet property="expiryLabel" destination="8Vg-dd-ZpW" id="3n5-2Z-J8y"/> + <outlet property="logoutButton" destination="QHr-Lz-v6t" id="K7y-9z-xdj"/> + <outlet property="purchaseButton" destination="Ja8-Zt-rQX" id="fbk-aY-fj5"/> + <outlet property="restoreButton" destination="h5f-yH-jeE" id="jAy-mR-DEN"/> <segue destination="P2i-eG-jQx" kind="unwind" identifier="Logout" unwindAction="unwindFromAccountWithSegue:" id="5li-wk-yRM"/> </connections> </viewController> <placeholder placeholderIdentifier="IBFirstResponder" id="3tt-67-nI8" userLabel="First Responder" sceneMemberID="firstResponder"/> <exit id="P2i-eG-jQx" userLabel="Exit" sceneMemberID="exit"/> </objects> - <point key="canvasLocation" x="2578" y="-1258"/> + <point key="canvasLocation" x="2576.8000000000002" y="-1258.0209895052474"/> </scene> <!--Navigation Controller--> <scene sceneID="er3-W2-NkS"> @@ -903,7 +969,7 @@ <rect key="frame" x="0.0" y="0.0" width="375" height="598"/> <subviews> <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="N9k-cQ-tlw" userLabel="Content view"> - <rect key="frame" x="0.0" y="0.0" width="375" height="558"/> + <rect key="frame" x="0.0" y="0.0" width="375" height="568"/> <subviews> <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Wnl-L9-JqG" userLabel="Logo header"> <rect key="frame" x="0.0" y="0.0" width="375" height="100"/> @@ -939,7 +1005,7 @@ <nil key="highlightedColor"/> </label> <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="leading" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="Cas-Tk-gcz" customClass="LinkButton" customModule="MullvadVPN" customModuleProvider="target"> - <rect key="frame" x="20" y="516" width="20" height="22"/> + <rect key="frame" x="20" y="516" width="36" height="32"/> <fontDescription key="fontDescription" name=".AppleSystemUIFont" family=".AppleSystemUIFont" pointSize="18"/> <color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <state key="normal" title="Privacy Policy" image="IconExtlink"/> @@ -1067,31 +1133,31 @@ </view> <prototypes> <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" reuseIdentifier="Cell" id="aFz-H5-sPu" customClass="SelectLocationCell" customModule="MullvadVPN" customModuleProvider="target"> - <rect key="frame" x="0.0" y="173" width="375" height="43"/> + <rect key="frame" x="0.0" y="173" width="375" height="48.5"/> <autoresizingMask key="autoresizingMask"/> <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="aFz-H5-sPu" id="6nQ-gT-vzf"> - <rect key="frame" x="0.0" y="0.0" width="375" height="43"/> + <rect key="frame" x="0.0" y="0.0" width="375" height="48.5"/> <autoresizingMask key="autoresizingMask"/> <subviews> <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="5ag-N4-pUg" customClass="RelayStatusIndicatorView" customModule="MullvadVPN" customModuleProvider="target"> - <rect key="frame" x="16" y="13.5" width="16" height="16"/> + <rect key="frame" x="16" y="16.5" width="16" height="16"/> <constraints> <constraint firstAttribute="height" constant="16" id="QWj-hh-I3P"/> <constraint firstAttribute="width" constant="16" id="TFV-yi-LXG"/> </constraints> </view> <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="y7o-0b-MUV"> - <rect key="frame" x="44" y="11" width="42" height="21"/> + <rect key="frame" x="44" y="11" width="42" height="26.5"/> <fontDescription key="fontDescription" type="system" pointSize="17"/> <color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <nil key="highlightedColor"/> </label> <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="IconTick" translatesAutoresizingMaskIntoConstraints="NO" id="e1o-Bl-zd5"> - <rect key="frame" x="12" y="9.5" width="24" height="24"/> + <rect key="frame" x="0.0" y="0.5" width="48" height="48"/> <color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> </imageView> <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="KaW-bN-I51"> - <rect key="frame" x="311" y="0.0" width="64" height="43"/> + <rect key="frame" x="311" y="0.0" width="64" height="48.5"/> <constraints> <constraint firstAttribute="width" constant="64" id="UU3-Di-65E"/> </constraints> diff --git a/ios/MullvadVPN/InAppPurchaseButton.swift b/ios/MullvadVPN/InAppPurchaseButton.swift new file mode 100644 index 0000000000..38ae3c90fe --- /dev/null +++ b/ios/MullvadVPN/InAppPurchaseButton.swift @@ -0,0 +1,47 @@ +// +// InAppPurchaseButton.swift +// MullvadVPN +// +// Created by pronebird on 23/03/2020. +// Copyright © 2020 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import UIKit + +class InAppPurchaseButton: AppButton { + + let activityIndicator = SpinnerActivityIndicatorView(style: .medium) + + var isLoading: Bool = false { + didSet { + if isLoading { + activityIndicator.startAnimating() + } else { + activityIndicator.stopAnimating() + } + + titleLabel?.alpha = isLoading ? 0 : 1 + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + commonInit() + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + commonInit() + } + + private func commonInit() { + addSubview(activityIndicator) + } + + override func layoutSubviews() { + super.layoutSubviews() + + activityIndicator.center = self.center + } +} diff --git a/ios/MullvadVPN/JsonRpc.swift b/ios/MullvadVPN/JsonRpc.swift index 2e79cf7e90..a41678fa1e 100644 --- a/ios/MullvadVPN/JsonRpc.swift +++ b/ios/MullvadVPN/JsonRpc.swift @@ -14,7 +14,7 @@ extension Encodable { } } -struct AnyEncodable : Encodable { +struct AnyEncodable: Encodable { let value: Encodable init(_ value: Encodable) { @@ -44,7 +44,7 @@ class JsonRpcResponseError<ResponseCode>: Error, Decodable let code: ResponseCode let message: String - var localizedDescription: String? { + var localizedDescription: String { return message } diff --git a/ios/MullvadVPN/SKProduct+Formatting.swift b/ios/MullvadVPN/SKProduct+Formatting.swift new file mode 100644 index 0000000000..19063622bc --- /dev/null +++ b/ios/MullvadVPN/SKProduct+Formatting.swift @@ -0,0 +1,22 @@ +// +// SKProduct+Formatting.swift +// MullvadVPN +// +// Created by pronebird on 19/03/2020. +// Copyright © 2020 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import StoreKit + +extension SKProduct { + + var localizedPrice: String? { + let formatter = NumberFormatter() + formatter.locale = priceLocale + formatter.numberStyle = .currency + + return formatter.string(from: price) + } + +} diff --git a/ios/MullvadVPN/UserInterfaceInteractionRestriction.swift b/ios/MullvadVPN/UserInterfaceInteractionRestriction.swift new file mode 100644 index 0000000000..a8f1455b5f --- /dev/null +++ b/ios/MullvadVPN/UserInterfaceInteractionRestriction.swift @@ -0,0 +1,90 @@ +// +// UserInterfaceInteractionRestriction.swift +// MullvadVPN +// +// Created by pronebird on 20/03/2020. +// Copyright © 2020 Mullvad VPN AB. All rights reserved. +// + +import Combine +import Foundation + +/// A protocol describing a common interface for the implementations of user interaction restriction +protocol UserInterfaceInteractionRestrictionProtocol { + /// Raise the user interface interaction restrictions + func lift(animated: Bool) + + /// Lift the user interface interaction restrictions + func raise(animated: Bool) +} + +/// A counter based user interface interaction restriction implementation +class UserInterfaceInteractionRestriction<S: Scheduler> + : UserInterfaceInteractionRestrictionProtocol +{ + typealias Action = (_ disableUserInteraction: Bool, _ animated: Bool) -> Void + + private let action: Action + private let scheduler: S + private var counter: UInt = 0 + + init(scheduler: S, action: @escaping Action) { + self.action = action + self.scheduler = scheduler + } + + func raise(animated: Bool) { + scheduler.schedule { + if self.counter == 0 { + self.action(false, animated) + } + self.counter += 1 + } + } + + func lift(animated: Bool) { + scheduler.schedule { + guard self.counter > 0 else { return } + + self.counter -= 1 + if self.counter == 0 { + self.action(true, animated) + } + } + } +} + +/// A user interface restriction implementation that simply combines multiple child restrictions +/// into one and automatically forwards all calls to them in the order in which they are given to +/// the initializer. +class CompoundUserInterfaceInteractionRestriction: UserInterfaceInteractionRestrictionProtocol { + private let restrictions: [UserInterfaceInteractionRestrictionProtocol] + + init(restrictions: [UserInterfaceInteractionRestrictionProtocol]) { + self.restrictions = restrictions + } + + func lift(animated: Bool) { + restrictions.forEach { $0.lift(animated: animated) } + } + + func raise(animated: Bool) { + restrictions.forEach { $0.raise(animated: animated) } + } +} + +extension Publisher { + func restrictUserInterfaceInteraction( + with restriction: UserInterfaceInteractionRestrictionProtocol, + animated: Bool + ) -> Publishers.HandleEvents<Self> + { + return handleEvents(receiveSubscription: { _ in + restriction.raise(animated: animated) + }, receiveCompletion: { _ in + restriction.lift(animated: animated) + }, receiveCancel: { () in + restriction.lift(animated: animated) + }) + } +} |
