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)
+ }
+
+}