diff --git a/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_community.imageset/Contents.json b/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_community.imageset/Contents.json new file mode 100644 index 0000000000..9f6e8de0a4 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_community.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "onboarding_use_case_community.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_community.imageset/onboarding_use_case_community.svg b/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_community.imageset/onboarding_use_case_community.svg new file mode 100644 index 0000000000..6b58a7b567 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_community.imageset/onboarding_use_case_community.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_community_dark.imageset/Contents.json b/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_community_dark.imageset/Contents.json new file mode 100644 index 0000000000..e34cfce4e8 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_community_dark.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "onboarding_use_case_community_dark.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_community_dark.imageset/onboarding_use_case_community_dark.svg b/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_community_dark.imageset/onboarding_use_case_community_dark.svg new file mode 100644 index 0000000000..1fb2f8af13 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_community_dark.imageset/onboarding_use_case_community_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_icon.imageset/Contents.json b/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_icon.imageset/Contents.json new file mode 100644 index 0000000000..f4e9e56aca --- /dev/null +++ b/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_icon.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "onboarding_use_case_icon.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_icon.imageset/onboarding_use_case_icon.svg b/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_icon.imageset/onboarding_use_case_icon.svg new file mode 100644 index 0000000000..2366becdb5 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_icon.imageset/onboarding_use_case_icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_personal.imageset/Contents.json b/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_personal.imageset/Contents.json new file mode 100644 index 0000000000..1073b40874 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_personal.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "onboarding_use_case_personal.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_personal.imageset/onboarding_use_case_personal.svg b/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_personal.imageset/onboarding_use_case_personal.svg new file mode 100644 index 0000000000..efd561c9a0 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_personal.imageset/onboarding_use_case_personal.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_personal_dark.imageset/Contents.json b/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_personal_dark.imageset/Contents.json new file mode 100644 index 0000000000..081fb3fde0 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_personal_dark.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "onboarding_use_case_personal_dark.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_personal_dark.imageset/onboarding_use_case_personal_dark.svg b/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_personal_dark.imageset/onboarding_use_case_personal_dark.svg new file mode 100644 index 0000000000..b5c728adce --- /dev/null +++ b/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_personal_dark.imageset/onboarding_use_case_personal_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_work.imageset/Contents.json b/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_work.imageset/Contents.json new file mode 100644 index 0000000000..40d6dcbdf0 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_work.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "onboarding_use_case_work.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_work.imageset/onboarding_use_case_work.svg b/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_work.imageset/onboarding_use_case_work.svg new file mode 100644 index 0000000000..61cf7bd860 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_work.imageset/onboarding_use_case_work.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_work_dark.imageset/Contents.json b/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_work_dark.imageset/Contents.json new file mode 100644 index 0000000000..da38b9aad4 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_work_dark.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "onboarding_use_case_work_dark.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_work_dark.imageset/onboarding_use_case_work_dark.svg b/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_work_dark.imageset/onboarding_use_case_work_dark.svg new file mode 100644 index 0000000000..0f6ee387ac --- /dev/null +++ b/Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_work_dark.imageset/onboarding_use_case_work_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index 0cc59b0e55..e7e8f2593b 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -93,6 +93,17 @@ "onboarding_splash_page_4_title_no_pun" = "Messaging for your team."; "onboarding_splash_page_4_message" = "Element is also great for the workplace. It’s trusted by the world’s most secure organisations."; +"onboarding_use_case_title" = "Who will you chat to the most?"; +"onboarding_use_case_message" = "We’ll help you get connected."; +"onboarding_use_case_personal_messaging" = "Friends and family"; +"onboarding_use_case_work_messaging" = "Coworkers and teams"; +"onboarding_use_case_community_messaging" = "Online community members"; +/* The placeholder string contains onboarding_use_case_skip_button as a tappable action */ +"onboarding_use_case_not_sure_yet" = "Not sure yet? You can %@"; +"onboarding_use_case_skip_button" = "skip this question"; +"onboarding_use_case_existing_server_message" = "Looking to join an existing server?"; +"onboarding_use_case_existing_server_button" = "Connect to server"; + // Authentication "auth_login" = "Log in"; "auth_register" = "Register"; diff --git a/Riot/Generated/Images.swift b/Riot/Generated/Images.swift index 2c6b3aeef1..9e2cf5b878 100644 --- a/Riot/Generated/Images.swift +++ b/Riot/Generated/Images.swift @@ -118,6 +118,13 @@ internal enum Asset { internal static let onboardingSplashScreenPage3Dark = ImageAsset(name: "OnboardingSplashScreenPage3Dark") internal static let onboardingSplashScreenPage4 = ImageAsset(name: "OnboardingSplashScreenPage4") internal static let onboardingSplashScreenPage4Dark = ImageAsset(name: "OnboardingSplashScreenPage4Dark") + 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") + internal static let onboardingUseCasePersonal = ImageAsset(name: "onboarding_use_case_personal") + internal static let onboardingUseCasePersonalDark = ImageAsset(name: "onboarding_use_case_personal_dark") + internal static let onboardingUseCaseWork = ImageAsset(name: "onboarding_use_case_work") + internal static let onboardingUseCaseWorkDark = ImageAsset(name: "onboarding_use_case_work_dark") internal static let peopleEmptyScreenArtwork = ImageAsset(name: "people_empty_screen_artwork") internal static let peopleEmptyScreenArtworkDark = ImageAsset(name: "people_empty_screen_artwork_dark") internal static let peopleFloatingAction = ImageAsset(name: "people_floating_action") diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index 9923967674..3b9cea86f2 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -2395,6 +2395,42 @@ public class VectorL10n: NSObject { public static var onboardingSplashRegisterButtonTitle: String { return VectorL10n.tr("Vector", "onboarding_splash_register_button_title") } + /// Online community members + public static var onboardingUseCaseCommunityMessaging: String { + return VectorL10n.tr("Vector", "onboarding_use_case_community_messaging") + } + /// Connect to server + public static var onboardingUseCaseExistingServerButton: String { + return VectorL10n.tr("Vector", "onboarding_use_case_existing_server_button") + } + /// Looking to join an existing server? + public static var onboardingUseCaseExistingServerMessage: String { + return VectorL10n.tr("Vector", "onboarding_use_case_existing_server_message") + } + /// We’ll help you get connected. + public static var onboardingUseCaseMessage: String { + return VectorL10n.tr("Vector", "onboarding_use_case_message") + } + /// Not sure yet? You can %@ + public static func onboardingUseCaseNotSureYet(_ p1: String) -> String { + return VectorL10n.tr("Vector", "onboarding_use_case_not_sure_yet", p1) + } + /// Friends and family + public static var onboardingUseCasePersonalMessaging: String { + return VectorL10n.tr("Vector", "onboarding_use_case_personal_messaging") + } + /// skip this question + public static var onboardingUseCaseSkipButton: String { + return VectorL10n.tr("Vector", "onboarding_use_case_skip_button") + } + /// Who will you chat to the most? + public static var onboardingUseCaseTitle: String { + return VectorL10n.tr("Vector", "onboarding_use_case_title") + } + /// Coworkers and teams + public static var onboardingUseCaseWorkMessaging: String { + return VectorL10n.tr("Vector", "onboarding_use_case_work_messaging") + } /// Open public static var `open`: String { return VectorL10n.tr("Vector", "open") diff --git a/Riot/Managers/UserSessions/UserSession.swift b/Riot/Managers/UserSessions/UserSession.swift index 786fed88d6..fa5b883acf 100644 --- a/Riot/Managers/UserSessions/UserSession.swift +++ b/Riot/Managers/UserSessions/UserSession.swift @@ -33,6 +33,8 @@ class UserSession: NSObject, UserSessionProtocol { let account: MXKAccount // Keep strong reference to the MXSession because account.mxSession can become nil on logout or failure let matrixSession: MXSession + /// An object that contains user specific properties. + private(set) lazy var properties = UserSessionProperties(userId: userId) var userId: String { guard let userId = self.account.mxCredentials.userId else { diff --git a/Riot/Managers/UserSessions/UserSessionProperties.swift b/Riot/Managers/UserSessions/UserSessionProperties.swift new file mode 100644 index 0000000000..170c25cdbf --- /dev/null +++ b/Riot/Managers/UserSessions/UserSessionProperties.swift @@ -0,0 +1,97 @@ +// +// 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 + +/// User properties that are tied to a particular user ID. +class UserSessionProperties: NSObject { + + // MARK: - Constants + private enum Constants { + static let suiteName = BuildSettings.baseBundleIdentifier + ".UserSession" + static let useCaseKey = "useCase" + } + + // MARK: - Properties + + // MARK: Private + + /// The user ID for these properties + private let userId: String + /// The underlying dictionary that stores the properties in user defaults. + private var dictionary: [String: Any] { + didSet { + UserDefaults(suiteName: Constants.suiteName)?.dictionary(forKey: userId) + } + } + + // MARK: Public + + /// The user's use case selection if this session was the one used to register the account. + var useCase: UseCase? { + get { + guard let useCaseRawValue = dictionary[Constants.useCaseKey] as? String else { return nil } + return UseCase(rawValue: useCaseRawValue) + } set { + dictionary[Constants.useCaseKey] = newValue?.rawValue + } + } + + /// Represents a selected use case for the app. + /// Note: The raw string value is used for storage. + enum UseCase: String { + case personalMessaging + case workMessaging + case communityMessaging + case skipped + } + + // MARK: - Setup + + /// Create new properties for the specified user ID. + /// - Parameter userId: The user ID to load properties for. + init(userId: String) { + self.userId = userId + self.dictionary = UserDefaults(suiteName: Constants.suiteName)?.dictionary(forKey: userId) ?? [:] + + super.init() + } + + // MARK: - Public + + /// Store the user's use case selection using the Onboarding result. + /// - Parameter useCaseResult: An `OnboardingUseCaseViewModelResult` representing the user's selection. + func store(useCaseResult: OnboardingUseCaseViewModelResult) { + switch useCaseResult { + case .personalMessaging: + useCase = .personalMessaging + case .workMessaging: + useCase = .workMessaging + case .communityMessaging: + useCase = .communityMessaging + case .skipped: + useCase = .skipped + case .customServer: + useCase = nil + } + } + + /// Clear all of the stored properties. + func delete() { + dictionary = [:] + UserDefaults(suiteName: Constants.suiteName)?.removeObject(forKey: userId) + } +} diff --git a/Riot/Managers/UserSessions/UserSessionsService.swift b/Riot/Managers/UserSessions/UserSessionsService.swift index b8618abb0d..b764220953 100644 --- a/Riot/Managers/UserSessions/UserSessionsService.swift +++ b/Riot/Managers/UserSessions/UserSessionsService.swift @@ -131,6 +131,9 @@ class UserSessionsService: NSObject { NotificationCenter.default.post(name: UserSessionsService.willRemoveUserSession, object: self, userInfo: [NotificationUserInfoKey.userSession: userSession]) } + // Clear any stored user properties from this session. + userSession.properties.delete() + self.userSessions.removeAll { (userSession) -> Bool in return userId == userSession.userId } diff --git a/Riot/Modules/Analytics/Analytics.swift b/Riot/Modules/Analytics/Analytics.swift index 3f6147c6a3..15eda52175 100644 --- a/Riot/Modules/Analytics/Analytics.swift +++ b/Riot/Modules/Analytics/Analytics.swift @@ -111,12 +111,20 @@ import AnalyticsEvents let service = AnalyticsService(session: session) self.service = service + // Gather any tracked user properties into an identity event. + var identityEvent: AnalyticsEvent.Identity? + if let userId = session.credentials.userId, + let userSession = UserSessionsService.shared.userSession(withUserId: userId), + let useCase = userSession.properties.useCase { + identityEvent = AnalyticsEvent.Identity(ftueUseCaseSelection: useCase.ftueUseCaseSelection) + } + service.settings { [weak self] result in guard let self = self else { return } switch result { case .success(let settings): - self.identify(with: settings) + self.identify(with: settings, and: identityEvent) self.service = nil case .failure: MXLog.error("[Analytics] Failed to use analytics settings. Will continue to run without analytics ID.") @@ -149,13 +157,14 @@ import AnalyticsEvents /// Identify (pseudonymously) any future events with the ID from the analytics account data settings. /// - Parameter settings: The settings to use for identification. The ID must be set *before* calling this method. - private func identify(with settings: AnalyticsSettings) { + /// - Parameter identityEvent: An identity event that is used to add user properties to the request. + private func identify(with settings: AnalyticsSettings, and identityEvent: AnalyticsEvent.Identity?) { guard let id = settings.id else { MXLog.error("[Analytics] identify(with:) called before an ID has been generated.") return } - client.identify(id: id) + client.identify(id: id, identity: identityEvent) MXLog.debug("[Analytics] Identified.") RiotSettings.shared.isIdentifiedForAnalytics = true } diff --git a/Riot/Modules/Analytics/AnalyticsClientProtocol.swift b/Riot/Modules/Analytics/AnalyticsClientProtocol.swift index af07a58fba..5e11189a23 100644 --- a/Riot/Modules/Analytics/AnalyticsClientProtocol.swift +++ b/Riot/Modules/Analytics/AnalyticsClientProtocol.swift @@ -26,7 +26,8 @@ protocol AnalyticsClientProtocol { /// Associate the client with an ID. This is persisted until `reset` is called. /// - Parameter id: The ID to associate with the user. - func identify(id: String) + /// - Parameter event: An identity event that contains user specific properties to be sent. + func identify(id: String, identity event: AnalyticsEvent.Identity?) /// Reset all stored properties and any event queues on the client. Note that /// the client will remain active, but in a fresh unidentified state. diff --git a/Riot/Modules/Analytics/Helpers/UserSessionProperties+Analytics.swift b/Riot/Modules/Analytics/Helpers/UserSessionProperties+Analytics.swift new file mode 100644 index 0000000000..35e4d98beb --- /dev/null +++ b/Riot/Modules/Analytics/Helpers/UserSessionProperties+Analytics.swift @@ -0,0 +1,32 @@ +// +// 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 AnalyticsEvents + +extension UserSessionProperties.UseCase { + var ftueUseCaseSelection: AnalyticsEvent.Identity.FtueUseCaseSelection? { + switch self { + case .personalMessaging: + return .PersonalMessaging + case .workMessaging: + return .WorkMessaging + case .communityMessaging: + return .CommunityMessaging + case .skipped: + return .Skip + } + } +} diff --git a/Riot/Modules/Analytics/PostHogAnalyticsClient.swift b/Riot/Modules/Analytics/PostHogAnalyticsClient.swift index 1c71721120..653ef6d0d5 100644 --- a/Riot/Modules/Analytics/PostHogAnalyticsClient.swift +++ b/Riot/Modules/Analytics/PostHogAnalyticsClient.swift @@ -35,8 +35,8 @@ class PostHogAnalyticsClient: AnalyticsClientProtocol { postHog?.enable() } - func identify(id: String) { - postHog?.identify(id) + func identify(id: String, identity event: AnalyticsEvent.Identity?) { + postHog?.identify(id, properties: event?.properties) } func reset() { diff --git a/Riot/Modules/Authentication/AuthenticationCoordinator.swift b/Riot/Modules/Authentication/AuthenticationCoordinator.swift index a310cdb1ad..521ad52c29 100644 --- a/Riot/Modules/Authentication/AuthenticationCoordinator.swift +++ b/Riot/Modules/Authentication/AuthenticationCoordinator.swift @@ -32,7 +32,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc // Must be used only internally var childCoordinators: [Coordinator] = [] - var completion: (() -> Void)? + var completion: ((MXKAuthenticationType) -> Void)? // MARK: - Setup @@ -82,6 +82,6 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc // MARK: - AuthenticationViewControllerDelegate extension AuthenticationCoordinator: AuthenticationViewControllerDelegate { func authenticationViewControllerDidDismiss(_ authenticationViewController: AuthenticationViewController!) { - completion?() + completion?(authenticationViewController.authType) } } diff --git a/Riot/Modules/Authentication/AuthenticationCoordinatorProtocol.swift b/Riot/Modules/Authentication/AuthenticationCoordinatorProtocol.swift index 7037f9d1f3..c90c212a5c 100644 --- a/Riot/Modules/Authentication/AuthenticationCoordinatorProtocol.swift +++ b/Riot/Modules/Authentication/AuthenticationCoordinatorProtocol.swift @@ -20,7 +20,7 @@ import Foundation /// `AuthenticationCoordinatorProtocol` is a protocol describing a Coordinator that handle's the authentication navigation flow. protocol AuthenticationCoordinatorProtocol: Coordinator, Presentable { - var completion: (() -> Void)? { get set } + var completion: ((MXKAuthenticationType) -> Void)? { get set } /// Update the screen to display registration or login. func update(authenticationType: MXKAuthenticationType) diff --git a/Riot/Modules/Onboarding/OnboardingCoordinator.swift b/Riot/Modules/Onboarding/OnboardingCoordinator.swift index 8a6dcc7c42..4ea9247e2e 100644 --- a/Riot/Modules/Onboarding/OnboardingCoordinator.swift +++ b/Riot/Modules/Onboarding/OnboardingCoordinator.swift @@ -60,6 +60,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { // MARK: Screen results private var splashScreenResult: OnboardingSplashScreenViewModelResult? + private var useCaseResult: OnboardingUseCaseViewModelResult? // MARK: Public @@ -126,9 +127,43 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { self.navigationRouter.setRootModule(coordinator, popCompletion: nil) } + @available(iOS 14.0, *) /// Displays the next view in the flow after the splash screen. private func splashScreenCoordinator(_ coordinator: OnboardingSplashScreenCoordinator, didCompleteWith result: OnboardingSplashScreenViewModelResult) { splashScreenResult = result + + switch result { + case .register: + showUseCase() + case .login: + showAuthenticationScreen() + } + } + + @available(iOS 14.0, *) + /// Show the use case screen for new users. + private func showUseCase() { + let coordinator = OnboardingUseCaseCoordinator() + coordinator.completion = { [weak self, weak coordinator] result in + guard let self = self, let coordinator = coordinator else { return } + self.useCaseCoordinator(coordinator, didCompleteWith: result) + } + + coordinator.start() + add(childCoordinator: coordinator) + + if self.navigationRouter.modules.isEmpty { + self.navigationRouter.setRootModule(coordinator, popCompletion: nil) + } else { + self.navigationRouter.push(coordinator, animated: true) { [weak self] in + self?.remove(childCoordinator: coordinator) + } + } + } + + /// Displays the next view in the flow after the use case screen. + private func useCaseCoordinator(_ coordinator: OnboardingUseCaseCoordinator, didCompleteWith result: OnboardingUseCaseViewModelResult) { + useCaseResult = result showAuthenticationScreen() } @@ -139,9 +174,9 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { MXLog.debug("[OnboardingCoordinator] showAuthenticationScreen") let coordinator = authenticationCoordinator - coordinator.completion = { [weak self, weak coordinator] in + coordinator.completion = { [weak self, weak coordinator] authenticationType in guard let self = self, let coordinator = coordinator else { return } - self.authenticationCoordinatorDidComplete(coordinator) + self.authenticationCoordinator(coordinator, didCompleteWith: authenticationType) } // Due to needing to preload the authVC, this breaks the Coordinator init/start pattern. @@ -178,8 +213,15 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { } /// Displays the next view in the flow after the authentication screen. - private func authenticationCoordinatorDidComplete(_ coordinator: AuthenticationCoordinatorProtocol) { + private func authenticationCoordinator(_ coordinator: AuthenticationCoordinatorProtocol, didCompleteWith authenticationType: MXKAuthenticationType) { completion?() isShowingAuthentication = false + + // Store the chosen use case when appropriate for any default configuration and, if opted in, for analytics. + if authenticationType == MXKAuthenticationTypeRegister, + let useCaseResult = useCaseResult, + let userSession = UserSessionsService.shared.mainUserSession { + userSession.properties.store(useCaseResult: useCaseResult) + } } } diff --git a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift index bf139307f2..e2d3d48162 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] = [ + MockOnboardingUseCaseScreenState.self, MockOnboardingSplashScreenScreenState.self, MockLocationSharingScreenState.self, MockAnalyticsPromptScreenState.self, diff --git a/RiotSwiftUI/Modules/Onboarding/Common/OnboardingButtonStyle.swift b/RiotSwiftUI/Modules/Onboarding/Common/OnboardingButtonStyle.swift new file mode 100644 index 0000000000..73a591d868 --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/Common/OnboardingButtonStyle.swift @@ -0,0 +1,32 @@ +// +// 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 OnboardingButtonStyle: ButtonStyle { + @Environment(\.theme) private var theme + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8) + .stroke(configuration.isPressed ? theme.colors.accent : theme.colors.quinaryContent, lineWidth: configuration.isPressed ? 2 : 1.5) + ) + .contentShape(RoundedRectangle(cornerRadius: 8)) + } +} diff --git a/RiotSwiftUI/Modules/Onboarding/UseCase/Coordinator/OnboardingUseCaseCoordinator.swift b/RiotSwiftUI/Modules/Onboarding/UseCase/Coordinator/OnboardingUseCaseCoordinator.swift new file mode 100644 index 0000000000..5265c828e5 --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/UseCase/Coordinator/OnboardingUseCaseCoordinator.swift @@ -0,0 +1,60 @@ +// +// 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 + +final class OnboardingUseCaseCoordinator: Coordinator, Presentable { + + // MARK: - Properties + + // MARK: Private + + private let onboardingUseCaseHostingController: UIViewController + private var onboardingUseCaseViewModel: OnboardingUseCaseViewModelProtocol + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var completion: ((OnboardingUseCaseViewModelResult) -> Void)? + + // MARK: - Setup + + @available(iOS 14.0, *) + init() { + let viewModel = OnboardingUseCaseViewModel() + let view = OnboardingUseCase(viewModel: viewModel.context) + onboardingUseCaseViewModel = viewModel + + let hostingController = VectorHostingController(rootView: view) + hostingController.vc_removeBackTitle() + onboardingUseCaseHostingController = hostingController + } + + // MARK: - Public + func start() { + MXLog.debug("[OnboardingUseCaseCoordinator] did start.") + onboardingUseCaseViewModel.completion = { [weak self] result in + MXLog.debug("[OnboardingUseCaseCoordinator] OnboardingUseCaseViewModel did complete with result: \(result).") + guard let self = self else { return } + self.completion?(result) + } + } + + func toPresentable() -> UIViewController { + return self.onboardingUseCaseHostingController + } +} diff --git a/RiotSwiftUI/Modules/Onboarding/UseCase/MockOnboardingUseCaseScreenState.swift b/RiotSwiftUI/Modules/Onboarding/UseCase/MockOnboardingUseCaseScreenState.swift new file mode 100644 index 0000000000..7cf850d9be --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/UseCase/MockOnboardingUseCaseScreenState.swift @@ -0,0 +1,52 @@ +// +// 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 MockOnboardingUseCaseScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case `default` + + /// The associated screen + var screenType: Any.Type { + OnboardingUseCase.self + } + + /// A list of screen state definitions + static var allCases: [MockOnboardingUseCaseScreenState] { + // Each of the presence statuses + [.default] + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let viewModel = OnboardingUseCaseViewModel() + + // can simulate service and viewModel actions here if needs be. + + return ( + [self, viewModel], + AnyView(OnboardingUseCase(viewModel: viewModel.context) + .addDependency(MockAvatarService.example)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Onboarding/UseCase/OnboardingUseCaseModels.swift b/RiotSwiftUI/Modules/Onboarding/UseCase/OnboardingUseCaseModels.swift new file mode 100644 index 0000000000..c67798b6db --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/UseCase/OnboardingUseCaseModels.swift @@ -0,0 +1,41 @@ +// +// 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 OnboardingUseCaseStateAction { + case viewAction(OnboardingUseCaseViewAction) +} + +enum OnboardingUseCaseViewModelResult { + case personalMessaging + case workMessaging + case communityMessaging + case skipped + case customServer +} + +// MARK: View + +struct OnboardingUseCaseViewState: BindableState { } + +enum OnboardingUseCaseViewAction { + case answer(OnboardingUseCaseViewModelResult) +} diff --git a/RiotSwiftUI/Modules/Onboarding/UseCase/OnboardingUseCaseViewModel.swift b/RiotSwiftUI/Modules/Onboarding/UseCase/OnboardingUseCaseViewModel.swift new file mode 100644 index 0000000000..89a4ae9f12 --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/UseCase/OnboardingUseCaseViewModel.swift @@ -0,0 +1,52 @@ +// +// 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 OnboardingUseCaseViewModelType = StateStoreViewModel +@available(iOS 14, *) +class OnboardingUseCaseViewModel: OnboardingUseCaseViewModelType, OnboardingUseCaseViewModelProtocol { + + // MARK: - Properties + + // MARK: Private + + // MARK: Public + + var completion: ((OnboardingUseCaseViewModelResult) -> Void)? + + // MARK: - Setup + + init() { + super.init(initialViewState: OnboardingUseCaseViewState()) + } + + // MARK: - Public + + override func process(viewAction: OnboardingUseCaseViewAction) { + switch viewAction { + case .answer(let result): + completion?(result) + } + } + + override class func reducer(state: inout OnboardingUseCaseViewState, action: OnboardingUseCaseStateAction) { + // There is no mutable state to reduce :) + } +} diff --git a/RiotSwiftUI/Modules/Onboarding/UseCase/OnboardingUseCaseViewModelProtocol.swift b/RiotSwiftUI/Modules/Onboarding/UseCase/OnboardingUseCaseViewModelProtocol.swift new file mode 100644 index 0000000000..0c535b36c3 --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/UseCase/OnboardingUseCaseViewModelProtocol.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 OnboardingUseCaseViewModelProtocol { + + var completion: ((OnboardingUseCaseViewModelResult) -> Void)? { get set } + @available(iOS 14, *) + var context: OnboardingUseCaseViewModelType.Context { get } +} diff --git a/RiotSwiftUI/Modules/Onboarding/UseCase/Test/UI/OnboardingUseCaseUITests.swift b/RiotSwiftUI/Modules/Onboarding/UseCase/Test/UI/OnboardingUseCaseUITests.swift new file mode 100644 index 0000000000..0f254dfb39 --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/UseCase/Test/UI/OnboardingUseCaseUITests.swift @@ -0,0 +1,23 @@ +// +// 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 OnboardingUseCaseUITests: MockScreenTest { + // The view has no parameters or changing state to test. +} diff --git a/RiotSwiftUI/Modules/Onboarding/UseCase/Test/Unit/OnboardingUseCaseViewModelTests.swift b/RiotSwiftUI/Modules/Onboarding/UseCase/Test/Unit/OnboardingUseCaseViewModelTests.swift new file mode 100644 index 0000000000..9d3883fafb --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/UseCase/Test/Unit/OnboardingUseCaseViewModelTests.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 OnboardingUseCaseViewModelTests: XCTestCase { + // The view model has nothing to test. +} diff --git a/RiotSwiftUI/Modules/Onboarding/UseCase/View/OnboardingUseCase.swift b/RiotSwiftUI/Modules/Onboarding/UseCase/View/OnboardingUseCase.swift new file mode 100644 index 0000000000..dfa0fb21ed --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/UseCase/View/OnboardingUseCase.swift @@ -0,0 +1,132 @@ +// +// 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, *) +/// The screen shown to a new user to select their use case for the app. +struct OnboardingUseCase: View { + + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + // MARK: Public + + @ObservedObject var viewModel: OnboardingUseCaseViewModel.Context + + /// The screen's title and instructions. + var titleContent: some View { + VStack(spacing: 8) { + Image(Asset.Images.onboardingUseCaseIcon.name) + .padding(.bottom, 8) + + Text(VectorL10n.onboardingUseCaseTitle) + .font(theme.fonts.title2B) + .foregroundColor(theme.colors.primaryContent) + + Text(VectorL10n.onboardingUseCaseMessage) + .font(theme.fonts.body) + .foregroundColor(theme.colors.secondaryContent) + } + } + + /// The buttons used to select a use case for the app. + var useCaseButtons: some View { + VStack(spacing: 8) { + OnboardingUseCaseButton(title: VectorL10n.onboardingUseCasePersonalMessaging, + image: theme.isDark ? Asset.Images.onboardingUseCasePersonalDark : Asset.Images.onboardingUseCasePersonal) { + viewModel.send(viewAction: .answer(.personalMessaging)) + } + + OnboardingUseCaseButton(title: VectorL10n.onboardingUseCaseWorkMessaging, + image: theme.isDark ? Asset.Images.onboardingUseCaseWorkDark : Asset.Images.onboardingUseCaseWork) { + viewModel.send(viewAction: .answer(.workMessaging)) + } + + OnboardingUseCaseButton(title: VectorL10n.onboardingUseCaseCommunityMessaging, + image: theme.isDark ? Asset.Images.onboardingUseCaseCommunityDark : Asset.Images.onboardingUseCaseCommunity) { + viewModel.send(viewAction: .answer(.communityMessaging)) + } + + InlineTextButton(VectorL10n.onboardingUseCaseNotSureYet("%@"), + tappableText: VectorL10n.onboardingUseCaseSkipButton) { + viewModel.send(viewAction: .answer(.skipped)) + } + .font(theme.fonts.subheadline) + .foregroundColor(theme.colors.tertiaryContent) + .padding(.top, 8) + } + } + + /// A footer showing a button to connect to a server. + var serverFooter: some View { + VStack(spacing: 14) { + Text(VectorL10n.onboardingUseCaseExistingServerMessage) + .font(theme.fonts.subheadline) + .foregroundColor(theme.colors.tertiaryContent) + + Button { viewModel.send(viewAction: .answer(.customServer)) } label: { + Text(VectorL10n.onboardingUseCaseExistingServerButton) + .font(theme.fonts.body) + } + } + } + + var body: some View { + GeometryReader { geometry in + VStack { + ScrollView { + VStack(spacing: 0) { + titleContent + .padding(.bottom, 36) + + useCaseButtons + } + .frame(maxWidth: OnboardingConstants.maxContentWidth, + maxHeight: OnboardingConstants.maxContentHeight) + .padding(.horizontal, 16) + .padding(.top, 48) + .padding(.bottom, 16) + } + .frame(maxWidth: .infinity) + + serverFooter + .padding(.horizontal, 16) + .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36) + } + } + .background(theme.colors.background.ignoresSafeArea()) + .accentColor(theme.colors.accent) + } +} + +// MARK: - Previews + +@available(iOS 14.0, *) +struct OnboardingUseCase_Previews: PreviewProvider { + static let stateRenderer = MockOnboardingUseCaseScreenState.stateRenderer + static var previews: some View { + NavigationView { + stateRenderer.screenGroup() + .navigationBarTitleDisplayMode(.inline) + } + .navigationViewStyle(StackNavigationViewStyle()) + } +} diff --git a/RiotSwiftUI/Modules/Onboarding/UseCase/View/OnboardingUseCaseButton.swift b/RiotSwiftUI/Modules/Onboarding/UseCase/View/OnboardingUseCaseButton.swift new file mode 100644 index 0000000000..0fedfedb09 --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/UseCase/View/OnboardingUseCaseButton.swift @@ -0,0 +1,59 @@ +// +// 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, *) +/// A button used for the Use Case selection. +struct OnboardingUseCaseButton: View { + + // MARK: Private + + @Environment(\.theme) private var theme + + // MARK: Public + + /// The button's title. + let title: String + /// The button's image. + let image: ImageAsset + + /// The button's action when tapped. + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 16) { + Image(image.name) + Text(title) + .font(theme.fonts.bodySB) + .foregroundColor(theme.colors.primaryContent) + } + .padding(16) + } + .buttonStyle(OnboardingButtonStyle()) + } +} + +@available(iOS 14.0, *) +struct Previews_OnboardingUseCaseButton_Previews: PreviewProvider { + static var previews: some View { + OnboardingUseCaseButton(title: VectorL10n.onboardingUseCaseWorkMessaging, + image: Asset.Images.onboardingUseCaseWork, + action: { }) + .padding(16) + } +} diff --git a/RiotTests/OnboardingTests.swift b/RiotTests/OnboardingTests.swift new file mode 100644 index 0000000000..090bae3d0e --- /dev/null +++ b/RiotTests/OnboardingTests.swift @@ -0,0 +1,87 @@ +// +// 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 Riot + +class OnboardingTests: XCTestCase { + + let userId = "@test:matrix.org" + + override func setUp() { + // Clear any properties for the test + UserSessionProperties(userId: userId).delete() + } + + func testEmptyUseCase() { + // Given an empty set of user properties + let properties = UserSessionProperties(userId: userId) + + // Then the use case property should return nil + XCTAssertNil(properties.useCase, "A use case has not been set") + } + + func testPersonalMessagingUseCase() { + // Given an empty set of user properties + let properties = UserSessionProperties(userId: userId) + + // When storing a use case result of personal messaging + let result = OnboardingUseCaseViewModelResult.personalMessaging + properties.store(useCaseResult: result) + + // Then the use case property should return personal messaging + XCTAssertEqual(properties.useCase, .personalMessaging, "The use case should be Personal Messaging") + } + + func testSkippedUseCase() { + // Given an empty set of user properties + let properties = UserSessionProperties(userId: userId) + + // When storing a skipped use case result + let result = OnboardingUseCaseViewModelResult.skipped + properties.store(useCaseResult: result) + + // Then the use case property should return skipped + XCTAssertEqual(properties.useCase, .skipped) + } + + func testCustomServerUseCase() { + // Given an empty set of user properties + let properties = UserSessionProperties(userId: userId) + + // When storing a custom server case result + let result = OnboardingUseCaseViewModelResult.customServer + properties.store(useCaseResult: result) + + // Then the use case property should return nil + XCTAssertNil(properties.useCase) + } + + func testUseCaseAfterDeletingProperties() { + // Given a set of user properties with the Work Messaging use case + let properties = UserSessionProperties(userId: userId) + let result = OnboardingUseCaseViewModelResult.workMessaging + properties.store(useCaseResult: result) + XCTAssertEqual(properties.useCase, .workMessaging, "The use case should be Work Messaging") + + // When deleting the user properties + properties.delete() + + // Then the use case property should return nil + XCTAssertNil(properties.useCase) + } + +}