diff options
| author | Bug Magnet <marco.nikic@mullvad.net> | 2023-09-13 16:28:16 +0200 |
|---|---|---|
| committer | Bug Magnet <marco.nikic@mullvad.net> | 2023-09-13 16:28:16 +0200 |
| commit | 571d6bd61dfb7151ca28ebd4cce0516f710b7c9f (patch) | |
| tree | d01938a194efeac8bebf10616b31dbd23bb47f04 | |
| parent | c0ee7f5b8686a4ed67cec01b574a43c7e9ef0287 (diff) | |
| parent | f47bbebe41337e471a602f969a05dd06e8904779 (diff) | |
| download | mullvadvpn-571d6bd61dfb7151ca28ebd4cce0516f710b7c9f.tar.xz mullvadvpn-571d6bd61dfb7151ca28ebd4cce0516f710b7c9f.zip | |
Merge branch 'coordinate-alert-presentation-ios-175'
34 files changed, 704 insertions, 482 deletions
diff --git a/ios/.swiftlint.yml b/ios/.swiftlint.yml index 9e7704961d..5a66346daa 100644 --- a/ios/.swiftlint.yml +++ b/ios/.swiftlint.yml @@ -31,6 +31,8 @@ line_length: ignores_interpolated_strings: true warning: 120 error: 300 +cyclomatic_complexity: + ignores_case_statements: true type_name: min_length: 4 diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 7aab178ca5..f65ffd2851 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -135,7 +135,6 @@ 586A0DD12A20E371006C731C /* WireGuardKitTypes in Frameworks */ = {isa = PBXBuildFile; productRef = 586A0DD02A20E371006C731C /* WireGuardKitTypes */; }; 586A0DD42A20E4A9006C731C /* MullvadREST.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 06799ABC28F98E1D00ACD94E /* MullvadREST.framework */; }; 586A950C290125EE007BAF2B /* AlertPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B9EB122488ED2100095626 /* AlertPresenter.swift */; }; - 586A950D290125F0007BAF2B /* PresentAlertOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5820675D26E6839900655B05 /* PresentAlertOperation.swift */; }; 586A950E290125F3007BAF2B /* ProductsRequestOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5846226426E0D9630035F7C2 /* ProductsRequestOperation.swift */; }; 586A950F29012BEE007BAF2B /* AddressCacheTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06AC114028F841390037AF9A /* AddressCacheTracker.swift */; }; 586E54FB27A2DF6D0029B88B /* SendTunnelProviderMessageOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586E54FA27A2DF6D0029B88B /* SendTunnelProviderMessageOperation.swift */; }; @@ -390,6 +389,7 @@ 58FF2C03281BDE02009EF542 /* SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FF2C02281BDE02009EF542 /* SettingsManager.swift */; }; 7A02D4EB2A9CEC7A00C19E31 /* MullvadVPNScreenshots.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = 7A02D4EA2A9CEC7A00C19E31 /* MullvadVPNScreenshots.xctestplan */; }; 7A09C98129D99215000C2CAC /* String+FuzzyMatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A09C98029D99215000C2CAC /* String+FuzzyMatch.swift */; }; + 7A0C0F632A979C4A0058EFCE /* Coordinator+Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A0C0F622A979C4A0058EFCE /* Coordinator+Router.swift */; }; 7A11DD0B2A9495D400098CD8 /* AppRoutes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5802EBC42A8E44AC00E5CE4C /* AppRoutes.swift */; }; 7A1A26432A2612AE00B978AA /* PaymentAlertPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1A26422A2612AE00B978AA /* PaymentAlertPresenter.swift */; }; 7A21DACF2A30AA3700A787A9 /* UITextField+Appearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A21DACE2A30AA3700A787A9 /* UITextField+Appearance.swift */; }; @@ -399,6 +399,8 @@ 7A3353912AAA014400F0A71C /* SimulatorVPNConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3353902AAA014400F0A71C /* SimulatorVPNConnection.swift */; }; 7A3353932AAA089000F0A71C /* SimulatorTunnelInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3353922AAA089000F0A71C /* SimulatorTunnelInfo.swift */; }; 7A3353972AAA0F8600F0A71C /* OperationBlockObserverSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3353962AAA0F8600F0A71C /* OperationBlockObserverSupport.swift */; }; + 7A2960F62A963F7500389B82 /* AlertCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A2960F52A963F7500389B82 /* AlertCoordinator.swift */; }; + 7A2960FD2A964BB700389B82 /* AlertPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A2960FC2A964BB700389B82 /* AlertPresentation.swift */; }; 7A42DEC92A05164100B209BE /* SettingsInputCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A42DEC82A05164100B209BE /* SettingsInputCell.swift */; }; 7A42DECD2A09064C00B209BE /* SelectableSettingsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A42DECC2A09064C00B209BE /* SelectableSettingsCell.swift */; }; 7A7AD28D29DC677800480EF1 /* FirstTimeLaunch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A7AD28C29DC677800480EF1 /* FirstTimeLaunch.swift */; }; @@ -438,7 +440,7 @@ 7ABCA5B72A9353C60044A708 /* Coordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CAF9F72983D36800BE19F7 /* Coordinator.swift */; }; 7ABE318D2A1CDD4500DF4963 /* UIFont+Weight.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ABE318C2A1CDD4500DF4963 /* UIFont+Weight.swift */; }; 7AE044BB2A935726003915D8 /* Routing.h in Headers */ = {isa = PBXBuildFile; fileRef = 7A88DCD02A8FABBE00D2FF0E /* Routing.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 7AE47E522A17972A000418DA /* CustomAlertViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AE47E512A17972A000418DA /* CustomAlertViewController.swift */; }; + 7AE47E522A17972A000418DA /* AlertViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AE47E512A17972A000418DA /* AlertViewController.swift */; }; 7AF6E5F02A95051E00F2679D /* RouterBlockDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF6E5EF2A95051E00F2679D /* RouterBlockDelegate.swift */; }; 7AF6E5F12A95F4A500F2679D /* DurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FBFBF0291630700020E046 /* DurationTests.swift */; }; 7AF9BE992A4E0FE900DBFEDB /* MarkdownStylingOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE982A4E0FE900DBFEDB /* MarkdownStylingOptions.swift */; }; @@ -1040,7 +1042,6 @@ 581F23AC2A8CF92100788AB6 /* MockDefaultPathObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDefaultPathObserver.swift; sourceTree = "<group>"; }; 581F23AE2A8CF94D00788AB6 /* MockPinger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPinger.swift; sourceTree = "<group>"; }; 5820675A26E6576800655B05 /* RelayCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayCache.swift; sourceTree = "<group>"; }; - 5820675D26E6839900655B05 /* PresentAlertOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentAlertOperation.swift; sourceTree = "<group>"; }; 5820676326E771DB00655B05 /* TunnelManagerErrors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelManagerErrors.swift; sourceTree = "<group>"; }; 5820EDA8288FE064006BF4E4 /* DeviceManagementInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceManagementInteractor.swift; sourceTree = "<group>"; }; 5820EDAA288FF0D2006BF4E4 /* DeviceRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRowView.swift; sourceTree = "<group>"; }; @@ -1314,6 +1315,7 @@ 58FF2C02281BDE02009EF542 /* SettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManager.swift; sourceTree = "<group>"; }; 7A02D4EA2A9CEC7A00C19E31 /* MullvadVPNScreenshots.xctestplan */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = MullvadVPNScreenshots.xctestplan; sourceTree = "<group>"; }; 7A09C98029D99215000C2CAC /* String+FuzzyMatch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+FuzzyMatch.swift"; sourceTree = "<group>"; }; + 7A0C0F622A979C4A0058EFCE /* Coordinator+Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Coordinator+Router.swift"; sourceTree = "<group>"; }; 7A1A26422A2612AE00B978AA /* PaymentAlertPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentAlertPresenter.swift; sourceTree = "<group>"; }; 7A21DACE2A30AA3700A787A9 /* UITextField+Appearance.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UITextField+Appearance.swift"; sourceTree = "<group>"; }; 7A307AD82A8CD8DA0017618B /* Duration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Duration.swift; sourceTree = "<group>"; }; @@ -1322,6 +1324,8 @@ 7A3353902AAA014400F0A71C /* SimulatorVPNConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimulatorVPNConnection.swift; sourceTree = "<group>"; }; 7A3353922AAA089000F0A71C /* SimulatorTunnelInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimulatorTunnelInfo.swift; sourceTree = "<group>"; }; 7A3353962AAA0F8600F0A71C /* OperationBlockObserverSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationBlockObserverSupport.swift; sourceTree = "<group>"; }; + 7A2960F52A963F7500389B82 /* AlertCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertCoordinator.swift; sourceTree = "<group>"; }; + 7A2960FC2A964BB700389B82 /* AlertPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertPresentation.swift; sourceTree = "<group>"; }; 7A42DEC82A05164100B209BE /* SettingsInputCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsInputCell.swift; sourceTree = "<group>"; }; 7A42DECC2A09064C00B209BE /* SelectableSettingsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableSettingsCell.swift; sourceTree = "<group>"; }; 7A7AD28C29DC677800480EF1 /* FirstTimeLaunch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirstTimeLaunch.swift; sourceTree = "<group>"; }; @@ -1353,7 +1357,7 @@ 7A9CCCB12A96302800DD6A34 /* ApplicationCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ApplicationCoordinator.swift; sourceTree = "<group>"; }; 7A9CCCB22A96302800DD6A34 /* TunnelCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TunnelCoordinator.swift; sourceTree = "<group>"; }; 7ABE318C2A1CDD4500DF4963 /* UIFont+Weight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Weight.swift"; sourceTree = "<group>"; }; - 7AE47E512A17972A000418DA /* CustomAlertViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomAlertViewController.swift; sourceTree = "<group>"; }; + 7AE47E512A17972A000418DA /* AlertViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertViewController.swift; sourceTree = "<group>"; }; 7AF6E5EF2A95051E00F2679D /* RouterBlockDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouterBlockDelegate.swift; sourceTree = "<group>"; }; 7AF9BE982A4E0FE900DBFEDB /* MarkdownStylingOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownStylingOptions.swift; sourceTree = "<group>"; }; A917351E29FAA9C400D5DCFD /* RESTTransportStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RESTTransportStrategy.swift; sourceTree = "<group>"; }; @@ -1784,10 +1788,11 @@ 583FE01629C196E8006E85F9 /* View controllers */ = { isa = PBXGroup; children = ( - F0EF50D12A8FA47E0031E8DF /* ChangeLog */, 583FE02029C1A0B1006E85F9 /* Account */, F0E8E4BF2A602C7D00ED26A3 /* AccountDeletion */, + 7A2960F72A964A3500389B82 /* Alert */, F0E8E4B92A55593300ED26A3 /* CreationAccount */, + F0EF50D12A8FA47E0031E8DF /* ChangeLog */, 583FE01D29C197C1006E85F9 /* DeviceList */, 583FE02529C1AD0E006E85F9 /* Launch */, 583FE02129C1A0F4006E85F9 /* Login */, @@ -1966,6 +1971,7 @@ 5891BF1B25E3E3EB006D6FB0 /* Bundle+ProductVersion.swift */, 587EB669270EFACB00123C75 /* CharacterSet+IPAddress.swift */, 58E511E528DDDEAC00B0BCDE /* CodingErrors+CustomErrorDescription.swift */, + 7A0C0F622A979C4A0058EFCE /* Coordinator+Router.swift */, 5811DE4F239014550011EB53 /* NEVPNStatus+Debug.swift */, 584EBDBC2747C98F00A0C9FD /* NSAttributedString+Markdown.swift */, 587D9675288989DB00CD8F1C /* NSLayoutConstraint+Helpers.swift */, @@ -2021,10 +2027,9 @@ children = ( 587988C628A2A01F00E3DF54 /* AccountDataThrottling.swift */, F04FBE602A8379EE009278D7 /* AppPreferences.swift */, - 58FEEB57260B662E00A621A8 /* AutomaticKeyboardResponder.swift */, 5802EBC42A8E44AC00E5CE4C /* AppRoutes.swift */, + 58FEEB57260B662E00A621A8 /* AutomaticKeyboardResponder.swift */, 5871FB95254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift */, - 7AE47E512A17972A000418DA /* CustomAlertViewController.swift */, 5896AE83246D5889005B36CB /* CustomDateComponentsFormatting.swift */, 58138E60294871C600684F0C /* DeviceDataThrottling.swift */, 7A7AD28C29DC677800480EF1 /* FirstTimeLaunch.swift */, @@ -2122,8 +2127,6 @@ 586A950B2901250A007BAF2B /* Operations */ = { isa = PBXGroup; children = ( - 58B9EB122488ED2100095626 /* AlertPresenter.swift */, - 5820675D26E6839900655B05 /* PresentAlertOperation.swift */, 5846226426E0D9630035F7C2 /* ProductsRequestOperation.swift */, ); path = Operations; @@ -2312,6 +2315,7 @@ 7A9CCCAF2A96302800DD6A34 /* AccountCoordinator.swift */, 7A9CCCAC2A96302800DD6A34 /* AccountDeletionCoordinator.swift */, 7A9CCCA32A96302700DD6A34 /* AddCreditSucceededCoordinator.swift */, + 7A2960F52A963F7500389B82 /* AlertCoordinator.swift */, 7A9CCCB12A96302800DD6A34 /* ApplicationCoordinator.swift */, 7A9CCCAA2A96302700DD6A34 /* ChangeLogCoordinator.swift */, 7A9CCCA82A96302700DD6A34 /* CreateAccountVoucherCoordinator.swift */, @@ -2564,6 +2568,16 @@ path = MullvadRESTTests; sourceTree = "<group>"; }; + 7A2960F72A964A3500389B82 /* Alert */ = { + isa = PBXGroup; + children = ( + 7A2960FC2A964BB700389B82 /* AlertPresentation.swift */, + 58B9EB122488ED2100095626 /* AlertPresenter.swift */, + 7AE47E512A17972A000418DA /* AlertViewController.swift */, + ); + path = Alert; + sourceTree = "<group>"; + }; 7A83C3FC2A55B39500DFB83A /* TestPlans */ = { isa = PBXGroup; children = ( @@ -3804,6 +3818,7 @@ 7ABE318D2A1CDD4500DF4963 /* UIFont+Weight.swift in Sources */, 58C774BE29A7A249003A1A56 /* CustomNavigationController.swift in Sources */, E1FD0DF528AA7CE400299DB4 /* StatusActivityView.swift in Sources */, + 7A2960FD2A964BB700389B82 /* AlertPresentation.swift in Sources */, 0697D6E728F01513007A9E99 /* TransportMonitor.swift in Sources */, 58968FAE28743E2000B799DC /* TunnelInteractor.swift in Sources */, 5864AF0929C78850005B0CD9 /* PreferencesCellFactory.swift in Sources */, @@ -3846,6 +3861,7 @@ 58F19E35228C15BA00C7710B /* SpinnerActivityIndicatorView.swift in Sources */, 5864859929A0D028006C5743 /* FormsheetPresentationController.swift in Sources */, 7A9CCCB52A96302800DD6A34 /* AddCreditSucceededCoordinator.swift in Sources */, + 7A0C0F632A979C4A0058EFCE /* Coordinator+Router.swift in Sources */, 58A99ED3240014A0006599E9 /* TermsOfServiceViewController.swift in Sources */, 58CCA0162242560B004F3011 /* UIColor+Palette.swift in Sources */, 587CBFE322807F530028DED3 /* UIColor+Helpers.swift in Sources */, @@ -3890,7 +3906,7 @@ 5891BF1C25E3E3EB006D6FB0 /* Bundle+ProductVersion.swift in Sources */, 5878A26F2907E7E00096FC88 /* ProblemReportInteractor.swift in Sources */, 7A3353912AAA014400F0A71C /* SimulatorVPNConnection.swift in Sources */, - 7AE47E522A17972A000418DA /* CustomAlertViewController.swift in Sources */, + 7AE47E522A17972A000418DA /* AlertViewController.swift in Sources */, F028A56A2A34D4E700C0CAA3 /* RedeemVoucherViewController.swift in Sources */, 58E11188292FA11F009FCA84 /* SettingsMigrationUIHandler.swift in Sources */, 58CAFA002983FF0200BE19F7 /* LoginInteractor.swift in Sources */, @@ -3910,7 +3926,6 @@ 58607A4D2947287800BC467D /* AccountExpiryInAppNotificationProvider.swift in Sources */, 58C8191829FAA2C400DEB1B4 /* NotificationConfiguration.swift in Sources */, 06410E07292D108E00AFC18C /* SettingsStore.swift in Sources */, - 586A950D290125F0007BAF2B /* PresentAlertOperation.swift in Sources */, 7A7AD28F29DEDB1C00480EF1 /* SettingsHeaderView.swift in Sources */, 58B93A1326C3F13600A55733 /* TunnelState.swift in Sources */, 58B26E262943522400D5980C /* NotificationProvider.swift in Sources */, @@ -3949,6 +3964,7 @@ 584592612639B4A200EF967F /* TermsOfServiceContentView.swift in Sources */, 584EBDBD2747C98F00A0C9FD /* NSAttributedString+Markdown.swift in Sources */, 5875960A26F371FC00BF6711 /* Tunnel+Messaging.swift in Sources */, + 7A2960F62A963F7500389B82 /* AlertCoordinator.swift in Sources */, 063687BA28EB234F00BE7161 /* PacketTunnelTransport.swift in Sources */, 587425C12299833500CA2045 /* RootContainerViewController.swift in Sources */, 5896AE84246D5889005B36CB /* CustomDateComponentsFormatting.swift in Sources */, diff --git a/ios/MullvadVPN/Classes/AppRoutes.swift b/ios/MullvadVPN/Classes/AppRoutes.swift index a217fe8539..13c087b9e5 100644 --- a/ios/MullvadVPN/Classes/AppRoutes.swift +++ b/ios/MullvadVPN/Classes/AppRoutes.swift @@ -39,12 +39,17 @@ enum AppRouteGroup: AppRouteGroupProtocol { */ case changelog + /** + Alert group. Alert id should match the id of the alert being contained. + */ + case alert(_ alertId: String) + var isModal: Bool { switch self { case .primary: return UIDevice.current.userInterfaceIdiom == .pad - case .selectLocation, .account, .settings, .changelog: + case .selectLocation, .account, .settings, .changelog, .alert: return true } } @@ -55,6 +60,9 @@ enum AppRouteGroup: AppRouteGroupProtocol { return 0 case .settings, .account, .selectLocation, .changelog: return 1 + case .alert: + // Alerts should always be topmost. + return .max } } } @@ -84,13 +92,19 @@ enum AppRoute: AppRouteProtocol { case changelog /** + Alert route. Alert id must be a unique string in order to produce a unique route + that distinguishes between different kinds of alerts. + */ + case alert(_ alertId: String) + + /** Routes that are part of primary horizontal navigation group. */ case tos, login, main, revoked, outOfTime, welcome var isExclusive: Bool { switch self { - case .selectLocation, .account, .settings, .changelog: + case .selectLocation, .account, .settings, .changelog, .alert: return true default: return false @@ -117,6 +131,8 @@ enum AppRoute: AppRouteProtocol { return .account case .settings: return .settings + case let .alert(id): + return .alert(id) } } } diff --git a/ios/MullvadVPN/Coordinators/AccountCoordinator.swift b/ios/MullvadVPN/Coordinators/AccountCoordinator.swift index b1811001c2..0338d7427b 100644 --- a/ios/MullvadVPN/Coordinators/AccountCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/AccountCoordinator.swift @@ -23,17 +23,12 @@ enum AddedMoreCreditOption: Equatable { final class AccountCoordinator: Coordinator, Presentable, Presenting { private let interactor: AccountInteractor private var accountController: AccountViewController? - private let alertPresenter = AlertPresenter() let navigationController: UINavigationController var presentedViewController: UIViewController { navigationController } - var presentationContext: UIViewController { - navigationController - } - var didFinish: ((AccountCoordinator, AccountDismissReason) -> Void)? init( @@ -49,10 +44,7 @@ final class AccountCoordinator: Coordinator, Presentable, Presenting { let accountController = AccountViewController( interactor: interactor, - errorPresenter: PaymentAlertPresenter( - presentationController: presentationContext, - alertPresenter: alertPresenter - ) + errorPresenter: PaymentAlertPresenter(alertContext: self) ) accountController.actionHandler = handleViewControllerAction @@ -141,19 +133,25 @@ final class AccountCoordinator: Coordinator, Presentable, Presenting { // MARK: - Alerts private func logOut() { - let alertController = CustomAlertViewController(icon: .spinner) + let presentation = AlertPresentation( + id: "account-logout-alert", + icon: .spinner, + message: nil, + buttons: [] + ) - alertPresenter.enqueue(alertController, presentingController: presentationContext) { - self.interactor.logout { - DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { [weak self] in - guard let self else { return } + let alertPresenter = AlertPresenter(context: self) - alertController.dismiss(animated: true) { - self.didFinish?(self, .userLoggedOut) - } - } + interactor.logout { + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { [weak self] in + guard let self else { return } + + alertPresenter.dismissAlert(presentation: presentation, animated: true) + self.didFinish?(self, .userLoggedOut) } } + + alertPresenter.showAlert(presentation: presentation, animated: true) } private func showAccountDeviceInfo() { @@ -172,21 +170,21 @@ final class AccountCoordinator: Coordinator, Presentable, Presenting { comment: "" ) - let alertController = CustomAlertViewController( + let presentation = AlertPresentation( + id: "account-device-info-alert", message: message, - icon: .info - ) - - alertController.addAction( - title: NSLocalizedString( - "DEVICE_INFO_DIALOG_OK_ACTION", - tableName: "Account", - value: "Got it!", - comment: "" - ), - style: .default + buttons: [AlertAction( + title: NSLocalizedString( + "DEVICE_INFO_DIALOG_OK_ACTION", + tableName: "Account", + value: "Got it!", + comment: "" + ), + style: .default + )] ) - alertPresenter.enqueue(alertController, presentingController: presentationContext) + let presenter = AlertPresenter(context: self) + presenter.showAlert(presentation: presentation, animated: true) } } diff --git a/ios/MullvadVPN/Coordinators/AlertCoordinator.swift b/ios/MullvadVPN/Coordinators/AlertCoordinator.swift new file mode 100644 index 0000000000..3dbdc59f7a --- /dev/null +++ b/ios/MullvadVPN/Coordinators/AlertCoordinator.swift @@ -0,0 +1,45 @@ +// +// AlertCoordinator.swift +// MullvadVPN +// +// Created by Jon Petersson on 2023-08-23. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import MullvadLogging +import Routing +import UIKit + +final class AlertCoordinator: Coordinator, Presentable { + private var alertController: AlertViewController? + private let presentation: AlertPresentation + + var didFinish: (() -> Void)? + + var presentedViewController: UIViewController { + return alertController! + } + + init(presentation: AlertPresentation) { + self.presentation = presentation + } + + func start() { + let alertController = AlertViewController( + header: presentation.header, + title: presentation.title, + message: presentation.message, + icon: presentation.icon + ) + + self.alertController = alertController + + alertController.onDismiss = { [weak self] in + self?.didFinish?() + } + + presentation.buttons.forEach { action in + alertController.addAction(title: action.title, style: action.style, handler: action.handler) + } + } +} diff --git a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift index bcc87d657a..7f6cb1d474 100644 --- a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift @@ -17,14 +17,13 @@ import UIKit Application coordinator managing split view and two navigation contexts. */ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewControllerDelegate, - UISplitViewControllerDelegate, ApplicationRouterDelegate, - NotificationManagerDelegate { + UISplitViewControllerDelegate, ApplicationRouterDelegate, NotificationManagerDelegate { typealias RouteType = AppRoute /** Application router. */ - private var router: ApplicationRouter<AppRoute>! + private(set) var router: ApplicationRouter<AppRoute>! /** Primary navigation container. @@ -127,11 +126,11 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo func applicationRouter( _ router: ApplicationRouter<RouteType>, - route: AppRoute, + presentWithContext context: RoutePresentationContext<RouteType>, animated: Bool, completion: @escaping (Coordinator) -> Void ) { - switch route { + switch context.route { case .account: presentAccount(animated: animated, completion: completion) @@ -161,6 +160,9 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo case .welcome: presentWelcome(animated: animated, completion: completion) + + case .alert: + presentAlert(animated: animated, context: context, completion: completion) } } @@ -169,15 +171,15 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo dismissWithContext context: RouteDismissalContext<RouteType>, completion: @escaping () -> Void ) { - if context.isClosing { - let dismissedRoute = context.dismissedRoutes.first! + let dismissedRoute = context.dismissedRoutes.first! + if context.isClosing { switch dismissedRoute.route.routeGroup { case .primary: endHorizontalFlow(animated: context.isAnimated, completion: completion) context.dismissedRoutes.forEach { $0.coordinator.removeFromParent() } - case .selectLocation, .account, .settings, .changelog: + case .selectLocation, .account, .settings, .changelog, .alert: guard let coordinator = dismissedRoute.coordinator as? Presentable else { completion() return assertionFailure("Expected presentable coordinator for \(dismissedRoute.route)") @@ -186,13 +188,13 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo coordinator.dismiss(animated: context.isAnimated, completion: completion) } } else { - let dismissedRoute = context.dismissedRoutes.first! assert(context.dismissedRoutes.count == 1) - if dismissedRoute.route == .outOfTime { - guard let coordinator = dismissedRoute.coordinator as? OutOfTimeCoordinator else { + switch dismissedRoute.route { + case .outOfTime, .welcome: + guard let coordinator = dismissedRoute.coordinator as? Poppable else { completion() - return assertionFailure("Unhandled coordinator for \(dismissedRoute.route)") + return assertionFailure("Expected presentable coordinator for \(dismissedRoute.route)") } coordinator.popFromNavigationStack( @@ -201,19 +203,8 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo ) coordinator.removeFromParent() - } else if dismissedRoute.route == .welcome { - guard let coordinator = dismissedRoute.coordinator as? WelcomeCoordinator else { - completion() - return assertionFailure("Unhandled coordinator for \(dismissedRoute.route)") - } - coordinator.popFromNavigationStack( - animated: context.isAnimated, - completion: completion - ) - - coordinator.removeFromParent() - } else { + default: assertionFailure("Unhandled dismissal for \(dismissedRoute.route)") completion() } @@ -579,11 +570,10 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo ) coordinator.didFinishPayment = { [weak self] _ in - guard let self else { return } + guard let self = self else { return } if shouldDismissOutOfTime() { router.dismiss(.outOfTime, animated: true) - continueFlow(animated: true) } } @@ -628,10 +618,7 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo !(tunnelManager.deviceState.accountData?.isExpired ?? false) } - private func presentSelectLocation( - animated: Bool, - completion: @escaping (Coordinator) -> Void - ) { + private func presentSelectLocation(animated: Bool, completion: @escaping (Coordinator) -> Void) { let coordinator = makeSelectLocationCoordinator(forModalPresentation: true) coordinator.start() @@ -664,6 +651,29 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo } } + private func presentAlert( + animated: Bool, + context: RoutePresentationContext<RouteType>, + completion: @escaping (Coordinator) -> Void + ) { + guard let metadata = context.metadata as? AlertMetadata else { + assertionFailure("Could not get AlertMetadata from RoutePresentationContext.") + return + } + + let coordinator = AlertCoordinator(presentation: metadata.presentation) + + coordinator.didFinish = { [weak self] in + self?.router.dismiss(context.route) + } + + coordinator.start() + + metadata.context.presentChild(coordinator, animated: animated) { + completion(coordinator) + } + } + private func makeTunnelCoordinator() -> TunnelCoordinator { let tunnelCoordinator = TunnelCoordinator(tunnelManager: tunnelManager) diff --git a/ios/MullvadVPN/Coordinators/ChangeLogCoordinator.swift b/ios/MullvadVPN/Coordinators/ChangeLogCoordinator.swift index 3437b55f8b..ae86829b12 100644 --- a/ios/MullvadVPN/Coordinators/ChangeLogCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/ChangeLogCoordinator.swift @@ -11,7 +11,7 @@ import Routing import UIKit final class ChangeLogCoordinator: Coordinator, Presentable { - private var alertController: CustomAlertViewController? + private var alertController: AlertViewController? private let interactor: ChangeLogInteractor var didFinish: ((ChangeLogCoordinator) -> Void)? @@ -24,7 +24,7 @@ final class ChangeLogCoordinator: Coordinator, Presentable { } func start() { - let alertController = CustomAlertViewController( + let alertController = AlertViewController( header: interactor.viewModel.header, title: interactor.viewModel.title, attributedMessage: interactor.viewModel.body @@ -43,6 +43,7 @@ final class ChangeLogCoordinator: Coordinator, Presentable { didFinish?(self) } ) + self.alertController = alertController } } diff --git a/ios/MullvadVPN/Coordinators/InAppPurchaseCoordinator.swift b/ios/MullvadVPN/Coordinators/InAppPurchaseCoordinator.swift index f4cea6a013..e2ccb578df 100644 --- a/ios/MullvadVPN/Coordinators/InAppPurchaseCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/InAppPurchaseCoordinator.swift @@ -11,7 +11,7 @@ import Routing import StoreKit import UIKit -class InAppPurchaseCoordinator: Coordinator, Presentable { +class InAppPurchaseCoordinator: Coordinator, Presenting, Presentable { private let navigationController: RootContainerViewController private let interactor: InAppPurchaseInteractor @@ -49,23 +49,29 @@ class InAppPurchaseCoordinator: Coordinator, Presentable { coordinator.start() case let .failure(failure): - let alertController = CustomAlertViewController( + let presentation = AlertPresentation( + id: "in-app-purchase-error-alert", + icon: .alert, message: failure.error.localizedDescription, - icon: .alert + buttons: [ + AlertAction( + title: NSLocalizedString( + "IN_APP_PURCHASE_ERROR_DIALOG_OK_ACTION", + tableName: "Welcome", + value: "Got it!", + comment: "" + ), + style: .default, + handler: { [weak self] in + guard let self = self else { return } + self.didCancel?(self) + } + ), + ] ) - alertController.addAction( - title: NSLocalizedString( - "IN_APP_PURCHASE_ERROR_DIALOG_OK_ACTION", - tableName: "Welcome", - value: "Got it!", - comment: "" - ), - style: .default - ) - presentedViewController.present(alertController, animated: true) { - self.didCancel?(self) - } + let presenter = AlertPresenter(context: self) + presenter.showAlert(presentation: presentation, animated: true) } } } diff --git a/ios/MullvadVPN/Coordinators/LoginCoordinator.swift b/ios/MullvadVPN/Coordinators/LoginCoordinator.swift index a85353c410..f62869f271 100644 --- a/ios/MullvadVPN/Coordinators/LoginCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/LoginCoordinator.swift @@ -13,7 +13,7 @@ import Operations import Routing import UIKit -final class LoginCoordinator: Coordinator, DeviceManagementViewControllerDelegate { +final class LoginCoordinator: Coordinator, Presenting, DeviceManagementViewControllerDelegate { private let tunnelManager: TunnelManager private let devicesProxy: REST.DevicesProxy @@ -25,6 +25,9 @@ final class LoginCoordinator: Coordinator, DeviceManagementViewControllerDelegat var didCreateAccount: (() -> Void)? var preferredAccountNumberPublisher: AnyPublisher<String, Never>? + var presentationContext: UIViewController { + navigationController + } let navigationController: RootContainerViewController @@ -117,10 +120,14 @@ final class LoginCoordinator: Coordinator, DeviceManagementViewControllerDelegat accountNumber: accountNumber, devicesProxy: devicesProxy ) - let controller = DeviceManagementViewController(interactor: interactor) + let controller = DeviceManagementViewController( + interactor: interactor, + alertPresenter: AlertPresenter(context: self) + ) controller.delegate = self + controller.fetchDevices(animateUpdates: false) { [weak self] result in - guard let self else { return } + guard let self = self else { return } switch result { case .success: diff --git a/ios/MullvadVPN/Coordinators/OutOfTimeCoordinator.swift b/ios/MullvadVPN/Coordinators/OutOfTimeCoordinator.swift index a001d2d060..5c333c4564 100644 --- a/ios/MullvadVPN/Coordinators/OutOfTimeCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/OutOfTimeCoordinator.swift @@ -9,13 +9,17 @@ import Routing import UIKit -class OutOfTimeCoordinator: Coordinator, OutOfTimeViewControllerDelegate { +class OutOfTimeCoordinator: Coordinator, Presenting, OutOfTimeViewControllerDelegate, Poppable { let navigationController: RootContainerViewController let storePaymentManager: StorePaymentManager let tunnelManager: TunnelManager var didFinishPayment: ((OutOfTimeCoordinator) -> Void)? + var presentedViewController: UIViewController { + navigationController + } + private(set) var isMakingPayment = false private var viewController: OutOfTimeViewController? @@ -42,10 +46,7 @@ class OutOfTimeCoordinator: Coordinator, OutOfTimeViewControllerDelegate { let controller = OutOfTimeViewController( interactor: interactor, - errorPresenter: PaymentAlertPresenter( - presentationController: navigationController, - alertPresenter: AlertPresenter() - ) + errorPresenter: PaymentAlertPresenter(alertContext: self) ) controller.delegate = self @@ -55,9 +56,9 @@ class OutOfTimeCoordinator: Coordinator, OutOfTimeViewControllerDelegate { navigationController.pushViewController(controller, animated: animated) } - func popFromNavigationStack(animated: Bool, completion: @escaping () -> Void) { + func popFromNavigationStack(animated: Bool, completion: (() -> Void)?) { guard let viewController else { - completion() + completion?() return } diff --git a/ios/MullvadVPN/Coordinators/SettingsCoordinator.swift b/ios/MullvadVPN/Coordinators/SettingsCoordinator.swift index 9e48af43ca..88a32826b5 100644 --- a/ios/MullvadVPN/Coordinators/SettingsCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/SettingsCoordinator.swift @@ -32,10 +32,6 @@ final class SettingsCoordinator: Coordinator, Presentable, Presenting, SettingsV navigationController } - var presentationContext: UIViewController { - navigationController - } - var willNavigate: (( _ coordinator: SettingsCoordinator, _ from: SettingsNavigationRoute?, @@ -158,12 +154,14 @@ final class SettingsCoordinator: Coordinator, Presentable, Presenting, SettingsV case .preferences: return PreferencesViewController( - interactor: interactorFactory.makePreferencesInteractor() + interactor: interactorFactory.makePreferencesInteractor(), + alertPresenter: AlertPresenter(context: self) ) case .problemReport: return ProblemReportViewController( - interactor: interactorFactory.makeProblemReportInteractor() + interactor: interactorFactory.makeProblemReportInteractor(), + alertPresenter: AlertPresenter(context: self) ) case .faq: diff --git a/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift b/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift index da686aadca..255728ccef 100644 --- a/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift @@ -9,13 +9,16 @@ import Routing import UIKit -class TunnelCoordinator: Coordinator { +class TunnelCoordinator: Coordinator, Presenting { private let tunnelManager: TunnelManager private let controller: TunnelViewController - private let alertPresenter = AlertPresenter() private var tunnelObserver: TunnelObserver? + var presentationContext: UIViewController { + controller + } + var rootViewController: UIViewController { controller } @@ -59,40 +62,41 @@ class TunnelCoordinator: Coordinator { } private func showCancelTunnelAlert() { - let alertController = CustomAlertViewController( - title: nil, + let presentation = AlertPresentation( + id: "main-cancel-tunnel-alert", + icon: .alert, message: NSLocalizedString( "CANCEL_TUNNEL_ALERT_MESSAGE", tableName: "Main", value: "If you disconnect now, you won’t be able to secure your connection until the device is online.", comment: "" ), - icon: .alert - ) - - alertController.addAction( - title: NSLocalizedString( - "CANCEL_TUNNEL_ALERT_DISCONNECT_ACTION", - tableName: "Main", - value: "Disconnect", - comment: "" - ), - style: .destructive, - handler: { [weak self] in - self?.tunnelManager.stopTunnel() - } - ) - - alertController.addAction( - title: NSLocalizedString( - "CANCEL_TUNNEL_ALERT_CANCEL_ACTION", - tableName: "Main", - value: "Cancel", - comment: "" - ), - style: .default + buttons: [ + AlertAction( + title: NSLocalizedString( + "CANCEL_TUNNEL_ALERT_DISCONNECT_ACTION", + tableName: "Main", + value: "Disconnect", + comment: "" + ), + style: .destructive, + handler: { [weak self] in + self?.tunnelManager.stopTunnel() + } + ), + AlertAction( + title: NSLocalizedString( + "CANCEL_TUNNEL_ALERT_CANCEL_ACTION", + tableName: "Main", + value: "Cancel", + comment: "" + ), + style: .default + ), + ] ) - alertPresenter.enqueue(alertController, presentingController: rootViewController) + let presenter = AlertPresenter(context: self) + presenter.showAlert(presentation: presentation, animated: true) } } diff --git a/ios/MullvadVPN/Coordinators/WelcomeCoordinator.swift b/ios/MullvadVPN/Coordinators/WelcomeCoordinator.swift index 7a489478df..a614dd780f 100644 --- a/ios/MullvadVPN/Coordinators/WelcomeCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/WelcomeCoordinator.swift @@ -12,7 +12,7 @@ import Routing import StoreKit import UIKit -final class WelcomeCoordinator: Coordinator, Presentable, Presenting { +final class WelcomeCoordinator: Coordinator, Poppable, Presenting { private let navigationController: RootContainerViewController private let storePaymentManager: StorePaymentManager private let tunnelManager: TunnelManager @@ -28,10 +28,6 @@ final class WelcomeCoordinator: Coordinator, Presentable, Presenting { navigationController } - var presentationContext: UIViewController { - navigationController - } - init( navigationController: RootContainerViewController, storePaymentManager: StorePaymentManager, @@ -70,11 +66,11 @@ final class WelcomeCoordinator: Coordinator, Presentable, Presenting { navigationController.pushViewController(controller, animated: animated) } - func popFromNavigationStack(animated: Bool, completion: @escaping () -> Void) { + func popFromNavigationStack(animated: Bool, completion: (() -> Void)?) { guard let viewController, let index = navigationController.viewControllers.firstIndex(of: viewController) else { - completion() + completion?() return } navigationController.setViewControllers( @@ -87,36 +83,42 @@ final class WelcomeCoordinator: Coordinator, Presentable, Presenting { extension WelcomeCoordinator: WelcomeViewControllerDelegate { func didRequestToShowInfo(controller: WelcomeViewController) { - let message = """ - This is the name assigned to the device. Each device logged in on a \ - Mullvad account gets a unique name that helps \ - you identify it when you manage your devices in the app or on the website. + let message = NSLocalizedString( + "WELCOME_DEVICE_CONCEPT_TEXT_DIALOG", + tableName: "Welcome", + value: + """ + This is the name assigned to the device. Each device logged in on a \ + Mullvad account gets a unique name that helps \ + you identify it when you manage your devices in the app or on the website. - You can have up to 5 devices logged in on one Mullvad account. + You can have up to 5 devices logged in on one Mullvad account. - If you log out, the device and the device name is removed. \ - When you log back in again, the device will get a new name. - """ - let alertController = CustomAlertViewController( - message: NSLocalizedString( - "WELCOME_DEVICE_CONCEPT_TEXT_DIALOG", - tableName: "Welcome", - value: message, - comment: "" - ), - icon: .info + If you log out, the device and the device name is removed. \ + When you log back in again, the device will get a new name. + """, + comment: "" ) - alertController.addAction( - title: NSLocalizedString( - "WELCOME_DEVICE_NAME_DIALOG_OK_ACTION", - tableName: "Welcome", - value: "Got it!", - comment: "" - ), - style: .default + let presentation = AlertPresentation( + id: "welcome-device-name-alert", + icon: .info, + message: message, + buttons: [ + AlertAction( + title: NSLocalizedString( + "WELCOME_DEVICE_NAME_DIALOG_OK_ACTION", + tableName: "Welcome", + value: "Got it!", + comment: "" + ), + style: .default + ), + ] ) - presentedViewController.present(alertController, animated: true) + + let presenter = AlertPresenter(context: self) + presenter.showAlert(presentation: presentation, animated: true) } func didRequestToPurchaseCredit(controller: WelcomeViewController, accountNumber: String, product: SKProduct) { @@ -153,7 +155,7 @@ extension WelcomeCoordinator: WelcomeViewControllerDelegate { ) coordinator.didCancel = { [weak self] coordinator in - guard let self else { return } + guard let self = self else { return } navigationController.popViewController(animated: true) coordinator.removeFromParent() } diff --git a/ios/MullvadVPN/Extensions/Coordinator+Router.swift b/ios/MullvadVPN/Extensions/Coordinator+Router.swift new file mode 100644 index 0000000000..dba2f5fed5 --- /dev/null +++ b/ios/MullvadVPN/Extensions/Coordinator+Router.swift @@ -0,0 +1,22 @@ +// +// Coordinator+Router.swift +// MullvadVPN +// +// Created by Jon Petersson on 2023-08-24. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import Routing + +extension Coordinator { + var applicationRouter: ApplicationRouter<AppRoute>? { + var appCoordinator = self + + while let parentCoordinator = appCoordinator.parent { + appCoordinator = parentCoordinator + } + + return (appCoordinator as? ApplicationCoordinator)?.router + } +} diff --git a/ios/MullvadVPN/Operations/AlertPresenter.swift b/ios/MullvadVPN/Operations/AlertPresenter.swift deleted file mode 100644 index 7c31017a4e..0000000000 --- a/ios/MullvadVPN/Operations/AlertPresenter.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// AlertPresenter.swift -// MullvadVPN -// -// Created by pronebird on 04/06/2020. -// Copyright © 2020 Mullvad VPN AB. All rights reserved. -// - -import Operations -import UIKit - -final class AlertPresenter { - private let operationQueue = AsyncOperationQueue.makeSerial() - - func enqueue( - _ alertController: CustomAlertViewController, - presentingController: UIViewController, - presentCompletion: (() -> Void)? = nil - ) { - let operation = PresentAlertOperation( - alertController: alertController, - presentingController: presentingController, - presentCompletion: presentCompletion - ) - - operationQueue.addOperation(operation) - } - - func cancelAll() { - operationQueue.cancelAllOperations() - } -} diff --git a/ios/MullvadVPN/Operations/PresentAlertOperation.swift b/ios/MullvadVPN/Operations/PresentAlertOperation.swift deleted file mode 100644 index 7dff3399f6..0000000000 --- a/ios/MullvadVPN/Operations/PresentAlertOperation.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// PresentAlertOperation.swift -// MullvadVPN -// -// Created by pronebird on 06/09/2021. -// Copyright © 2021 Mullvad VPN AB. All rights reserved. -// - -import Operations -import UIKit - -final class PresentAlertOperation: AsyncOperation { - private let alertController: CustomAlertViewController - private let presentingController: UIViewController - private let presentCompletion: (() -> Void)? - - init( - alertController: CustomAlertViewController, - presentingController: UIViewController, - presentCompletion: (() -> Void)? = nil - ) { - self.alertController = alertController - self.presentingController = presentingController - self.presentCompletion = presentCompletion - - super.init(dispatchQueue: .main) - } - - override func operationDidCancel() { - // Guard against trying to dismiss the alert when operation hasn't started yet. - guard isExecuting else { return } - - // Guard against dismissing controller during transition. - if !alertController.isBeingPresented, !alertController.isBeingDismissed { - dismissAndFinish() - } - } - - override func main() { - alertController.didDismiss = { [weak self] in - self?.finish() - } - - presentingController.present(alertController, animated: true) { - self.presentCompletion?() - - // Alert operation was cancelled during transition? - if self.isCancelled { - self.dismissAndFinish() - } - } - } - - private func dismissAndFinish() { - alertController.dismiss(animated: false) { - self.finish() - } - } -} diff --git a/ios/MullvadVPN/SceneDelegate.swift b/ios/MullvadVPN/SceneDelegate.swift index b8641b6845..bb1adca7a5 100644 --- a/ios/MullvadVPN/SceneDelegate.swift +++ b/ios/MullvadVPN/SceneDelegate.swift @@ -187,28 +187,33 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate, SettingsMigrationUIHand // MARK: - SettingsMigrationUIHandler func showMigrationError(_ error: Error, completionHandler: @escaping () -> Void) { - let alertController = CustomAlertViewController( + guard let appCoordinator else { + completionHandler() + return + } + + let presentation = AlertPresentation( + id: "settings-migration-error-alert", title: NSLocalizedString( "ALERT_TITLE", tableName: "SettingsMigrationUI", value: "Settings migration error", comment: "" ), - message: Self.migrationErrorReason(error) - ) - alertController.addAction( - title: NSLocalizedString("Got it!", tableName: "SettingsMigrationUI", comment: ""), - style: .default, - handler: { - completionHandler() - } + message: Self.migrationErrorReason(error), + buttons: [ + AlertAction( + title: NSLocalizedString("Got it!", tableName: "SettingsMigrationUI", comment: ""), + style: .default, + handler: { + completionHandler() + } + ), + ] ) - if let rootViewController = window?.rootViewController { - rootViewController.present(alertController, animated: true) - } else { - completionHandler() - } + let presenter = AlertPresenter(context: appCoordinator) + presenter.showAlert(presentation: presentation, animated: true) } private static func migrationErrorReason(_ error: Error) -> String { diff --git a/ios/MullvadVPN/View controllers/Account/AccountDeviceRow.swift b/ios/MullvadVPN/View controllers/Account/AccountDeviceRow.swift index be9bbce2b1..ae7ac01991 100644 --- a/ios/MullvadVPN/View controllers/Account/AccountDeviceRow.swift +++ b/ios/MullvadVPN/View controllers/Account/AccountDeviceRow.swift @@ -75,6 +75,10 @@ class AccountDeviceRow: UIView { fatalError("init(coder:) has not been implemented") } + func setButtons(enabled: Bool) { + infoButton.isEnabled = enabled + } + @objc private func didTapInfoButton() { infoButtonAction?() } diff --git a/ios/MullvadVPN/View controllers/Account/AccountNumberRow.swift b/ios/MullvadVPN/View controllers/Account/AccountNumberRow.swift index 2f8ce5f037..07d5199c70 100644 --- a/ios/MullvadVPN/View controllers/Account/AccountNumberRow.swift +++ b/ios/MullvadVPN/View controllers/Account/AccountNumberRow.swift @@ -108,6 +108,11 @@ class AccountNumberRow: UIView { fatalError("init(coder:) has not been implemented") } + func setButtons(enabled: Bool) { + showHideButton.isEnabled = enabled + copyButton.isEnabled = enabled + } + // MARK: - Private private func updateView() { diff --git a/ios/MullvadVPN/View controllers/Account/AccountViewController.swift b/ios/MullvadVPN/View controllers/Account/AccountViewController.swift index 4f74878ced..435123dc4e 100644 --- a/ios/MullvadVPN/View controllers/Account/AccountViewController.swift +++ b/ios/MullvadVPN/View controllers/Account/AccountViewController.swift @@ -190,6 +190,8 @@ class AccountViewController: UIViewController { contentView.purchaseButton.isLoading = productState.isFetching purchaseButton.isEnabled = productState.isReceived && isInteractionEnabled + contentView.accountDeviceRow.setButtons(enabled: isInteractionEnabled) + contentView.accountTokenRowView.setButtons(enabled: isInteractionEnabled) contentView.restorePurchasesButton.isEnabled = isInteractionEnabled contentView.logoutButton.isEnabled = isInteractionEnabled contentView.redeemVoucherButton.isEnabled = isInteractionEnabled diff --git a/ios/MullvadVPN/View controllers/Account/PaymentAlertPresenter.swift b/ios/MullvadVPN/View controllers/Account/PaymentAlertPresenter.swift index 8d120a6a5b..0192f3fdd3 100644 --- a/ios/MullvadVPN/View controllers/Account/PaymentAlertPresenter.swift +++ b/ios/MullvadVPN/View controllers/Account/PaymentAlertPresenter.swift @@ -7,36 +7,33 @@ // import MullvadREST -import UIKit +import Routing -class PaymentAlertPresenter { - private let presentationController: UIViewController - private let alertPresenter: AlertPresenter - - init(presentationController: UIViewController, alertPresenter: AlertPresenter) { - self.presentationController = presentationController - self.alertPresenter = alertPresenter - } +struct PaymentAlertPresenter { + let alertContext: any Presenting func showAlertForError( _ error: StorePaymentManagerError, context: REST.CreateApplePaymentResponse.Context, completion: (() -> Void)? = nil ) { - let alertController = CustomAlertViewController( + let presentation = AlertPresentation( + id: "payment-error-alert", title: context.errorTitle, - message: error.displayErrorDescription - ) - - alertController.addAction( - title: okButtonTextForKey("PAYMENT_ERROR_ALERT_OK_ACTION"), - style: .default, - handler: { - completion?() - } + message: error.displayErrorDescription, + buttons: [ + AlertAction( + title: okButtonTextForKey("PAYMENT_ERROR_ALERT_OK_ACTION"), + style: .default, + handler: { + completion?() + } + ), + ] ) - alertPresenter.enqueue(alertController, presentingController: presentationController) + let presenter = AlertPresenter(context: alertContext) + presenter.showAlert(presentation: presentation, animated: true) } func showAlertForResponse( @@ -49,20 +46,23 @@ class PaymentAlertPresenter { return } - let alertController = CustomAlertViewController( + let presentation = AlertPresentation( + id: "payment-response-alert", title: response.alertTitle(context: context), - message: response.alertMessage(context: context) - ) - - alertController.addAction( - title: okButtonTextForKey("PAYMENT_RESPONSE_ALERT_OK_ACTION"), - style: .default, - handler: { - completion?() - } + message: response.alertMessage(context: context), + buttons: [ + AlertAction( + title: okButtonTextForKey("PAYMENT_RESPONSE_ALERT_OK_ACTION"), + style: .default, + handler: { + completion?() + } + ), + ] ) - alertPresenter.enqueue(alertController, presentingController: presentationController) + let presenter = AlertPresenter(context: alertContext) + presenter.showAlert(presentation: presentation, animated: true) } private func okButtonTextForKey(_ key: String) -> String { diff --git a/ios/MullvadVPN/View controllers/Alert/AlertPresentation.swift b/ios/MullvadVPN/View controllers/Alert/AlertPresentation.swift new file mode 100644 index 0000000000..9fcaac8429 --- /dev/null +++ b/ios/MullvadVPN/View controllers/Alert/AlertPresentation.swift @@ -0,0 +1,45 @@ +// +// AlertPresentation.swift +// MullvadVPN +// +// Created by Jon Petersson on 2023-08-23. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import Routing + +struct AlertMetadata { + let presentation: AlertPresentation + let context: Presenting +} + +struct AlertAction { + let title: String + let style: AlertActionStyle + var handler: (() -> Void)? +} + +struct AlertPresentation: Identifiable, CustomDebugStringConvertible { + let id: String + + var header: String? + var icon: AlertIcon? + var title: String? + let message: String? + let buttons: [AlertAction] + + var debugDescription: String { + return id + } +} + +extension AlertPresentation: Equatable, Hashable { + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func == (lhs: AlertPresentation, rhs: AlertPresentation) -> Bool { + return lhs.id == rhs.id + } +} diff --git a/ios/MullvadVPN/View controllers/Alert/AlertPresenter.swift b/ios/MullvadVPN/View controllers/Alert/AlertPresenter.swift new file mode 100644 index 0000000000..db2c0ff971 --- /dev/null +++ b/ios/MullvadVPN/View controllers/Alert/AlertPresenter.swift @@ -0,0 +1,31 @@ +// +// AlertPresenter.swift +// MullvadVPN +// +// Created by pronebird on 04/06/2020. +// Copyright © 2020 Mullvad VPN AB. All rights reserved. +// + +import Routing + +struct AlertPresenter { + let context: any Presenting + + func showAlert(presentation: AlertPresentation, animated: Bool) { + context.applicationRouter?.presentAlert( + route: .alert(presentation.id), + animated: animated, + metadata: AlertMetadata(presentation: presentation, context: context) + ) + } + + func dismissAlert(presentation: AlertPresentation, animated: Bool) { + context.applicationRouter?.dismiss(.alert(presentation.id), animated: animated) + } +} + +extension ApplicationRouter { + func presentAlert(route: RouteType, animated: Bool, metadata: AlertMetadata) { + present(route, animated: animated, metadata: metadata) + } +} diff --git a/ios/MullvadVPN/Classes/CustomAlertViewController.swift b/ios/MullvadVPN/View controllers/Alert/AlertViewController.swift index cb6b92d1df..0a3fced671 100644 --- a/ios/MullvadVPN/Classes/CustomAlertViewController.swift +++ b/ios/MullvadVPN/View controllers/Alert/AlertViewController.swift @@ -1,5 +1,5 @@ // -// CustomAlertController.swift +// AlertViewController.swift // MullvadVPN // // Created by Jon Petersson on 2023-05-19. @@ -8,41 +8,40 @@ import UIKit -class CustomAlertViewController: UIViewController { - typealias Handler = () -> Void - - enum Icon { - case alert - case info - case spinner +enum AlertActionStyle { + case `default` + case destructive - fileprivate var image: UIImage? { - switch self { - case .alert: - return UIImage(named: "IconAlert")?.withTintColor(.dangerColor) - case .info: - return UIImage(named: "IconInfo")?.withTintColor(.white) - default: - return nil - } + fileprivate var buttonStyle: AppButton.Style { + switch self { + case .default: + return .default + case .destructive: + return .danger } } +} - enum ActionStyle { - case `default` - case destructive +enum AlertIcon { + case alert + case info + case spinner - fileprivate var buttonStyle: AppButton.Style { - switch self { - case .default: - return .default - case .destructive: - return .danger - } + fileprivate var image: UIImage? { + switch self { + case .alert: + return UIImage(named: "IconAlert")?.withTintColor(.dangerColor) + case .info: + return UIImage(named: "IconInfo")?.withTintColor(.white) + default: + return nil } } +} - var didDismiss: (() -> Void)? +class AlertViewController: UIViewController { + typealias Handler = () -> Void + var onDismiss: Handler? private let containerView: UIStackView = { let view = UIStackView() @@ -59,7 +58,7 @@ class CustomAlertViewController: UIViewController { private var handlers = [UIButton: Handler]() - init(header: String? = nil, title: String? = nil, message: String? = nil, icon: Icon? = nil) { + init(header: String? = nil, title: String? = nil, message: String? = nil, icon: AlertIcon? = nil) { super.init(nibName: nil, bundle: nil) setUp(header: header, title: title, icon: icon) { @@ -67,7 +66,7 @@ class CustomAlertViewController: UIViewController { } } - init(header: String? = nil, title: String? = nil, attributedMessage: NSAttributedString?, icon: Icon? = nil) { + init(header: String? = nil, title: String? = nil, attributedMessage: NSAttributedString?, icon: AlertIcon? = nil) { super.init(nibName: nil, bundle: nil) setUp(header: header, title: title, icon: icon) { @@ -80,7 +79,7 @@ class CustomAlertViewController: UIViewController { } // This code runs before viewDidLoad(). As such, no implicit calls to self.view should be made before this point. - private func setUp(header: String?, title: String?, icon: Icon?, addMessageCallback: () -> Void) { + private func setUp(header: String?, title: String?, icon: AlertIcon?, addMessageCallback: () -> Void) { modalPresentationStyle = .overFullScreen modalTransitionStyle = .crossDissolve @@ -121,7 +120,7 @@ class CustomAlertViewController: UIViewController { } } - func addAction(title: String, style: ActionStyle, handler: (() -> Void)? = nil) { + func addAction(title: String, style: AlertActionStyle, handler: (() -> Void)? = nil) { // The presence of a button should reset any custom button margin to default. containerView.directionalLayoutMargins.bottom = UIMetrics.CustomAlert.containerMargins.bottom @@ -191,12 +190,12 @@ class CustomAlertViewController: UIViewController { containerView.addArrangedSubview(label) } - private func addIcon(_ icon: Icon) { + private func addIcon(_ icon: AlertIcon) { let iconView = icon == .spinner ? getSpinnerView() : getImageView(for: icon) containerView.addArrangedSubview(iconView) } - private func getImageView(for icon: Icon) -> UIView { + private func getImageView(for icon: AlertIcon) -> UIView { let imageView = UIImageView() let imageContainerView = UIView() @@ -228,13 +227,11 @@ class CustomAlertViewController: UIViewController { } @objc private func didTapButton(_ button: AppButton) { - dismiss(animated: true) { [self] in - if let handler = handlers.removeValue(forKey: button) { - handler() - } - didDismiss?() - didDismiss = nil - handlers.removeAll() + if let handler = handlers.removeValue(forKey: button) { + handler() } + + handlers.removeAll() + onDismiss?() } } diff --git a/ios/MullvadVPN/View controllers/DeviceList/DeviceManagementViewController.swift b/ios/MullvadVPN/View controllers/DeviceList/DeviceManagementViewController.swift index fa8e1f0945..7675490333 100644 --- a/ios/MullvadVPN/View controllers/DeviceList/DeviceManagementViewController.swift +++ b/ios/MullvadVPN/View controllers/DeviceList/DeviceManagementViewController.swift @@ -32,8 +32,6 @@ class DeviceManagementViewController: UIViewController, RootContainment { .lightContent } - private let alertPresenter = AlertPresenter() - private let contentView: DeviceManagementContentView = { let contentView = DeviceManagementContentView() contentView.translatesAutoresizingMaskIntoConstraints = false @@ -42,9 +40,11 @@ class DeviceManagementViewController: UIViewController, RootContainment { private let logger = Logger(label: "DeviceManagementViewController") private let interactor: DeviceManagementInteractor + private let alertPresenter: AlertPresenter - init(interactor: DeviceManagementInteractor) { + init(interactor: DeviceManagementInteractor, alertPresenter: AlertPresenter) { self.interactor = interactor + self.alertPresenter = alertPresenter super.init(nibName: nil, bundle: nil) } @@ -89,7 +89,7 @@ class DeviceManagementViewController: UIViewController, RootContainment { completionHandler: ((Result<Void, Error>) -> Void)? = nil ) { interactor.getDevices { [weak self] result in - guard let self else { return } + guard let self = self else { return } if let devices = result.value { setDevices(devices, animated: animateUpdates) @@ -130,7 +130,9 @@ class DeviceManagementViewController: UIViewController, RootContainment { return } - deleteDevice(identifier: device.id) { error in + deleteDevice(identifier: device.id) { [weak self] error in + guard let self = self else { return } + if let error { self.showErrorAlert( title: NSLocalizedString( @@ -157,74 +159,75 @@ class DeviceManagementViewController: UIViewController, RootContainment { } private func showErrorAlert(title: String, error: Error) { - let alertController = CustomAlertViewController( + let presentation = AlertPresentation( + id: "delete-device-error-alert", title: title, - message: getErrorDescription(error) - ) - - alertController.addAction( - title: NSLocalizedString( - "ERROR_ALERT_OK_ACTION", - tableName: "DeviceManagement", - value: "Got it!", - comment: "" - ), - style: .default + message: getErrorDescription(error), + buttons: [ + AlertAction( + title: NSLocalizedString( + "ERROR_ALERT_OK_ACTION", + tableName: "DeviceManagement", + value: "Got it!", + comment: "" + ), + style: .default + ), + ] ) - alertPresenter.enqueue(alertController, presentingController: self) + alertPresenter.showAlert(presentation: presentation, animated: true) } private func showLogoutConfirmation( deviceName: String, completion: @escaping (_ shouldDelete: Bool) -> Void ) { - let message = String( - format: NSLocalizedString( - "DELETE_ALERT_TITLE", - tableName: "DeviceManagement", - value: "Are you sure you want to log %@ out?", - comment: "" - ), deviceName - ) - - let alertController = CustomAlertViewController( - message: message, - icon: .alert - ) - - alertController.addAction( - title: NSLocalizedString( - "DELETE_ALERT_CANCEL_ACTION", - tableName: "DeviceManagement", - value: "Back", - comment: "" - ), - style: .default, - handler: { - completion(false) - } - ) - - alertController.addAction( - title: NSLocalizedString( - "DELETE_ALERT_CONFIRM_ACTION", - tableName: "DeviceManagement", - value: "Yes, log out device", - comment: "" + let presentation = AlertPresentation( + id: "logout-confirmation-alert", + icon: .alert, + message: String( + format: NSLocalizedString( + "DELETE_ALERT_TITLE", + tableName: "DeviceManagement", + value: "Are you sure you want to log %@ out?", + comment: "" + ), deviceName ), - style: .destructive, - handler: { - completion(true) - } + buttons: [ + AlertAction( + title: NSLocalizedString( + "DELETE_ALERT_CANCEL_ACTION", + tableName: "DeviceManagement", + value: "Back", + comment: "" + ), + style: .default, + handler: { + completion(false) + } + ), + AlertAction( + title: NSLocalizedString( + "DELETE_ALERT_CONFIRM_ACTION", + tableName: "DeviceManagement", + value: "Yes, log out device", + comment: "" + ), + style: .destructive, + handler: { + completion(true) + } + ), + ] ) - alertPresenter.enqueue(alertController, presentingController: self) + alertPresenter.showAlert(presentation: presentation, animated: true) } private func deleteDevice(identifier: String, completionHandler: @escaping (Error?) -> Void) { interactor.deleteDevice(identifier) { [weak self] completion in - guard let self else { return } + guard let self = self else { return } switch completion { case .success: diff --git a/ios/MullvadVPN/View controllers/Preferences/PreferencesViewController.swift b/ios/MullvadVPN/View controllers/Preferences/PreferencesViewController.swift index ef05999fe4..2a267142ae 100644 --- a/ios/MullvadVPN/View controllers/Preferences/PreferencesViewController.swift +++ b/ios/MullvadVPN/View controllers/Preferences/PreferencesViewController.swift @@ -11,14 +11,16 @@ import UIKit class PreferencesViewController: UITableViewController, PreferencesDataSourceDelegate { private let interactor: PreferencesInteractor private var dataSource: PreferencesDataSource? - private let alertPresenter = AlertPresenter() + private let alertPresenter: AlertPresenter override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent } - init(interactor: PreferencesInteractor) { + init(interactor: PreferencesInteractor, alertPresenter: AlertPresenter) { self.interactor = interactor + self.alertPresenter = alertPresenter + super.init(style: .grouped) } @@ -76,22 +78,24 @@ class PreferencesViewController: UITableViewController, PreferencesDataSourceDel } private func showContentBlockerInfo(with message: String) { - let alertController = CustomAlertViewController( + let presentation = AlertPresentation( + id: "preferences-content-blockers-alert", + icon: .info, message: message, - icon: .info - ) - - alertController.addAction( - title: NSLocalizedString( - "PREFERENCES_CONTENT_BLOCKERS_OK_ACTION", - tableName: "ContentBlockers", - value: "Got it!", - comment: "" - ), - style: .default + buttons: [ + AlertAction( + title: NSLocalizedString( + "PREFERENCES_CONTENT_BLOCKERS_OK_ACTION", + tableName: "ContentBlockers", + value: "Got it!", + comment: "" + ), + style: .default + ), + ] ) - alertPresenter.enqueue(alertController, presentingController: self) + alertPresenter.showAlert(presentation: presentation, animated: true) } private func humanReadablePortRepresentation(_ ranges: [[UInt16]]) -> String { @@ -150,8 +154,7 @@ class PreferencesViewController: UITableViewController, PreferencesDataSourceDel case .wireGuardPorts: let portsString = humanReadablePortRepresentation( - interactor.cachedRelays?.relays.wireguard - .portRanges ?? [] + interactor.cachedRelays?.relays.wireguard.portRanges ?? [] ) message = String( diff --git a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController.swift b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController.swift index 19751e8ed7..1bbd63f101 100644 --- a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController.swift +++ b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController.swift @@ -13,6 +13,7 @@ import UIKit final class ProblemReportViewController: UIViewController, UITextFieldDelegate { private let interactor: ProblemReportInteractor + private let alertPresenter: AlertPresenter private var textViewKeyboardResponder: AutomaticKeyboardResponder? private var scrollViewKeyboardResponder: AutomaticKeyboardResponder? @@ -44,8 +45,9 @@ final class ProblemReportViewController: UIViewController, UITextFieldDelegate { "SUBHEAD_LABEL", tableName: "ProblemReport", value: """ - To help you more effectively, your app’s log file will be attached to this message. \ - Your data will remain secure and private, as it is anonymised before being sent over an encrypted channel. + To help you more effectively, your app’s log file will be attached to \ + this message. Your data will remain secure and private, as it is anonymised \ + before being sent over an encrypted channel. """, comment: "" ) @@ -86,8 +88,8 @@ final class ProblemReportViewController: UIViewController, UITextFieldDelegate { "DESCRIPTION_TEXTVIEW_PLACEHOLDER", tableName: "ProblemReport", value: """ - To assist you better, please write in English or Swedish and include \ - which country you are connecting from. + To assist you better, please write in English or Swedish and \ + include which country you are connecting from. """, comment: "" ) @@ -193,8 +195,9 @@ final class ProblemReportViewController: UIViewController, UITextFieldDelegate { false } - init(interactor: ProblemReportInteractor) { + init(interactor: ProblemReportInteractor, alertPresenter: AlertPresenter) { self.interactor = interactor + self.alertPresenter = alertPresenter super.init(nibName: nil, bundle: nil) } @@ -463,48 +466,47 @@ final class ProblemReportViewController: UIViewController, UITextFieldDelegate { } private func presentEmptyEmailConfirmationAlert(completion: @escaping (Bool) -> Void) { - let message = NSLocalizedString( - "EMPTY_EMAIL_ALERT_MESSAGE", - tableName: "ProblemReport", - value: """ - You are about to send the problem report without a way for us to get back to you. \ - If you want an answer to your report you will have to enter an email address. - """, - comment: "" - ) - - let alertController = CustomAlertViewController( - message: message, - icon: .alert - ) - - alertController.addAction( - title: NSLocalizedString( - "EMPTY_EMAIL_ALERT_SEND_ANYWAY_ACTION", + let presentation = AlertPresentation( + id: "problem-report-alert", + icon: .alert, + message: NSLocalizedString( + "EMPTY_EMAIL_ALERT_MESSAGE", tableName: "ProblemReport", - value: "Send anyway", + value: """ + You are about to send the problem report without a way for us to get back to you. \ + If you want an answer to your report you will have to enter an email address. + """, comment: "" ), - style: .destructive, - handler: { - completion(true) - } - ) - - alertController.addAction( - title: NSLocalizedString( - "EMPTY_EMAIL_ALERT_CANCEL_ACTION", - tableName: "ProblemReport", - value: "Cancel", - comment: "" - ), - style: .default, - handler: { - completion(false) - } + buttons: [ + AlertAction( + title: NSLocalizedString( + "EMPTY_EMAIL_ALERT_SEND_ANYWAY_ACTION", + tableName: "ProblemReport", + value: "Send anyway", + comment: "" + ), + style: .destructive, + handler: { + completion(true) + } + ), + AlertAction( + title: NSLocalizedString( + "EMPTY_EMAIL_ALERT_CANCEL_ACTION", + tableName: "ProblemReport", + value: "Cancel", + comment: "" + ), + style: .default, + handler: { + completion(false) + } + ), + ] ) - present(alertController, animated: true) + alertPresenter.showAlert(presentation: presentation, animated: true) } // MARK: - Private: Problem report submission diff --git a/ios/Operations/AsyncOperation.swift b/ios/Operations/AsyncOperation.swift index 638d6ff415..034f17f199 100644 --- a/ios/Operations/AsyncOperation.swift +++ b/ios/Operations/AsyncOperation.swift @@ -71,7 +71,6 @@ open class AsyncOperation: Operation { get { stateLock.lock() defer { stateLock.unlock() } - return _state } set(newState) { @@ -88,7 +87,6 @@ open class AsyncOperation: Operation { get { stateLock.lock() defer { stateLock.unlock() } - return __isCancelled } set { @@ -182,7 +180,6 @@ open class AsyncOperation: Operation { public final var conditions: [OperationCondition] { operationLock.lock() defer { operationLock.unlock() } - return _conditions } diff --git a/ios/Routing/Coordinator.swift b/ios/Routing/Coordinator.swift index 217a951aef..46e343e648 100644 --- a/ios/Routing/Coordinator.swift +++ b/ios/Routing/Coordinator.swift @@ -84,13 +84,23 @@ open class Coordinator: NSObject { */ public protocol Presentable: Coordinator { /** - View controller that is presented modally. It's expected it to be the top-most view controller + View controller that is presented modally. It's expected it to be the topmost view controller managed by coordinator. */ var presentedViewController: UIViewController { get } } /** + Protocol describing `Presentable` coordinators that can be popped from a navigation stack. + */ +public protocol Poppable: Presentable { + func popFromNavigationStack( + animated: Bool, + completion: (() -> Void)? + ) +} + +/** Protocol describing coordinators that provide modal presentation context. */ public protocol Presenting: Coordinator { @@ -100,6 +110,15 @@ public protocol Presenting: Coordinator { var presentationContext: UIViewController { get } } +extension Presenting where Self: Presentable { + /** + View controller providing modal presentation context. + */ + public var presentationContext: UIViewController { + return presentedViewController + } +} + extension Presenting { /** Present child coordinator. @@ -134,12 +153,22 @@ extension Presenting { addChild(child) - presentationContext.present( + topmostPresentationContext(from: presentationContext).present( child.presentedViewController, animated: animated, completion: completion ) } + + private func topmostPresentationContext(from: UIViewController) -> UIViewController { + var context = presentationContext + + while let childContext = context.presentedViewController, context != childContext { + context = childContext + } + + return context + } } extension Presentable { diff --git a/ios/Routing/Router/AppRouteProtocol.swift b/ios/Routing/Router/AppRouteProtocol.swift index 1843183958..85fbd53028 100644 --- a/ios/Routing/Router/AppRouteProtocol.swift +++ b/ios/Routing/Router/AppRouteProtocol.swift @@ -35,6 +35,10 @@ extension AppRouteGroupProtocol { public static func < (lhs: Self, rhs: Self) -> Bool { lhs.modalLevel < rhs.modalLevel } + + public static func <= (lhs: Self, rhs: Self) -> Bool { + lhs.modalLevel <= rhs.modalLevel + } } /** diff --git a/ios/Routing/Router/ApplicationRouter.swift b/ios/Routing/Router/ApplicationRouter.swift index 2de0cbc3ff..d565875143 100644 --- a/ios/Routing/Router/ApplicationRouter.swift +++ b/ios/Routing/Router/ApplicationRouter.swift @@ -53,10 +53,11 @@ public final class ApplicationRouter<RouteType: AppRouteProtocol> { /** Enqueue route for presetnation. */ - public func present(_ route: RouteType, animated: Bool = true) { + public func present(_ route: RouteType, animated: Bool = true, metadata: Any? = nil) { enqueue(PendingRoute( operation: .present(route), - animated: animated + animated: animated, + metadata: metadata )) } @@ -81,7 +82,7 @@ public final class ApplicationRouter<RouteType: AppRouteProtocol> { } private func enqueue(_ pendingRoute: PendingRoute<RouteType>) { - logger.debug("Enqueue \(pendingRoute.operation).") + logger.debug("\(pendingRoute.operation).") pendingRoutes.append(pendingRoute) @@ -93,6 +94,7 @@ public final class ApplicationRouter<RouteType: AppRouteProtocol> { private func presentRoute( _ route: RouteType, animated: Bool, + metadata: Any?, completion: @escaping (PendingPresentationResult) -> Void ) { /** @@ -117,7 +119,7 @@ public final class ApplicationRouter<RouteType: AppRouteProtocol> { } /** - Drop duplicate routes. + Drop duplicate exclusive routes. */ if route.isExclusive, modalStack.contains(route.routeGroup) { completion(.drop) @@ -135,8 +137,15 @@ public final class ApplicationRouter<RouteType: AppRouteProtocol> { /** Check if route can be presented above the last route in the modal stack. */ - if let lastRouteGroup = modalStack.last, route.routeGroup.isModal, - (lastRouteGroup > route.routeGroup) || (route.isExclusive && lastRouteGroup == route.routeGroup) { + if let + // Get current modal route. + lastRouteGroup = modalStack.last, + // Check if incoming route is modal. + route.routeGroup.isModal, + // Check whether incoming route can be presented on top of current. + (lastRouteGroup > route.routeGroup) || + // OR, check whether incoming exclusive route can be presented on top of current. + (lastRouteGroup >= route.routeGroup && route.isExclusive) { completion(.blockedByModalContext) return } @@ -145,7 +154,9 @@ public final class ApplicationRouter<RouteType: AppRouteProtocol> { Consult with delegate whether the route should still be presented. */ if delegate.applicationRouter(self, shouldPresent: route) { - delegate.applicationRouter(self, route: route, animated: animated) { coordinator in + let context = RoutePresentationContext(route: route, isAnimated: animated, metadata: metadata) + + delegate.applicationRouter(self, presentWithContext: context, animated: animated) { coordinator in /* Synchronize router when modal controllers are removed by swipe. */ @@ -276,7 +287,7 @@ public final class ApplicationRouter<RouteType: AppRouteProtocol> { switch pendingRoute.operation { case let .present(route): - presentRoute(route, animated: pendingRoute.animated) { result in + presentRoute(route, animated: pendingRoute.animated, metadata: pendingRoute.metadata) { result in switch result { case .success, .drop: self.finishPendingRoute(pendingRoute) diff --git a/ios/Routing/Router/ApplicationRouterDelegate.swift b/ios/Routing/Router/ApplicationRouterDelegate.swift index ecccb15415..a98870d303 100644 --- a/ios/Routing/Router/ApplicationRouterDelegate.swift +++ b/ios/Routing/Router/ApplicationRouterDelegate.swift @@ -19,7 +19,7 @@ public protocol ApplicationRouterDelegate<RouteType>: AnyObject { */ func applicationRouter( _ router: ApplicationRouter<RouteType>, - route: RouteType, + presentWithContext context: RoutePresentationContext<RouteType>, animated: Bool, completion: @escaping (Coordinator) -> Void ) diff --git a/ios/Routing/Router/ApplicationRouterTypes.swift b/ios/Routing/Router/ApplicationRouterTypes.swift index 1dfd128c0a..7be142adaf 100644 --- a/ios/Routing/Router/ApplicationRouterTypes.swift +++ b/ios/Routing/Router/ApplicationRouterTypes.swift @@ -11,9 +11,16 @@ import Foundation /** Struct describing a routing request for presentation or dismissal. */ -struct PendingRoute<RouteType: AppRouteProtocol>: Equatable { +struct PendingRoute<RouteType: AppRouteProtocol> { var operation: RouteOperation<RouteType> var animated: Bool + var metadata: Any? +} + +extension PendingRoute: Equatable { + static func == (lhs: PendingRoute<RouteType>, rhs: PendingRoute<RouteType>) -> Bool { + lhs.operation == rhs.operation + } } /** @@ -69,7 +76,7 @@ enum PendingDismissalResult { /** Enum describing operation over the route. */ -enum RouteOperation<RouteType: AppRouteProtocol>: Equatable { +enum RouteOperation<RouteType: AppRouteProtocol>: Equatable, CustomDebugStringConvertible { /** Present route. */ @@ -91,12 +98,23 @@ enum RouteOperation<RouteType: AppRouteProtocol>: Equatable { return dismissMatch.routeGroup } } + + var debugDescription: String { + let action: String + switch self { + case let .present(routeType): + action = "Presenting .\(routeType)" + case let .dismiss(match): + action = "Dismissing .\(match)" + } + return "\(action)" + } } /** Enum type describing a single route or a group of routes requested to be dismissed. */ -enum DismissMatch<RouteType: AppRouteProtocol>: Equatable { +enum DismissMatch<RouteType: AppRouteProtocol>: Equatable, CustomDebugStringConvertible { case group(RouteType.RouteGroupType) case singleRoute(RouteType) @@ -111,6 +129,15 @@ enum DismissMatch<RouteType: AppRouteProtocol>: Equatable { return route.routeGroup } } + + var debugDescription: String { + switch self { + case let .group(group): + return "\(group)" + case let .singleRoute(route): + return "\(route)" + } + } } /** @@ -142,6 +169,26 @@ public struct RouteDismissalContext<RouteType: AppRouteProtocol> { } /** + Struct holding information used by delegate to perform presentation of a specific route. + */ +public struct RoutePresentationContext<RouteType: AppRouteProtocol> { + /** + Route that's being presented. + */ + public var route: RouteType + + /** + Whether transition is animated. + */ + public var isAnimated: Bool + + /** + Metadata associated with the route. + */ + public var metadata: Any? +} + +/** Struct holding information used by delegate to perform sub-navigation of the route in subject. */ public struct RouteSubnavigationContext<RouteType: AppRouteProtocol> { diff --git a/ios/RoutingTests/RouterBlockDelegate.swift b/ios/RoutingTests/RouterBlockDelegate.swift index b1c1f58908..977454b7e4 100644 --- a/ios/RoutingTests/RouterBlockDelegate.swift +++ b/ios/RoutingTests/RouterBlockDelegate.swift @@ -10,7 +10,7 @@ import Foundation import Routing class RouterBlockDelegate<RouteType: AppRouteProtocol>: ApplicationRouterDelegate { - var handleRoute: ((RouteType, Bool, (Coordinator) -> Void) -> Void)? + var handleRoute: ((RoutePresentationContext<RouteType>, Bool, (Coordinator) -> Void) -> Void)? var handleDismiss: ((RouteDismissalContext<RouteType>, () -> Void) -> Void)? var shouldPresent: ((RouteType) -> Bool)? var shouldDismiss: ((RouteDismissalContext<RouteType>) -> Bool)? @@ -18,11 +18,11 @@ class RouterBlockDelegate<RouteType: AppRouteProtocol>: ApplicationRouterDelegat func applicationRouter( _ router: ApplicationRouter<RouteType>, - route: RouteType, + presentWithContext context: RoutePresentationContext<RouteType>, animated: Bool, completion: @escaping (Coordinator) -> Void ) { - handleRoute?(route, animated, completion) ?? completion(Coordinator()) + handleRoute?(context, animated, completion) ?? completion(Coordinator()) } func applicationRouter( |
