diff --git a/Config/BuildSettings.swift b/Config/BuildSettings.swift index 3348ba964..59db9a270 100644 --- a/Config/BuildSettings.swift +++ b/Config/BuildSettings.swift @@ -361,6 +361,9 @@ final class BuildSettings: NSObject { // MARK: - Authentication Options static let authEnableRefreshTokens = false + // MARK: - Onboarding + static let onboardingShowAccountPersonalisation = false + // MARK: - Unified Search static let unifiedSearchScreenShowPublicDirectory = true diff --git a/Riot/Assets/Images.xcassets/Onboarding/onboarding_congratulations_icon.imageset/Contents.json b/Riot/Assets/Images.xcassets/Onboarding/onboarding_congratulations_icon.imageset/Contents.json new file mode 100644 index 000000000..abd9b2f32 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Onboarding/onboarding_congratulations_icon.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "user.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Riot/Assets/Images.xcassets/Onboarding/onboarding_congratulations_icon.imageset/user.svg b/Riot/Assets/Images.xcassets/Onboarding/onboarding_congratulations_icon.imageset/user.svg new file mode 100644 index 000000000..0321e191f --- /dev/null +++ b/Riot/Assets/Images.xcassets/Onboarding/onboarding_congratulations_icon.imageset/user.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index 63a0afa4d..9d3821746 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -104,6 +104,11 @@ "onboarding_use_case_existing_server_message" = "Looking to join an existing server?"; "onboarding_use_case_existing_server_button" = "Connect to server"; +"onboarding_congratulations_title" = "Congratulations!"; +"onboarding_congratulations_message" = "Your account\n%@\nhas been created."; +"onboarding_congratulations_personalise_button" = "Personalise profile"; +"onboarding_congratulations_home_button" = "Take me home"; + // Authentication "auth_login" = "Log in"; "auth_register" = "Register"; diff --git a/Riot/Generated/Images.swift b/Riot/Generated/Images.swift index ee18b509e..65511e31c 100644 --- a/Riot/Generated/Images.swift +++ b/Riot/Generated/Images.swift @@ -123,6 +123,7 @@ internal class Asset: NSObject { internal static let onboardingSplashScreenPage3Dark = ImageAsset(name: "OnboardingSplashScreenPage3Dark") internal static let onboardingSplashScreenPage4 = ImageAsset(name: "OnboardingSplashScreenPage4") internal static let onboardingSplashScreenPage4Dark = ImageAsset(name: "OnboardingSplashScreenPage4Dark") + internal static let onboardingCongratulationsIcon = ImageAsset(name: "onboarding_congratulations_icon") internal static let onboardingUseCaseCommunity = ImageAsset(name: "onboarding_use_case_community") internal static let onboardingUseCaseCommunityDark = ImageAsset(name: "onboarding_use_case_community_dark") internal static let onboardingUseCaseIcon = ImageAsset(name: "onboarding_use_case_icon") diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index 84b6970a6..98b157ed8 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -2407,6 +2407,22 @@ public class VectorL10n: NSObject { public static var on: String { return VectorL10n.tr("Vector", "on") } + /// Take me home + public static var onboardingCongratulationsHomeButton: String { + return VectorL10n.tr("Vector", "onboarding_congratulations_home_button") + } + /// Your account\n%@\nhas been created. + public static func onboardingCongratulationsMessage(_ p1: String) -> String { + return VectorL10n.tr("Vector", "onboarding_congratulations_message", p1) + } + /// Personalise profile + public static var onboardingCongratulationsPersonaliseButton: String { + return VectorL10n.tr("Vector", "onboarding_congratulations_personalise_button") + } + /// Congratulations! + public static var onboardingCongratulationsTitle: String { + return VectorL10n.tr("Vector", "onboarding_congratulations_title") + } /// I already have an account public static var onboardingSplashLoginButtonTitle: String { return VectorL10n.tr("Vector", "onboarding_splash_login_button_title") diff --git a/Riot/Modules/Authentication/AuthenticationCoordinator.swift b/Riot/Modules/Authentication/AuthenticationCoordinator.swift index 865161941..a98f95cc1 100644 --- a/Riot/Modules/Authentication/AuthenticationCoordinator.swift +++ b/Riot/Modules/Authentication/AuthenticationCoordinator.swift @@ -28,6 +28,8 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc private let navigationRouter: NavigationRouterType private let authenticationViewController: AuthenticationViewController + private var canPresentAdditionalScreens: Bool + private var isWaitingToPresentCompleteSecurity = false private let crossSigningService = CrossSigningService() /// The password entered, for use when setting up cross-signing. @@ -45,6 +47,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc init(parameters: AuthenticationCoordinatorParameters) { self.navigationRouter = parameters.navigationRouter + self.canPresentAdditionalScreens = parameters.canPresentAdditionalScreens let authenticationViewController = AuthenticationViewController() self.authenticationViewController = authenticationViewController @@ -91,6 +94,17 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc authenticationViewController.continueSSOLogin(withToken: loginToken, txnId: transactionID) } + func allowScreenPresentation() { + canPresentAdditionalScreens = true + + showLoadingAnimation() + + if isWaitingToPresentCompleteSecurity { + isWaitingToPresentCompleteSecurity = false + presentCompleteSecurity() + } + } + // MARK: - Private private func showLoadingAnimation() { @@ -102,7 +116,13 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc navigationRouter.setRootModule(loadingViewController) } - private func presentCompleteSecurity(with session: MXSession) { + private func presentCompleteSecurity() { + guard let session = session else { + MXLog.error("[AuthenticationCoordinator] presentCompleteSecurity: Unable to present security due to missing session.") + authenticationDidComplete() + return + } + let isNewSignIn = true let keyVerificationCoordinator = KeyVerificationCoordinator(session: session, flow: .completeSecurity(isNewSignIn)) @@ -115,7 +135,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc } private func authenticationDidComplete() { - completion?(.didComplete(authenticationViewController.authType)) + completion?(.didComplete) } private func registerSessionStateChangeNotification(for session: MXSession) { @@ -183,8 +203,14 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc self.authenticationDidComplete() } case .crossSigningExists: + guard self.canPresentAdditionalScreens else { + MXLog.debug("[AuthenticationCoordinator] sessionStateDidChange: Delaying presentCompleteSecurity during onboarding.") + self.isWaitingToPresentCompleteSecurity = true + return + } + MXLog.debug("[AuthenticationCoordinator] sessionStateDidChange: Complete security") - self.presentCompleteSecurity(with: session) + self.presentCompleteSecurity() default: MXLog.debug("[AuthenticationCoordinator] sessionStateDidChange: Nothing to do") @@ -211,8 +237,10 @@ extension AuthenticationCoordinator: AuthenticationViewControllerDelegate { self.session = session self.password = password - self.showLoadingAnimation() - completion?(.didLogin(session)) + if canPresentAdditionalScreens { + showLoadingAnimation() + } + completion?(.didLogin(session: session, authenticationType: authenticationViewController.authType)) } } diff --git a/Riot/Modules/Authentication/AuthenticationCoordinatorProtocol.swift b/Riot/Modules/Authentication/AuthenticationCoordinatorProtocol.swift index c024cddbe..95d97146d 100644 --- a/Riot/Modules/Authentication/AuthenticationCoordinatorProtocol.swift +++ b/Riot/Modules/Authentication/AuthenticationCoordinatorProtocol.swift @@ -20,14 +20,16 @@ import Foundation struct AuthenticationCoordinatorParameters { let navigationRouter: NavigationRouterType + /// Whether or not the coordinator should show the loading spinner, key verification etc. + let canPresentAdditionalScreens: Bool } enum AuthenticationCoordinatorResult { /// The user has authenticated but key verification is yet to happen. The session value is /// for a fresh session that still needs to load, sync etc before being ready. - case didLogin(MXSession) + case didLogin(session: MXSession, authenticationType: MXKAuthenticationType) /// All of the required authentication steps including key verification is complete. - case didComplete(MXKAuthenticationType) + case didComplete } /// `AuthenticationCoordinatorProtocol` is a protocol describing a Coordinator that handle's the authentication navigation flow. @@ -52,4 +54,8 @@ protocol AuthenticationCoordinatorProtocol: Coordinator, Presentable { /// When SSO login succeeded, when SFSafariViewController is used, continue login with success parameters. func continueSSOLogin(withToken loginToken: String, transactionID: String) -> Bool + + /// Indicates to the coordinator to display any pending screens if it was created with + /// the `canPresentAdditionalScreens` parameter set to `false` + func allowScreenPresentation() } diff --git a/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsView.h b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsView.h index c71b022ae..5582aa95a 100644 --- a/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsView.h +++ b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsView.h @@ -25,7 +25,7 @@ extern NSString *const MXKAuthErrorDomain; /** Authentication types */ -typedef enum { +typedef NS_ENUM(NSUInteger, MXKAuthenticationType) { /** Type used to sign up. */ @@ -39,7 +39,7 @@ typedef enum { */ MXKAuthenticationTypeForgotPassword -} MXKAuthenticationType; +}; @class MXKAuthInputsView; diff --git a/Riot/Modules/Onboarding/OnboardingCoordinator.swift b/Riot/Modules/Onboarding/OnboardingCoordinator.swift index dd7f088e7..fcd3c6ead 100644 --- a/Riot/Modules/Onboarding/OnboardingCoordinator.swift +++ b/Riot/Modules/Onboarding/OnboardingCoordinator.swift @@ -61,6 +61,13 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { // MARK: Screen results private var splashScreenResult: OnboardingSplashScreenViewModelResult? private var useCaseResult: OnboardingUseCaseViewModelResult? + private var authenticationType: MXKAuthenticationType? + private var session: MXSession? + + /// Whether all of the onboarding steps have been completed or not. `false` if there are more screens to be shown. + private var onboardingIsFinished = false + /// Whether the main app is ready to be shown or not. `true` once authenticated, verified and the store/data sources are ready. + private var appIsReady = false // MARK: Public @@ -74,7 +81,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { self.parameters = parameters // Preload the authVC (it is *really* slow to load in realtime) - let authenticationParameters = AuthenticationCoordinatorParameters(navigationRouter: parameters.router) + let authenticationParameters = AuthenticationCoordinatorParameters(navigationRouter: parameters.router, canPresentAdditionalScreens: false) authenticationCoordinator = AuthenticationCoordinator(parameters: authenticationParameters) super.init() @@ -115,11 +122,13 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { return authenticationCoordinator.continueSSOLogin(withToken: loginToken, transactionID: transactionID) } - // MARK: - Private + // MARK: - Pre-Authentication @available(iOS 14.0, *) /// Show the onboarding splash screen as the root module in the flow. private func showSplashScreen() { + MXLog.debug("[OnboardingCoordinator] showSplashScreen") + let coordinator = OnboardingSplashScreenCoordinator() coordinator.completion = { [weak self, weak coordinator] result in guard let self = self, let coordinator = coordinator else { return } @@ -129,7 +138,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { coordinator.start() add(childCoordinator: coordinator) - self.navigationRouter.setRootModule(coordinator, popCompletion: nil) + navigationRouter.setRootModule(coordinator, popCompletion: nil) } @available(iOS 14.0, *) @@ -138,8 +147,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { splashScreenResult = result // Set the auth type early to allow network requests to finish during display of the use case screen. - let mxkAuthenticationType = splashScreenResult == .register ? MXKAuthenticationTypeRegister : MXKAuthenticationTypeLogin - authenticationCoordinator.update(authenticationType: mxkAuthenticationType) + authenticationCoordinator.update(authenticationType: result.mxkAuthenticationType) switch result { case .register: @@ -152,6 +160,8 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { @available(iOS 14.0, *) /// Show the use case screen for new users. private func showUseCaseSelectionScreen() { + MXLog.debug("[OnboardingCoordinator] showUseCaseSelectionScreen") + let coordinator = OnboardingUseCaseSelectionCoordinator() coordinator.completion = { [weak self, weak coordinator] result in guard let self = self, let coordinator = coordinator else { return } @@ -161,10 +171,10 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { coordinator.start() add(childCoordinator: coordinator) - if self.navigationRouter.modules.isEmpty { - self.navigationRouter.setRootModule(coordinator, popCompletion: nil) + if navigationRouter.modules.isEmpty { + navigationRouter.setRootModule(coordinator, popCompletion: nil) } else { - self.navigationRouter.push(coordinator, animated: true) { [weak self] in + navigationRouter.push(coordinator, animated: true) { [weak self] in self?.remove(childCoordinator: coordinator) } } @@ -176,6 +186,8 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { showAuthenticationScreen() } + // MARK: - Authentication + /// Show the authentication screen. Any parameters that have been set in previous screens are be applied. private func showAuthenticationScreen() { guard !isShowingAuthentication else { return } @@ -187,10 +199,10 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { guard let self = self, let coordinator = coordinator else { return } switch result { - case .didLogin(let session): - self.authenticationCoordinator(coordinator, didLoginWith: session) - case .didComplete(let authenticationType): - self.authenticationCoordinator(coordinator, didCompleteWith: authenticationType) + case .didLogin(let session, let authenticationType): + self.authenticationCoordinator(coordinator, didLoginWith: session, and: authenticationType) + case .didComplete: + self.authenticationCoordinatorDidComplete(coordinator) } } @@ -217,10 +229,10 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { coordinator.updateHomeserver(customHomeserver, andIdentityServer: customIdentityServer) } - if self.navigationRouter.modules.isEmpty { - self.navigationRouter.setRootModule(coordinator, popCompletion: nil) + if navigationRouter.modules.isEmpty { + navigationRouter.setRootModule(coordinator, popCompletion: nil) } else { - self.navigationRouter.push(coordinator, animated: true) { [weak self] in + navigationRouter.push(coordinator, animated: true) { [weak self] in self?.remove(childCoordinator: coordinator) self?.isShowingAuthentication = false } @@ -228,18 +240,38 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { isShowingAuthentication = true } - private func authenticationCoordinator(_ coordinator: AuthenticationCoordinatorProtocol, didLoginWith session: MXSession) { - // TODO: Show next screens whilst waiting for the everything to load. + /// Displays the next view in the flow after the authentication screen, + /// whilst crypto and the rest of the app is launching in the background. + private func authenticationCoordinator(_ coordinator: AuthenticationCoordinatorProtocol, + didLoginWith session: MXSession, + and authenticationType: MXKAuthenticationType) { + self.session = session + self.authenticationType = authenticationType + // May need to move the spinner and key verification up to here in order to coordinate properly. + + // Check whether another screen should be shown. + if #available(iOS 14.0, *) { + if authenticationType == .register, let userId = session.credentials.userId, BuildSettings.onboardingShowAccountPersonalisation { + showCongratulationsScreen(userId: userId) + return + } else if Analytics.shared.shouldShowAnalyticsPrompt { + showAnalyticsPrompt(for: session) + return + } + } + + // Otherwise onboarding is finished. + onboardingIsFinished = true + completeIfReady() } /// Displays the next view in the flow after the authentication screen. - private func authenticationCoordinator(_ coordinator: AuthenticationCoordinatorProtocol, didCompleteWith authenticationType: MXKAuthenticationType) { - completion?() + private func authenticationCoordinatorDidComplete(_ coordinator: AuthenticationCoordinatorProtocol) { isShowingAuthentication = false // Handle the chosen use case where applicable - if authenticationType == MXKAuthenticationTypeRegister, + if authenticationType == .register, let useCase = useCaseResult?.userSessionPropertyValue, let userSession = UserSessionsService.shared.mainUserSession { // Store the value in the user's session @@ -248,6 +280,112 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { // Update the analytics user properties with the use case Analytics.shared.updateUserProperties(ftueUseCase: useCase) } + + // This method is only called when the app is ready so we can complete if finished + appIsReady = true + completeIfReady() + } + + // MARK: - Post-Authentication + + @available(iOS 14.0, *) + private func showCongratulationsScreen(userId: String) { + MXLog.debug("[OnboardingCoordinator] showCongratulationsScreen") + + let parameters = OnboardingCongratulationsCoordinatorParameters(userId: userId) + let coordinator = OnboardingCongratulationsCoordinator(parameters: parameters) + + coordinator.completion = { [weak self, weak coordinator] result in + guard let self = self, let coordinator = coordinator else { return } + self.congratulationsCoordinator(coordinator, didCompleteWith: result) + } + + add(childCoordinator: coordinator) + coordinator.start() + + // Navigating back doesn't make any sense now, so replace the whole stack. + navigationRouter.setRootModule(coordinator, hideNavigationBar: true, animated: true) { [weak self] in + self?.remove(childCoordinator: coordinator) + } + } + + @available(iOS 14.0, *) + private func congratulationsCoordinator(_ coordinator: OnboardingCongratulationsCoordinator, didCompleteWith result: OnboardingCongratulationsViewModelResult) { + if let session = session { + switch result { + case .personaliseProfile: + // TODO: Profile screens here instead. + if Analytics.shared.shouldShowAnalyticsPrompt { + showAnalyticsPrompt(for: session) + return + } + case .takeMeHome: + if Analytics.shared.shouldShowAnalyticsPrompt { + showAnalyticsPrompt(for: session) + return + } + } + } + + onboardingIsFinished = true + completeIfReady() + } + + @available(iOS 14.0, *) + private func showAnalyticsPrompt(for session: MXSession) { + MXLog.debug("[OnboardingCoordinator]: Invite the user to send analytics") + + let parameters = AnalyticsPromptCoordinatorParameters(session: session) + let coordinator = AnalyticsPromptCoordinator(parameters: parameters) + + coordinator.completion = { [weak self, weak coordinator] in + guard let self = self, let coordinator = coordinator else { return } + self.analyticsPromptCoordinatorDidComplete(coordinator) + } + + add(childCoordinator: coordinator) + coordinator.start() + + // TODO: Re-asses replacing the stack based on the previous screen once the whole flow is implemented + navigationRouter.setRootModule(coordinator, hideNavigationBar: true, animated: true) { [weak self] in + self?.remove(childCoordinator: coordinator) + } + } + + private func analyticsPromptCoordinatorDidComplete(_ coordinator: AnalyticsPromptCoordinator) { + onboardingIsFinished = true + completeIfReady() + } + + // MARK: - Finished + + private func completeIfReady() { + guard onboardingIsFinished else { + MXLog.debug("[OnboardingCoordinator] Delaying onboarding completion until all screens have been shown.") + return + } + + guard appIsReady else { + MXLog.debug("[OnboardingCoordinator] Allowing AuthenticationCoordinator to display any remaining screens.") + authenticationCoordinator.allowScreenPresentation() + return + } + + completion?() + } +} + +// MARK: - Helpers + +extension OnboardingSplashScreenViewModelResult { + /// The result converted into the MatrixKit authentication type to use. + var mxkAuthenticationType: MXKAuthenticationType { + switch self { + case .login: + return .login + case .register: + return .register + } } } diff --git a/Riot/Modules/TabBar/MasterTabBarController.h b/Riot/Modules/TabBar/MasterTabBarController.h index 6734a1528..c9948baf9 100644 --- a/Riot/Modules/TabBar/MasterTabBarController.h +++ b/Riot/Modules/TabBar/MasterTabBarController.h @@ -194,6 +194,5 @@ typedef NS_ENUM(NSUInteger, MasterTabBarIndex) { - (void)masterTabBarController:(MasterTabBarController *)masterTabBarController didSelectRoomPreviewWithParameters:(RoomPreviewNavigationParameters*)roomPreviewNavigationParameters completion:(void (^)(void))completion; - (void)masterTabBarController:(MasterTabBarController *)masterTabBarController didSelectContact:(MXKContact*)contact withPresentationParameters:(ScreenPresentationParameters*)presentationParameters; - (void)masterTabBarController:(MasterTabBarController *)masterTabBarController didSelectGroup:(MXGroup*)group inMatrixSession:(MXSession*)matrixSession presentationParameters:(ScreenPresentationParameters*)presentationParameters; -- (void)masterTabBarController:(MasterTabBarController *)masterTabBarController shouldPresentAnalyticsPromptForMatrixSession:(MXSession*)matrixSession; @end diff --git a/Riot/Modules/TabBar/MasterTabBarController.m b/Riot/Modules/TabBar/MasterTabBarController.m index 1a08218b9..bc306b757 100644 --- a/Riot/Modules/TabBar/MasterTabBarController.m +++ b/Riot/Modules/TabBar/MasterTabBarController.m @@ -73,11 +73,6 @@ @property (nonatomic) BOOL reviewSessionAlertHasBeenDisplayed; -/** - A flag to indicate that the analytics prompt should be shown during `-addMatrixSession:`. - */ -@property(nonatomic) BOOL presentAnalyticsPromptOnAddSession; - @end @implementation MasterTabBarController @@ -204,20 +199,6 @@ if (!authIsShown) { - // Check whether the user should be prompted to send analytics. - if (Analytics.shared.shouldShowAnalyticsPrompt) - { - MXSession *mxSession = self.mxSessions.firstObject; - if (mxSession) - { - [self promptUserBeforeUsingAnalyticsForSession:mxSession]; - } - else - { - self.presentAnalyticsPromptOnAddSession = YES; - } - } - [self refreshTabBarBadges]; // Release properly pushed and/or presented view controller @@ -422,12 +403,6 @@ return; } - if (self.presentAnalyticsPromptOnAddSession) - { - self.presentAnalyticsPromptOnAddSession = NO; - [self promptUserBeforeUsingAnalyticsForSession:mxSession]; - } - // Check whether the controller'€™s view is loaded into memory. if (self.homeViewController) { @@ -965,18 +940,6 @@ return NSNotFound; } -#pragma mark - - -- (void)promptUserBeforeUsingAnalyticsForSession:(MXSession *)mxSession -{ - // Analytics aren't collected on iOS 12 & 13. - if (@available(iOS 14.0, *)) - { - MXLogDebug(@"[MasterTabBarController]: Invite the user to send analytics"); - [self.masterTabBarDelegate masterTabBarController:self shouldPresentAnalyticsPromptForMatrixSession:mxSession]; - } -} - #pragma mark - Review session - (void)presentVerifyCurrentSessionAlertIfNeededWithSession:(MXSession*)session diff --git a/Riot/Modules/TabBar/TabBarCoordinator.swift b/Riot/Modules/TabBar/TabBarCoordinator.swift index 30ce90bc0..2cd8d408f 100644 --- a/Riot/Modules/TabBar/TabBarCoordinator.swift +++ b/Riot/Modules/TabBar/TabBarCoordinator.swift @@ -575,24 +575,6 @@ final class TabBarCoordinator: NSObject, TabBarCoordinatorType { self.splitViewMasterPresentableDelegate?.splitViewMasterPresentableWantsToResetDetail(self) } - @available(iOS 14.0, *) - private func presentAnalyticsPrompt(with session: MXSession) { - let parameters = AnalyticsPromptCoordinatorParameters(session: session) - let coordinator = AnalyticsPromptCoordinator(parameters: parameters) - - coordinator.completion = { [weak self, weak coordinator] in - guard let self = self, let coordinator = coordinator else { return } - - self.navigationRouter.dismissModule(animated: true, completion: nil) - self.remove(childCoordinator: coordinator) - } - - add(childCoordinator: coordinator) - - navigationRouter.present(coordinator, animated: true) - coordinator.start() - } - // MARK: UserSessions management private func registerUserSessionsServiceNotifications() { @@ -684,12 +666,6 @@ extension TabBarCoordinator: MasterTabBarControllerDelegate { self.masterTabBarController.navigationItem.leftBarButtonItem = sideMenuBarButtonItem } - - func masterTabBarController(_ masterTabBarController: MasterTabBarController!, shouldPresentAnalyticsPromptForMatrixSession matrixSession: MXSession!) { - if #available(iOS 14.0, *) { - presentAnalyticsPrompt(with: matrixSession) - } - } } // MARK: - RoomCoordinatorDelegate diff --git a/RiotSwiftUI/Modules/AnalyticsPrompt/View/AnalyticsPrompt.swift b/RiotSwiftUI/Modules/AnalyticsPrompt/View/AnalyticsPrompt.swift index 178d1d215..816498006 100644 --- a/RiotSwiftUI/Modules/AnalyticsPrompt/View/AnalyticsPrompt.swift +++ b/RiotSwiftUI/Modules/AnalyticsPrompt/View/AnalyticsPrompt.swift @@ -125,6 +125,8 @@ struct AnalyticsPrompt: View { .background(theme.colors.background.ignoresSafeArea()) .accentColor(theme.colors.accent) } + .navigationBarHidden(true) + .navigationBarBackButtonHidden(true) } } diff --git a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift index 242e4c088..73981575f 100644 --- a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift +++ b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift @@ -20,6 +20,7 @@ import Foundation @available(iOS 14.0, *) enum MockAppScreens { static let appScreens: [MockScreenState.Type] = [ + MockOnboardingCongratulationsScreenState.self, MockOnboardingUseCaseSelectionScreenState.self, MockOnboardingSplashScreenScreenState.self, MockLocationSharingScreenState.self, diff --git a/RiotSwiftUI/Modules/Onboarding/Congratulations/Coordinator/OnboardingCongratulationsCoordinator.swift b/RiotSwiftUI/Modules/Onboarding/Congratulations/Coordinator/OnboardingCongratulationsCoordinator.swift new file mode 100644 index 000000000..2ad988a7a --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/Congratulations/Coordinator/OnboardingCongratulationsCoordinator.swift @@ -0,0 +1,64 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct OnboardingCongratulationsCoordinatorParameters { + let userId: String +} + +final class OnboardingCongratulationsCoordinator: Coordinator, Presentable { + + // MARK: - Properties + + // MARK: Private + + private let parameters: OnboardingCongratulationsCoordinatorParameters + private let onboardingCongratulationsHostingController: UIViewController + private var onboardingCongratulationsViewModel: OnboardingCongratulationsViewModelProtocol + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var completion: ((OnboardingCongratulationsViewModelResult) -> Void)? + + // MARK: - Setup + + @available(iOS 14.0, *) + init(parameters: OnboardingCongratulationsCoordinatorParameters) { + self.parameters = parameters + + let viewModel = OnboardingCongratulationsViewModel(userId: parameters.userId) + let view = OnboardingCongratulations(viewModel: viewModel.context) + onboardingCongratulationsViewModel = viewModel + onboardingCongratulationsHostingController = VectorHostingController(rootView: view) + } + + // MARK: - Public + func start() { + MXLog.debug("[OnboardingCongratulationsCoordinator] did start.") + onboardingCongratulationsViewModel.completion = { [weak self] result in + MXLog.debug("[OnboardingCongratulationsCoordinator] OnboardingCongratulationsViewModel did complete with result: \(result).") + guard let self = self else { return } + self.completion?(result) + } + } + + func toPresentable() -> UIViewController { + return self.onboardingCongratulationsHostingController + } +} diff --git a/RiotSwiftUI/Modules/Onboarding/Congratulations/MockOnboardingCongratulationsScreenState.swift b/RiotSwiftUI/Modules/Onboarding/Congratulations/MockOnboardingCongratulationsScreenState.swift new file mode 100644 index 000000000..4aa617f1c --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/Congratulations/MockOnboardingCongratulationsScreenState.swift @@ -0,0 +1,46 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import SwiftUI + +/// Using an enum for the screen allows you define the different state cases with +/// the relevant associated data for each case. +@available(iOS 14.0, *) +enum MockOnboardingCongratulationsScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case congratulations + + /// The associated screen + var screenType: Any.Type { + OnboardingCongratulationsScreen.self + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let viewModel = OnboardingCongratulationsViewModel(userId: "@testuser:example.com") + + // can simulate service and viewModel actions here if needs be. + + return ( + [self, viewModel], + AnyView(OnboardingCongratulationsScreen(viewModel: viewModel.context) + .addDependency(MockAvatarService.example)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Onboarding/Congratulations/OnboardingCongratulationsModels.swift b/RiotSwiftUI/Modules/Onboarding/Congratulations/OnboardingCongratulationsModels.swift new file mode 100644 index 000000000..d8eb9e926 --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/Congratulations/OnboardingCongratulationsModels.swift @@ -0,0 +1,37 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +// MARK: - Coordinator + +// MARK: View model + +enum OnboardingCongratulationsViewModelResult { + case personaliseProfile + case takeMeHome +} + +// MARK: View + +struct OnboardingCongratulationsViewState: BindableState { + var userId: String +} + +enum OnboardingCongratulationsViewAction { + case personaliseProfile + case takeMeHome +} diff --git a/RiotSwiftUI/Modules/Onboarding/Congratulations/OnboardingCongratulationsViewModel.swift b/RiotSwiftUI/Modules/Onboarding/Congratulations/OnboardingCongratulationsViewModel.swift new file mode 100644 index 000000000..26a7ebfdd --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/Congratulations/OnboardingCongratulationsViewModel.swift @@ -0,0 +1,50 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +@available(iOS 14, *) +typealias OnboardingCongratulationsViewModelType = StateStoreViewModel +@available(iOS 14, *) +class OnboardingCongratulationsViewModel: OnboardingCongratulationsViewModelType, OnboardingCongratulationsViewModelProtocol { + + // MARK: - Properties + + // MARK: Private + + // MARK: Public + + var completion: ((OnboardingCongratulationsViewModelResult) -> Void)? + + // MARK: - Setup + + init(userId: String, initialCount: Int = 0) { + super.init(initialViewState: OnboardingCongratulationsViewState(userId: userId)) + } + + // MARK: - Public + + override func process(viewAction: OnboardingCongratulationsViewAction) { + switch viewAction { + case .personaliseProfile: + completion?(.personaliseProfile) + case .takeMeHome: + completion?(.takeMeHome) + } + } +} diff --git a/RiotSwiftUI/Modules/Onboarding/Congratulations/OnboardingCongratulationsViewModelProtocol.swift b/RiotSwiftUI/Modules/Onboarding/Congratulations/OnboardingCongratulationsViewModelProtocol.swift new file mode 100644 index 000000000..72bde393e --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/Congratulations/OnboardingCongratulationsViewModelProtocol.swift @@ -0,0 +1,24 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +protocol OnboardingCongratulationsViewModelProtocol { + + var completion: ((OnboardingCongratulationsViewModelResult) -> Void)? { get set } + @available(iOS 14, *) + var context: OnboardingCongratulationsViewModelType.Context { get } +} diff --git a/RiotSwiftUI/Modules/Onboarding/Congratulations/Test/UI/OnboardingCongratulationsUITests.swift b/RiotSwiftUI/Modules/Onboarding/Congratulations/Test/UI/OnboardingCongratulationsUITests.swift new file mode 100644 index 000000000..5b46e5c97 --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/Congratulations/Test/UI/OnboardingCongratulationsUITests.swift @@ -0,0 +1,40 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import RiotSwiftUI + +@available(iOS 14.0, *) +class OnboardingCongratulationsUITests: MockScreenTest { + + override class var screenType: MockScreenState.Type { + return MockOnboardingCongratulationsScreenState.self + } + + override class func createTest() -> MockScreenTest { + return OnboardingCongratulationsUITests(selector: #selector(verifyOnboardingCongratulationsScreen)) + } + + func verifyOnboardingCongratulationsScreen() throws { + guard let screenState = screenState as? MockOnboardingCongratulationsScreenState else { fatalError("no screen") } + switch screenState { + case .congratulations: + // There isn't anything to test here + break + } + } + +} diff --git a/RiotSwiftUI/Modules/Onboarding/Congratulations/Test/Unit/OnboardingCongratulationsViewModelTests.swift b/RiotSwiftUI/Modules/Onboarding/Congratulations/Test/Unit/OnboardingCongratulationsViewModelTests.swift new file mode 100644 index 000000000..44352f799 --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/Congratulations/Test/Unit/OnboardingCongratulationsViewModelTests.swift @@ -0,0 +1,24 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +@testable import RiotSwiftUI + +@available(iOS 14.0, *) +class OnboardingCongratulationsViewModelTests: XCTestCase { + // The view modal has minimal set up and no mutation so nothing to test. +} diff --git a/RiotSwiftUI/Modules/Onboarding/Congratulations/View/OnboardingCongratulationsScreen.swift b/RiotSwiftUI/Modules/Onboarding/Congratulations/View/OnboardingCongratulationsScreen.swift new file mode 100644 index 000000000..e7b6e5b13 --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/Congratulations/View/OnboardingCongratulationsScreen.swift @@ -0,0 +1,108 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +@available(iOS 14.0, *) +struct OnboardingCongratulationsScreen: View { + + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + private var horizontalPadding: CGFloat { + horizontalSizeClass == .regular ? 50 : 16 + } + + // MARK: Public + + @ObservedObject var viewModel: OnboardingCongratulationsViewModel.Context + + // MARK: Views + + var body: some View { + GeometryReader { geometry in + VStack { + mainContent + .padding(.top, 60) + .padding(.horizontal, horizontalPadding) + + Spacer() + + buttons + .padding(.horizontal, horizontalPadding) + .padding(.bottom, 24) + .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 0 : 16) + } + .frame(maxWidth: OnboardingConstants.maxContentWidth, + maxHeight: OnboardingConstants.maxContentHeight) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .background(theme.colors.accent.ignoresSafeArea()) + .accentColor(.white) + .navigationBarHidden(true) + .preferredColorScheme(.dark) // make the status bar white + } + + /// The main content of the view to be shown in a scroll view. + var mainContent: some View { + VStack(spacing: 62) { + Image(Asset.Images.onboardingCongratulationsIcon.name) + + VStack(spacing: 8) { + Text(VectorL10n.onboardingCongratulationsTitle) + .font(theme.fonts.title2B) + .foregroundColor(.white) + + Text(VectorL10n.onboardingCongratulationsMessage(viewModel.viewState.userId)) + .font(theme.fonts.body) + .foregroundColor(.white) + .multilineTextAlignment(.center) + } + } + } + + /// The action buttons shown at the bottom of the view. + var buttons: some View { + VStack(spacing: 12) { + Button { viewModel.send(viewAction: .personaliseProfile) } label: { + Text(VectorL10n.onboardingCongratulationsPersonaliseButton) + .font(theme.fonts.bodySB) + .foregroundColor(theme.colors.accent) + } + .buttonStyle(PrimaryActionButtonStyle(customColor: .white)) + + Button { viewModel.send(viewAction: .takeMeHome) } label: { + Text(VectorL10n.onboardingCongratulationsHomeButton) + .font(theme.fonts.body) + .padding(.vertical, 12) + } + } + } +} + +// MARK: - Previews + +@available(iOS 14.0, *) +struct OnboardingCongratulationsScreen_Previews: PreviewProvider { + static let stateRenderer = MockOnboardingCongratulationsScreenState.stateRenderer + static var previews: some View { + stateRenderer.screenGroup() + } +}