From d11b3e0950ca70ede20b9f74c9495758c3ef9e83 Mon Sep 17 00:00:00 2001 From: Doug Date: Wed, 2 Feb 2022 12:24:22 +0000 Subject: [PATCH 1/4] Add the Use Case selection screen to the onboarding flow. --- .../Contents.json | 15 ++ .../onboarding_use_case_community.svg | 5 + .../Contents.json | 15 ++ .../onboarding_use_case_community_dark.svg | 5 + .../Contents.json | 15 ++ .../onboarding_use_case_icon.svg | 4 + .../Contents.json | 15 ++ .../onboarding_use_case_personal.svg | 5 + .../Contents.json | 15 ++ .../onboarding_use_case_personal_dark.svg | 5 + .../Contents.json | 15 ++ .../onboarding_use_case_work.svg | 5 + .../Contents.json | 15 ++ .../onboarding_use_case_work_dark.svg | 5 + Riot/Assets/en.lproj/Vector.strings | 11 ++ Riot/Generated/Images.swift | 7 + Riot/Generated/Strings.swift | 36 +++++ Riot/Managers/UserSessions/UserSession.swift | 2 + .../UserSessions/UserSessionProperties.swift | 80 +++++++++++ .../UserSessions/UserSessionsService.swift | 3 + .../Analytics/PostHogAnalyticsClient.swift | 2 +- .../AuthenticationCoordinator.swift | 4 +- .../AuthenticationCoordinatorProtocol.swift | 2 +- .../Onboarding/OnboardingCoordinator.swift | 66 ++++++++- RiotNSE/target.yml | 1 + RiotShareExtension/target.yml | 1 + .../AnalyticsPromptModels.swift | 39 +++--- .../AnalyticsPromptCoordinator.swift | 4 +- .../Coordinator/AnalyticsPromptStrings.swift | 9 -- .../MockAnalyticsPromptStrings.swift | 15 -- .../View/AnalyticsPrompt.swift | 13 +- .../View/AnalyticsPromptTermsText.swift | 74 ---------- .../Modules/Common/Mock/MockAppScreens.swift | 1 + .../Common/Util/InlineTextButton.swift | 89 ++++++++++++ .../Modules/Common/Util/StyledText.swift | 126 +++++++++++++++++ .../Common/OnboardingButtonStyle.swift | 32 +++++ .../OnboardingUseCaseCoordinator.swift | 60 ++++++++ .../MockOnboardingUseCaseScreenState.swift | 52 +++++++ .../UseCase/OnboardingUseCaseModels.swift | 41 ++++++ .../UseCase/OnboardingUseCaseViewModel.swift | 52 +++++++ .../OnboardingUseCaseViewModelProtocol.swift | 24 ++++ .../Test/UI/OnboardingUseCaseUITests.swift | 23 +++ .../OnboardingUseCaseViewModelTests.swift | 24 ++++ .../UseCase/View/OnboardingUseCase.swift | 132 ++++++++++++++++++ .../View/OnboardingUseCaseButton.swift | 59 ++++++++ RiotTests/OnboardingTests.swift | 87 ++++++++++++ SiriIntents/target.yml | 1 + changelog.d/5160.feature | 1 + 48 files changed, 1178 insertions(+), 134 deletions(-) create mode 100644 Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_community.imageset/Contents.json create mode 100644 Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_community.imageset/onboarding_use_case_community.svg create mode 100644 Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_community_dark.imageset/Contents.json create mode 100644 Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_community_dark.imageset/onboarding_use_case_community_dark.svg create mode 100644 Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_icon.imageset/Contents.json create mode 100644 Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_icon.imageset/onboarding_use_case_icon.svg create mode 100644 Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_personal.imageset/Contents.json create mode 100644 Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_personal.imageset/onboarding_use_case_personal.svg create mode 100644 Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_personal_dark.imageset/Contents.json create mode 100644 Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_personal_dark.imageset/onboarding_use_case_personal_dark.svg create mode 100644 Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_work.imageset/Contents.json create mode 100644 Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_work.imageset/onboarding_use_case_work.svg create mode 100644 Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_work_dark.imageset/Contents.json create mode 100644 Riot/Assets/Images.xcassets/Onboarding/onboarding_use_case_work_dark.imageset/onboarding_use_case_work_dark.svg create mode 100644 Riot/Managers/UserSessions/UserSessionProperties.swift delete mode 100644 RiotSwiftUI/Modules/AnalyticsPrompt/View/AnalyticsPromptTermsText.swift create mode 100644 RiotSwiftUI/Modules/Common/Util/InlineTextButton.swift create mode 100644 RiotSwiftUI/Modules/Common/Util/StyledText.swift create mode 100644 RiotSwiftUI/Modules/Onboarding/Common/OnboardingButtonStyle.swift create mode 100644 RiotSwiftUI/Modules/Onboarding/UseCase/Coordinator/OnboardingUseCaseCoordinator.swift create mode 100644 RiotSwiftUI/Modules/Onboarding/UseCase/MockOnboardingUseCaseScreenState.swift create mode 100644 RiotSwiftUI/Modules/Onboarding/UseCase/OnboardingUseCaseModels.swift create mode 100644 RiotSwiftUI/Modules/Onboarding/UseCase/OnboardingUseCaseViewModel.swift create mode 100644 RiotSwiftUI/Modules/Onboarding/UseCase/OnboardingUseCaseViewModelProtocol.swift create mode 100644 RiotSwiftUI/Modules/Onboarding/UseCase/Test/UI/OnboardingUseCaseUITests.swift create mode 100644 RiotSwiftUI/Modules/Onboarding/UseCase/Test/Unit/OnboardingUseCaseViewModelTests.swift create mode 100644 RiotSwiftUI/Modules/Onboarding/UseCase/View/OnboardingUseCase.swift create mode 100644 RiotSwiftUI/Modules/Onboarding/UseCase/View/OnboardingUseCaseButton.swift create mode 100644 RiotTests/OnboardingTests.swift create mode 100644 changelog.d/5160.feature 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 000000000..9f6e8de0a --- /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 000000000..6b58a7b56 --- /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 000000000..e34cfce4e --- /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 000000000..1fb2f8af1 --- /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 000000000..f4e9e56ac --- /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 000000000..2366becdb --- /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 000000000..1073b4087 --- /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 000000000..efd561c9a --- /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 000000000..081fb3fde --- /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 000000000..b5c728adc --- /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 000000000..40d6dcbdf --- /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 000000000..61cf7bd86 --- /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 000000000..da38b9aad --- /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 000000000..0f6ee387a --- /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 e81838b29..f7ee5c0ca 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 eca6c861e..cec8486d5 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 2a92eec26..ea3ecab07 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -2443,6 +2443,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 786fed88d..fa5b883ac 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 000000000..d7f4b31df --- /dev/null +++ b/Riot/Managers/UserSessions/UserSessionProperties.swift @@ -0,0 +1,80 @@ +// +// 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 + + /// 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 7a68d9752..c5842dfed 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/PostHogAnalyticsClient.swift b/Riot/Modules/Analytics/PostHogAnalyticsClient.swift index 1c7172112..f6db52f36 100644 --- a/Riot/Modules/Analytics/PostHogAnalyticsClient.swift +++ b/Riot/Modules/Analytics/PostHogAnalyticsClient.swift @@ -36,7 +36,7 @@ class PostHogAnalyticsClient: AnalyticsClientProtocol { } func identify(id: String) { - postHog?.identify(id) + postHog?.identify(id, properties: nil) } func reset() { diff --git a/Riot/Modules/Authentication/AuthenticationCoordinator.swift b/Riot/Modules/Authentication/AuthenticationCoordinator.swift index a310cdb1a..521ad52c2 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 7037f9d1f..c90c212a5 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 8a6dcc7c4..b14707c13 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,33 @@ 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.useCase = useCaseResult.userSessionPropertyValue + } + } +} + +extension OnboardingUseCaseViewModelResult { + /// The result converted into the type stored in the user session. + var userSessionPropertyValue: UserSessionProperties.UseCase? { + switch self { + case .personalMessaging: + return .personalMessaging + case .workMessaging: + return .workMessaging + case .communityMessaging: + return .communityMessaging + case .skipped: + return .skipped + case .customServer: + return nil + } } } diff --git a/RiotNSE/target.yml b/RiotNSE/target.yml index 68a86ca51..1f15351ef 100644 --- a/RiotNSE/target.yml +++ b/RiotNSE/target.yml @@ -63,6 +63,7 @@ targets: - path: ../Riot/PropertyWrappers/UserDefaultsBackedPropertyWrapper.swift - path: ../Riot/Modules/MatrixKit - path: ../Riot/Modules/Analytics + - path: ../Riot/Managers/UserSessions - path: ../Riot/Managers/AppInfo/ excludes: - "**/*.md" # excludes all files with the .md extension diff --git a/RiotShareExtension/target.yml b/RiotShareExtension/target.yml index 494407323..945d77d7a 100644 --- a/RiotShareExtension/target.yml +++ b/RiotShareExtension/target.yml @@ -70,6 +70,7 @@ targets: buildPhase: resources - path: ../Riot/Modules/MatrixKit - path: ../Riot/Modules/Analytics + - path: ../Riot/Managers/UserSessions excludes: - "**/*.md" # excludes all files with the .md extension - path: ../Riot/Generated/MatrixKitStrings.swift diff --git a/RiotSwiftUI/Modules/AnalyticsPrompt/AnalyticsPromptModels.swift b/RiotSwiftUI/Modules/AnalyticsPrompt/AnalyticsPromptModels.swift index 2cfdb6e0f..6e4e34f30 100644 --- a/RiotSwiftUI/Modules/AnalyticsPrompt/AnalyticsPromptModels.swift +++ b/RiotSwiftUI/Modules/AnalyticsPrompt/AnalyticsPromptModels.swift @@ -42,18 +42,13 @@ struct AnalyticsPromptViewState: BindableState { /// A collection of strings for the UI that need to be created in /// the coordinator or mocked in the RiotSwiftUI target. protocol AnalyticsPromptStringsProtocol { - var appDisplayName: String { get } - var point1: NSAttributedString { get } var point2: NSAttributedString { get } - - var termsNewUser: NSAttributedString { get } - var termsUpgrade: NSAttributedString { get } } enum AnalyticsPromptType { - case newUser(termsString: NSAttributedString) - case upgrade(termsString: NSAttributedString) + case newUser + case upgrade } extension AnalyticsPromptType { @@ -67,11 +62,23 @@ extension AnalyticsPromptType { } } - /// The terms string that should be displayed. - var termsStrings: NSAttributedString { + /// The main part of the terms string that should be displayed. + var mainTermsString: String { switch self { - case .newUser(let termsString), .upgrade(let termsString): - return termsString + case .newUser: + return VectorL10n.analyticsPromptTermsNewUser("%@") + case .upgrade: + return VectorL10n.analyticsPromptTermsUpgrade("%@") + } + } + + /// The tappable part of the terms string that should be displayed. + var termsLinkString: String { + switch self { + case .newUser: + return VectorL10n.analyticsPromptTermsLinkNewUser + case .upgrade: + return VectorL10n.analyticsPromptTermsLinkUpgrade } } @@ -96,15 +103,7 @@ extension AnalyticsPromptType { } } -extension AnalyticsPromptType: CaseIterable { - static var allCases: [AnalyticsPromptType] { - let strings = MockAnalyticsPromptStrings() - return [ - .newUser(termsString: strings.termsNewUser), - .upgrade(termsString: strings.termsUpgrade) - ] - } -} +extension AnalyticsPromptType: CaseIterable { } extension AnalyticsPromptType: Identifiable { var id: String { diff --git a/RiotSwiftUI/Modules/AnalyticsPrompt/Coordinator/AnalyticsPromptCoordinator.swift b/RiotSwiftUI/Modules/AnalyticsPrompt/Coordinator/AnalyticsPromptCoordinator.swift index 6e107b62b..32884fea0 100644 --- a/RiotSwiftUI/Modules/AnalyticsPrompt/Coordinator/AnalyticsPromptCoordinator.swift +++ b/RiotSwiftUI/Modules/AnalyticsPrompt/Coordinator/AnalyticsPromptCoordinator.swift @@ -52,9 +52,9 @@ final class AnalyticsPromptCoordinator: Coordinator, Presentable { let promptType: AnalyticsPromptType if Analytics.shared.promptShouldDisplayUpgradeMessage { - promptType = .upgrade(termsString: strings.termsUpgrade) + promptType = .upgrade } else { - promptType = .newUser(termsString: strings.termsNewUser) + promptType = .newUser } let viewModel = AnalyticsPromptViewModel(promptType: promptType, strings: strings, termsURL: BuildSettings.analyticsTermsURL) diff --git a/RiotSwiftUI/Modules/AnalyticsPrompt/Coordinator/AnalyticsPromptStrings.swift b/RiotSwiftUI/Modules/AnalyticsPrompt/Coordinator/AnalyticsPromptStrings.swift index 4ce8ab20d..cc653f6d5 100644 --- a/RiotSwiftUI/Modules/AnalyticsPrompt/Coordinator/AnalyticsPromptStrings.swift +++ b/RiotSwiftUI/Modules/AnalyticsPrompt/Coordinator/AnalyticsPromptStrings.swift @@ -18,16 +18,7 @@ import Foundation @available(iOS 14.0, *) struct AnalyticsPromptStrings: AnalyticsPromptStringsProtocol { - let appDisplayName = AppInfo.current.displayName - let point1 = HTMLFormatter().formatHTML(VectorL10n.analyticsPromptPoint1, withAllowedTags: ["b", "p"], fontSize: UIFont.systemFontSize) let point2 = HTMLFormatter().formatHTML(VectorL10n.analyticsPromptPoint2, withAllowedTags: ["b", "p"], fontSize: UIFont.systemFontSize) - - let termsNewUser = HTMLFormatter().format(VectorL10n.analyticsPromptTermsNewUser("%@"), - with: VectorL10n.analyticsPromptTermsLinkNewUser, - using: BuildSettings.analyticsTermsURL) - let termsUpgrade = HTMLFormatter().format(VectorL10n.analyticsPromptTermsUpgrade("%@"), - with: VectorL10n.analyticsPromptTermsLinkUpgrade, - using: BuildSettings.analyticsTermsURL) } diff --git a/RiotSwiftUI/Modules/AnalyticsPrompt/MockAnalyticsPromptStrings.swift b/RiotSwiftUI/Modules/AnalyticsPrompt/MockAnalyticsPromptStrings.swift index ee0e59ed0..37a38e32d 100644 --- a/RiotSwiftUI/Modules/AnalyticsPrompt/MockAnalyticsPromptStrings.swift +++ b/RiotSwiftUI/Modules/AnalyticsPrompt/MockAnalyticsPromptStrings.swift @@ -17,14 +17,9 @@ import UIKit struct MockAnalyticsPromptStrings: AnalyticsPromptStringsProtocol { - var appDisplayName = "Element" - let point1: NSAttributedString let point2: NSAttributedString - let termsNewUser: NSAttributedString - let termsUpgrade: NSAttributedString - let shortString = NSAttributedString(string: "This is a short string.") let longString = NSAttributedString(string: "This is a very long string that will be used to test the layout over multiple lines of text to ensure everything is correct.") @@ -38,15 +33,5 @@ struct MockAnalyticsPromptStrings: AnalyticsPromptStringsProtocol { point2.append(NSAttributedString(string: "don't", attributes: [.font: UIFont.boldSystemFont(ofSize: UIFont.systemFontSize)])) point2.append(NSAttributedString(string: " share information with third parties")) self.point2 = point2 - - let termsNewUser = NSMutableAttributedString(string: "You can read all our terms ") - termsNewUser.append(NSAttributedString(string: "here", attributes: [.link: URL(string: "https://element.io/cookie-policy")!])) - termsNewUser.append(NSAttributedString(string: ".")) - self.termsNewUser = termsNewUser - - let termsUpgrade = NSMutableAttributedString(string: "Read all our terms ") - termsUpgrade.append(NSAttributedString(string: "here", attributes: [.link: URL(string: "https://element.io/cookie-policy")!])) - termsUpgrade.append(NSAttributedString(string: ". Is that OK?")) - self.termsUpgrade = termsUpgrade } } diff --git a/RiotSwiftUI/Modules/AnalyticsPrompt/View/AnalyticsPrompt.swift b/RiotSwiftUI/Modules/AnalyticsPrompt/View/AnalyticsPrompt.swift index 5e622ab5d..178d1d215 100644 --- a/RiotSwiftUI/Modules/AnalyticsPrompt/View/AnalyticsPrompt.swift +++ b/RiotSwiftUI/Modules/AnalyticsPrompt/View/AnalyticsPrompt.swift @@ -42,12 +42,10 @@ struct AnalyticsPrompt: View { VStack { Text("\(viewModel.viewState.promptType.message)\n") - AnalyticsPromptTermsText(attributedString: viewModel.viewState.promptType.termsStrings) - .accessibilityLabel(Text(viewModel.viewState.promptType.termsStrings.string)) - .accessibilityValue(Text(VectorL10n.accessibilityButtonLabel)) - .onTapGesture { - viewModel.send(viewAction: .openTermsURL) - } + InlineTextButton(viewModel.viewState.promptType.mainTermsString, + tappableText: viewModel.viewState.promptType.termsLinkString) { + viewModel.send(viewAction: .openTermsURL) + } } } @@ -71,7 +69,7 @@ struct AnalyticsPrompt: View { Image(uiImage: Asset.Images.analyticsLogo.image) .padding(.bottom, 25) - Text(VectorL10n.analyticsPromptTitle(viewModel.viewState.strings.appDisplayName)) + Text(VectorL10n.analyticsPromptTitle(AppInfo.current.displayName)) .font(theme.fonts.title2B) .foregroundColor(theme.colors.primaryContent) .padding(.bottom, 2) @@ -125,6 +123,7 @@ struct AnalyticsPrompt: View { .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 0 : 16) } .background(theme.colors.background.ignoresSafeArea()) + .accentColor(theme.colors.accent) } } } diff --git a/RiotSwiftUI/Modules/AnalyticsPrompt/View/AnalyticsPromptTermsText.swift b/RiotSwiftUI/Modules/AnalyticsPrompt/View/AnalyticsPromptTermsText.swift deleted file mode 100644 index 7616e1084..000000000 --- a/RiotSwiftUI/Modules/AnalyticsPrompt/View/AnalyticsPromptTermsText.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// 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 last line of text in the description with highlighting on the link string. -struct AnalyticsPromptTermsText: View { - - // MARK: - Properties - - // MARK: Private - - @Environment(\.theme) private var theme - - /// A string with a link attribute. - private struct StringComponent { - let string: String - let isLink: Bool - } - - /// Internal representation of the string as composable parts. - private let components: [StringComponent] - - // MARK: - Setup - - init(attributedString: NSAttributedString) { - var components = [StringComponent]() - let range = NSRange(location: 0, length: attributedString.length) - let string = attributedString.string as NSString - - attributedString.enumerateAttributes(in: range, options: []) { attributes, range, stop in - let isLink = attributes.keys.contains(.link) - components.append(StringComponent(string: string.substring(with: range), isLink: isLink)) - } - - self.components = components - } - - // MARK: - Views - - var body: some View { - components.reduce(Text("")) { - $0 + Text($1.string).foregroundColor($1.isLink ? theme.colors.accent : nil) - } - } -} - -// MARK: - Previews -@available(iOS 14.0, *) -struct AnalyticsPromptTermsText_Previews: PreviewProvider { - - static let strings = MockAnalyticsPromptStrings() - - static var previews: some View { - VStack(spacing: 8) { - AnalyticsPromptTermsText(attributedString: strings.termsNewUser) - AnalyticsPromptTermsText(attributedString: strings.termsUpgrade) - } - } -} diff --git a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift index bf139307f..e2d3d4816 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/Common/Util/InlineTextButton.swift b/RiotSwiftUI/Modules/Common/Util/InlineTextButton.swift new file mode 100644 index 000000000..a2208bdc0 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Util/InlineTextButton.swift @@ -0,0 +1,89 @@ +// +// 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, introduced: 14.0, deprecated: 15.0, message: "Use Text with an AttributedString instead that includes a link and handle the tap by adding an OpenURLAction to the environment.") +/// A `Button`, that fakes having a tappable string inside of a regular string. +struct InlineTextButton: View { + + struct StringComponent { + let string: Substring + let isTinted: Bool + } + + // MARK: - Properties + + // MARK: Private + + /// The individual components of the string. + private let components: [StringComponent] + private let action: () -> Void + + + // MARK: - Setup + + /// Creates a new `InlineTextButton`. + /// - Parameters: + /// - mainText: The main text that shouldn't appear tappable. This must contain a single `%@` placeholder somewhere within. + /// - tappableText: The tappable text that will be substituted into the `%@` placeholder. + /// - action: The action to perform when tapping the button. + internal init(_ mainText: String, tappableText: String, action: @escaping () -> Void) { + guard let range = mainText.range(of: "%@") else { + self.components = [StringComponent(string: Substring(mainText), isTinted: false)] + self.action = action + return + } + + let firstComponent = StringComponent(string: mainText[.. some View { + components.reduce(Text("")) { lastValue, component in + lastValue + Text(component.string) + .foregroundColor(component.isTinted ? .accentColor.opacity(configuration.isPressed ? 0.2 : 1) : nil) + } + } + } +} + +@available(iOS 14.0, *) +struct Previews_InlineButtonText_Previews: PreviewProvider { + static var previews: some View { + InlineTextButton("Hello there this is a sentence. %@.", + tappableText: "And this is a button", + action: { }) + .padding() + } +} diff --git a/RiotSwiftUI/Modules/Common/Util/StyledText.swift b/RiotSwiftUI/Modules/Common/Util/StyledText.swift new file mode 100644 index 000000000..fbb7919f2 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Util/StyledText.swift @@ -0,0 +1,126 @@ +// +// 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 +import DesignKit + +@available(iOS, introduced: 14.0, deprecated: 15.0, message: "Use Text with an AttributedString instead.") +/// A `Text` view that renders attributed strings with their `.font` and `.foregroundColor` attributes. +/// This view is a workaround for iOS 13/14 not supporting `AttributedString`. +struct StyledText: View { + + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme + + /// A string with a bold property. + private struct StringComponent { + let string: String + var font: Font? = nil + var color: Color? = nil + } + + /// Internal representation of the string as composable parts. + private let components: [StringComponent] + + // MARK: - Setup + + /// Creates a `StyledText` using the supplied attributed string. + /// - Parameter attributedString: The attributed string to display. + init(_ attributedString: NSAttributedString) { + var components = [StringComponent]() + let range = NSRange(location: 0, length: attributedString.length) + let string = attributedString.string as NSString + + attributedString.enumerateAttributes(in: range, options: []) { attributes, range, stop in + let font = attributes[.font] as? UIFont + let color = attributes[.foregroundColor] as? UIColor + + let component = StringComponent( + string: string.substring(with: range), + font: font.map { Font($0) }, + color: color.map { Color($0) } + ) + + components.append(component) + } + + self.components = components + } + + /// Creates a `StyledText` using a plain string. + /// - Parameter string: The plain string to display + init(_ string: String) { + self.components = [StringComponent(string: string, font: nil)] + } + + // MARK: - Views + + var body: some View { + components.reduce(Text("")) { lastValue, component in + lastValue + Text(component.string) + .font(component.font) + .foregroundColor(component.color) + } + } +} + + +@available(iOS 14.0, *) +struct StyledText_Previews: PreviewProvider { + static func prettyText() -> NSAttributedString { + let string = NSMutableAttributedString(string: "T", attributes: [ + .font: UIFont.boldSystemFont(ofSize: 12), + .foregroundColor: UIColor.red + ]) + string.append(NSAttributedString(string: "e", attributes: [ + .font: UIFont.boldSystemFont(ofSize: 14), + .foregroundColor: UIColor.orange + ])) + string.append(NSAttributedString(string: "s", attributes: [ + .font: UIFont.boldSystemFont(ofSize: 13), + .foregroundColor: UIColor.yellow + ])) + string.append(NSAttributedString(string: "t", attributes: [ + .font: UIFont.boldSystemFont(ofSize: 15), + .foregroundColor: UIColor.green + ])) + string.append(NSAttributedString(string: "i", attributes: [ + .font: UIFont.boldSystemFont(ofSize: 11), + .foregroundColor: UIColor.cyan + ])) + string.append(NSAttributedString(string: "n", attributes: [ + .font: UIFont.boldSystemFont(ofSize: 16), + .foregroundColor: UIColor.blue + ])) + string.append(NSAttributedString(string: "g", attributes: [ + .font: UIFont.boldSystemFont(ofSize: 14), + .foregroundColor: UIColor.purple + ])) + return string + } + + static var previews: some View { + VStack(spacing: 8) { + StyledText("Hello, World!") + StyledText(NSAttributedString(string: "Testing", + attributes: [.font: UIFont.boldSystemFont(ofSize: 64)])) + StyledText(prettyText()) + } + } +} diff --git a/RiotSwiftUI/Modules/Onboarding/Common/OnboardingButtonStyle.swift b/RiotSwiftUI/Modules/Onboarding/Common/OnboardingButtonStyle.swift new file mode 100644 index 000000000..73a591d86 --- /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 000000000..5265c828e --- /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 000000000..7cf850d9b --- /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 000000000..c67798b6d --- /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 000000000..89a4ae9f1 --- /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 000000000..0c535b36c --- /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 000000000..0f254dfb3 --- /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 000000000..9d3883faf --- /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 000000000..dfa0fb21e --- /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 000000000..0fedfedb0 --- /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 000000000..090bae3d0 --- /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) + } + +} diff --git a/SiriIntents/target.yml b/SiriIntents/target.yml index ca60c3bc1..ddb51c5eb 100644 --- a/SiriIntents/target.yml +++ b/SiriIntents/target.yml @@ -52,6 +52,7 @@ targets: - path: ../Riot/Managers/Locale/LocaleProvider.swift - path: ../Riot/Modules/MatrixKit - path: ../Riot/Modules/Analytics + - path: ../Riot/Managers/UserSessions - path: ../Riot/Managers/AppInfo/ - path: ../Riot/Managers/Locale/LocaleProviderType.swift - path: ../Riot/Generated/Strings.swift diff --git a/changelog.d/5160.feature b/changelog.d/5160.feature new file mode 100644 index 000000000..2d567e205 --- /dev/null +++ b/changelog.d/5160.feature @@ -0,0 +1 @@ +Add Onboarding Use Case selection screen after the splash screen. From 906eb48181ced38edcdbdc8286fc51eb2926527b Mon Sep 17 00:00:00 2001 From: Doug Date: Fri, 4 Feb 2022 16:10:03 +0000 Subject: [PATCH 2/4] Tweaks to the Use Case screen ready for review. Update strings. Show the custom server field as needed. Enable scroll edges appearance for white navigation bar. --- Riot/Assets/en.lproj/Vector.strings | 4 ++-- Riot/Generated/Strings.swift | 4 ++-- Riot/Managers/Theme/Theme.swift | 9 ++++++- Riot/Managers/Theme/Themes/DarkTheme.swift | 24 ++++++++++++------- Riot/Managers/Theme/Themes/DefaultTheme.swift | 16 +++++++++---- .../AuthenticationCoordinator.swift | 4 ++++ .../AuthenticationCoordinatorProtocol.swift | 3 +++ .../AuthenticationViewController.h | 4 ++++ .../AuthenticationViewController.m | 3 ++- .../SwiftUI/VectorHostingController.swift | 6 ++++- .../Onboarding/OnboardingCoordinator.swift | 16 +++++++++---- .../View/OnboardingSplashScreenPage.swift | 1 + .../OnboardingUseCaseCoordinator.swift | 1 + .../UseCase/View/OnboardingUseCase.swift | 5 ++-- 14 files changed, 73 insertions(+), 27 deletions(-) diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index f7ee5c0ca..4b6cb9383 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -96,8 +96,8 @@ "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"; +"onboarding_use_case_work_messaging" = "Teams"; +"onboarding_use_case_community_messaging" = "Communities"; /* 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"; diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index ea3ecab07..840b7c7e9 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -2443,7 +2443,7 @@ public class VectorL10n: NSObject { public static var onboardingSplashRegisterButtonTitle: String { return VectorL10n.tr("Vector", "onboarding_splash_register_button_title") } - /// Online community members + /// Communities public static var onboardingUseCaseCommunityMessaging: String { return VectorL10n.tr("Vector", "onboarding_use_case_community_messaging") } @@ -2475,7 +2475,7 @@ public class VectorL10n: NSObject { public static var onboardingUseCaseTitle: String { return VectorL10n.tr("Vector", "onboarding_use_case_title") } - /// Coworkers and teams + /// Teams public static var onboardingUseCaseWorkMessaging: String { return VectorL10n.tr("Vector", "onboarding_use_case_work_messaging") } diff --git a/Riot/Managers/Theme/Theme.swift b/Riot/Managers/Theme/Theme.swift index 2418cc967..f8e0a382d 100644 --- a/Riot/Managers/Theme/Theme.swift +++ b/Riot/Managers/Theme/Theme.swift @@ -112,11 +112,18 @@ import DesignKit /// - Parameter tabBar: The tab bar to customise. func applyStyle(onTabBar tabBar: UITabBar) - /// Apply the theme on a navigation bar. + /// Apply the theme on a navigation bar, without enabling the iOS 15's scroll edges appearance. /// /// - Parameter navigationBar: the navigation bar to customise. func applyStyle(onNavigationBar navigationBar: UINavigationBar) + /// Apply the theme on a navigation bar. + /// + /// - Parameter navigationBar: the navigation bar to customise. + /// - Parameter modernScrollEdgesAppearance: whether or not to use the iOS 15 style scroll edges appearance + func applyStyle(onNavigationBar navigationBar: UINavigationBar, + withModernScrollEdgesAppearance modernScrollEdgesAppearance: Bool) + /// Apply the theme on a search bar. /// /// - Parameter searchBar: the search bar to customise. diff --git a/Riot/Managers/Theme/Themes/DarkTheme.swift b/Riot/Managers/Theme/Themes/DarkTheme.swift index ebac661c8..c24201cca 100644 --- a/Riot/Managers/Theme/Themes/DarkTheme.swift +++ b/Riot/Managers/Theme/Themes/DarkTheme.swift @@ -112,28 +112,36 @@ class DarkTheme: NSObject, Theme { } } - // Note: We are not using UINavigationBarAppearance on iOS 13/14 because of UINavigationBar directly including UISearchBar on their titleView that cause crop issues with UINavigationController pop. + // Protocols don't support default parameter values and a protocol extension won't work for @objc func applyStyle(onNavigationBar navigationBar: UINavigationBar) { - navigationBar.tintColor = self.tintColor + applyStyle(onNavigationBar: navigationBar, withModernScrollEdgesAppearance: false) + } + + // Note: We are not using UINavigationBarAppearance on iOS 13/14 because of UINavigationBar directly including UISearchBar on their titleView that cause crop issues with UINavigationController pop. + func applyStyle(onNavigationBar navigationBar: UINavigationBar, + withModernScrollEdgesAppearance modernScrollEdgesAppearance: Bool) { + navigationBar.tintColor = tintColor // On iOS 15 use UINavigationBarAppearance to fix visual issues with the scrollEdgeAppearance style. if #available(iOS 15.0, *) { let appearance = UINavigationBarAppearance() appearance.configureWithOpaqueBackground() - appearance.backgroundColor = self.baseColor - appearance.shadowColor = nil + appearance.backgroundColor = baseColor + if !modernScrollEdgesAppearance { + appearance.shadowColor = nil + } appearance.titleTextAttributes = [ - NSAttributedString.Key.foregroundColor: self.textPrimaryColor + NSAttributedString.Key.foregroundColor: textPrimaryColor ] navigationBar.standardAppearance = appearance - navigationBar.scrollEdgeAppearance = appearance + navigationBar.scrollEdgeAppearance = modernScrollEdgesAppearance ? nil : appearance } else { navigationBar.titleTextAttributes = [ - NSAttributedString.Key.foregroundColor: self.textPrimaryColor + NSAttributedString.Key.foregroundColor: textPrimaryColor ] - navigationBar.barTintColor = self.baseColor + navigationBar.barTintColor = baseColor navigationBar.shadowImage = UIImage() // Remove bottom shadow // The navigation bar needs to be opaque so that its background color is the expected one diff --git a/Riot/Managers/Theme/Themes/DefaultTheme.swift b/Riot/Managers/Theme/Themes/DefaultTheme.swift index 2be798d23..a2fe37492 100644 --- a/Riot/Managers/Theme/Themes/DefaultTheme.swift +++ b/Riot/Managers/Theme/Themes/DefaultTheme.swift @@ -118,9 +118,15 @@ class DefaultTheme: NSObject, Theme { } } - // Note: We are not using UINavigationBarAppearance on iOS 13/14 because of UINavigationBar directly including UISearchBar on their titleView that cause crop issues with UINavigationController pop. + // Protocols don't support default parameter values and a protocol extension doesn't work for @objc func applyStyle(onNavigationBar navigationBar: UINavigationBar) { - navigationBar.tintColor = self.tintColor + applyStyle(onNavigationBar: navigationBar, withModernScrollEdgesAppearance: false) + } + + // Note: We are not using UINavigationBarAppearance on iOS 13/14 because of UINavigationBar directly including UISearchBar on their titleView that cause crop issues with UINavigationController pop. + func applyStyle(onNavigationBar navigationBar: UINavigationBar, + withModernScrollEdgesAppearance modernScrollEdgesAppearance: Bool) { + navigationBar.tintColor = tintColor // On iOS 15 use UINavigationBarAppearance to fix visual issues with the scrollEdgeAppearance style. if #available(iOS 15.0, *) { @@ -128,13 +134,15 @@ class DefaultTheme: NSObject, Theme { appearance.configureWithOpaqueBackground() appearance.backgroundColor = baseColor - appearance.shadowColor = nil + if !modernScrollEdgesAppearance { + appearance.shadowColor = nil + } appearance.titleTextAttributes = [ NSAttributedString.Key.foregroundColor: textPrimaryColor ] navigationBar.standardAppearance = appearance - navigationBar.scrollEdgeAppearance = appearance + navigationBar.scrollEdgeAppearance = modernScrollEdgesAppearance ? nil : appearance } else { navigationBar.titleTextAttributes = [ NSAttributedString.Key.foregroundColor: textPrimaryColor diff --git a/Riot/Modules/Authentication/AuthenticationCoordinator.swift b/Riot/Modules/Authentication/AuthenticationCoordinator.swift index 521ad52c2..9cdb59ddb 100644 --- a/Riot/Modules/Authentication/AuthenticationCoordinator.swift +++ b/Riot/Modules/Authentication/AuthenticationCoordinator.swift @@ -62,6 +62,10 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc authenticationViewController.authType = authenticationType } + func showCustomServer() { + authenticationViewController.hideCustomServers(false) + } + func update(externalRegistrationParameters: [AnyHashable: Any]) { authenticationViewController.externalRegistrationParameters = externalRegistrationParameters } diff --git a/Riot/Modules/Authentication/AuthenticationCoordinatorProtocol.swift b/Riot/Modules/Authentication/AuthenticationCoordinatorProtocol.swift index c90c212a5..14a3d1186 100644 --- a/Riot/Modules/Authentication/AuthenticationCoordinatorProtocol.swift +++ b/Riot/Modules/Authentication/AuthenticationCoordinatorProtocol.swift @@ -25,6 +25,9 @@ protocol AuthenticationCoordinatorProtocol: Coordinator, Presentable { /// Update the screen to display registration or login. func update(authenticationType: MXKAuthenticationType) + /// Enable the custom server checkbox to allow the user to enter a homeserver URL. + func showCustomServer() + /// Force a registration process based on a predefined set of parameters from a server provisioning link. /// For more information see `AuthenticationViewController.externalRegistrationParameters`. func update(externalRegistrationParameters: [AnyHashable: Any]) diff --git a/Riot/Modules/Authentication/AuthenticationViewController.h b/Riot/Modules/Authentication/AuthenticationViewController.h index 894ea8b45..6d412400f 100644 --- a/Riot/Modules/Authentication/AuthenticationViewController.h +++ b/Riot/Modules/Authentication/AuthenticationViewController.h @@ -54,6 +54,10 @@ /// returns YES if the SSO login can be continued. - (BOOL)continueSSOLoginWithToken:(NSString*)loginToken txnId:(NSString*)txnId; +/// Hides/shows the custom homeserver field. +/// @param hidden YES to hide, NO to show. +- (void)hideCustomServers:(BOOL)hidden; + @end diff --git a/Riot/Modules/Authentication/AuthenticationViewController.m b/Riot/Modules/Authentication/AuthenticationViewController.m index 4f76a029b..5579bc416 100644 --- a/Riot/Modules/Authentication/AuthenticationViewController.m +++ b/Riot/Modules/Authentication/AuthenticationViewController.m @@ -214,7 +214,8 @@ static const CGFloat kAuthInputContainerViewMinHeightConstraintConstant = 150.0; - (void)userInterfaceThemeDidChange { - [ThemeService.shared.theme applyStyleOnNavigationBar:self.navigationController.navigationBar]; + [ThemeService.shared.theme applyStyleOnNavigationBar:self.navigationController.navigationBar + withModernScrollEdgesAppearance:YES]; self.view.backgroundColor = ThemeService.shared.theme.backgroundColor; diff --git a/Riot/Modules/Common/SwiftUI/VectorHostingController.swift b/Riot/Modules/Common/SwiftUI/VectorHostingController.swift index 677780e4f..8afaaa060 100644 --- a/Riot/Modules/Common/SwiftUI/VectorHostingController.swift +++ b/Riot/Modules/Common/SwiftUI/VectorHostingController.swift @@ -28,6 +28,10 @@ class VectorHostingController: UIHostingController { private var theme: Theme + // MARK: Public + + var enableNavigationBarScrollEdgesAppearance = false + init(rootView: Content) where Content: View { self.theme = ThemeService.shared().theme super.init(rootView: AnyView(rootView.vectorContent())) @@ -67,7 +71,7 @@ class VectorHostingController: UIHostingController { private func update(theme: Theme) { if let navigationBar = self.navigationController?.navigationBar { - theme.applyStyle(onNavigationBar: navigationBar) + theme.applyStyle(onNavigationBar: navigationBar, withModernScrollEdgesAppearance: enableNavigationBarScrollEdgesAppearance) } } } diff --git a/Riot/Modules/Onboarding/OnboardingCoordinator.swift b/Riot/Modules/Onboarding/OnboardingCoordinator.swift index b14707c13..3dd2e3e5c 100644 --- a/Riot/Modules/Onboarding/OnboardingCoordinator.swift +++ b/Riot/Modules/Onboarding/OnboardingCoordinator.swift @@ -132,6 +132,10 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { private func splashScreenCoordinator(_ coordinator: OnboardingSplashScreenCoordinator, didCompleteWith result: OnboardingSplashScreenViewModelResult) { splashScreenResult = result + // Set the auth type early to allow network requests to finish during display of the use case screen. + let mxkAuthenticationType = splashScreenResult == .register ? MXKAuthenticationTypeRegister : MXKAuthenticationTypeLogin + authenticationCoordinator.update(authenticationType: mxkAuthenticationType) + switch result { case .register: showUseCase() @@ -182,13 +186,14 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { // Due to needing to preload the authVC, this breaks the Coordinator init/start pattern. // This can be re-assessed once we re-write a native flow for authentication. - // Set authType first as registration parameters or soft logout credentials will modify this. - let mxkAuthenticationType = splashScreenResult == .register ? MXKAuthenticationTypeRegister : MXKAuthenticationTypeLogin - coordinator.update(authenticationType: mxkAuthenticationType) - if let externalRegistrationParameters = externalRegistrationParameters { coordinator.update(externalRegistrationParameters: externalRegistrationParameters) } + + if useCaseResult == .customServer { + coordinator.showCustomServer() + } + if let softLogoutCredentials = parameters.softLogoutCredentials { coordinator.update(softLogoutCredentials: softLogoutCredentials) } @@ -217,10 +222,11 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { completion?() isShowingAuthentication = false - // Store the chosen use case when appropriate for any default configuration and, if opted in, for analytics. + // Handle the chosen use case if appropriate if authenticationType == MXKAuthenticationTypeRegister, let useCaseResult = useCaseResult, let userSession = UserSessionsService.shared.mainUserSession { + // Store the value in the user's session userSession.properties.useCase = useCaseResult.userSessionPropertyValue } } diff --git a/RiotSwiftUI/Modules/Onboarding/SplashScreen/View/OnboardingSplashScreenPage.swift b/RiotSwiftUI/Modules/Onboarding/SplashScreen/View/OnboardingSplashScreenPage.swift index 53a71d301..9ec08b950 100644 --- a/RiotSwiftUI/Modules/Onboarding/SplashScreen/View/OnboardingSplashScreenPage.swift +++ b/RiotSwiftUI/Modules/Onboarding/SplashScreen/View/OnboardingSplashScreenPage.swift @@ -48,6 +48,7 @@ struct OnboardingSplashScreenPage: View { .scaledToFit() .frame(maxWidth: 300) .padding(20) + .accessibilityHidden(true) VStack(spacing: 8) { OnboardingSplashScreenTitleText(content.title) diff --git a/RiotSwiftUI/Modules/Onboarding/UseCase/Coordinator/OnboardingUseCaseCoordinator.swift b/RiotSwiftUI/Modules/Onboarding/UseCase/Coordinator/OnboardingUseCaseCoordinator.swift index 5265c828e..f91c79861 100644 --- a/RiotSwiftUI/Modules/Onboarding/UseCase/Coordinator/OnboardingUseCaseCoordinator.swift +++ b/RiotSwiftUI/Modules/Onboarding/UseCase/Coordinator/OnboardingUseCaseCoordinator.swift @@ -41,6 +41,7 @@ final class OnboardingUseCaseCoordinator: Coordinator, Presentable { let hostingController = VectorHostingController(rootView: view) hostingController.vc_removeBackTitle() + hostingController.enableNavigationBarScrollEdgesAppearance = true onboardingUseCaseHostingController = hostingController } diff --git a/RiotSwiftUI/Modules/Onboarding/UseCase/View/OnboardingUseCase.swift b/RiotSwiftUI/Modules/Onboarding/UseCase/View/OnboardingUseCase.swift index dfa0fb21e..5431ebd16 100644 --- a/RiotSwiftUI/Modules/Onboarding/UseCase/View/OnboardingUseCase.swift +++ b/RiotSwiftUI/Modules/Onboarding/UseCase/View/OnboardingUseCase.swift @@ -36,6 +36,7 @@ struct OnboardingUseCase: View { VStack(spacing: 8) { Image(Asset.Images.onboardingUseCaseIcon.name) .padding(.bottom, 8) + .accessibilityHidden(true) Text(VectorL10n.onboardingUseCaseTitle) .font(theme.fonts.title2B) @@ -101,9 +102,7 @@ struct OnboardingUseCase: View { } .frame(maxWidth: OnboardingConstants.maxContentWidth, maxHeight: OnboardingConstants.maxContentHeight) - .padding(.horizontal, 16) - .padding(.top, 48) - .padding(.bottom, 16) + .padding(16) } .frame(maxWidth: .infinity) From 8c55168067c46630d8f74892ce0e4dec4d38c46e Mon Sep 17 00:00:00 2001 From: Doug Date: Fri, 4 Feb 2022 17:46:27 +0000 Subject: [PATCH 3/4] Fix tests. --- .../UserSessions/UserSessionProperties.swift | 2 +- .../Analytics/PostHogAnalyticsClient.swift | 2 +- .../Common/Util/InlineTextButton.swift | 4 ++-- .../UseCase/View/OnboardingUseCase.swift | 1 - RiotTests/OnboardingTests.swift | 23 +++++++++++++++---- 5 files changed, 23 insertions(+), 9 deletions(-) diff --git a/Riot/Managers/UserSessions/UserSessionProperties.swift b/Riot/Managers/UserSessions/UserSessionProperties.swift index d7f4b31df..e637aa3fc 100644 --- a/Riot/Managers/UserSessions/UserSessionProperties.swift +++ b/Riot/Managers/UserSessions/UserSessionProperties.swift @@ -34,7 +34,7 @@ class UserSessionProperties: NSObject { /// The underlying dictionary that stores the properties in user defaults. private var dictionary: [String: Any] { didSet { - UserDefaults(suiteName: Constants.suiteName)?.dictionary(forKey: userId) + UserDefaults(suiteName: Constants.suiteName)?.set(dictionary, forKey: userId) } } diff --git a/Riot/Modules/Analytics/PostHogAnalyticsClient.swift b/Riot/Modules/Analytics/PostHogAnalyticsClient.swift index f6db52f36..1c7172112 100644 --- a/Riot/Modules/Analytics/PostHogAnalyticsClient.swift +++ b/Riot/Modules/Analytics/PostHogAnalyticsClient.swift @@ -36,7 +36,7 @@ class PostHogAnalyticsClient: AnalyticsClientProtocol { } func identify(id: String) { - postHog?.identify(id, properties: nil) + postHog?.identify(id) } func reset() { diff --git a/RiotSwiftUI/Modules/Common/Util/InlineTextButton.swift b/RiotSwiftUI/Modules/Common/Util/InlineTextButton.swift index a2208bdc0..1b1924b0b 100644 --- a/RiotSwiftUI/Modules/Common/Util/InlineTextButton.swift +++ b/RiotSwiftUI/Modules/Common/Util/InlineTextButton.swift @@ -20,7 +20,7 @@ import SwiftUI /// A `Button`, that fakes having a tappable string inside of a regular string. struct InlineTextButton: View { - struct StringComponent { + private struct StringComponent { let string: Substring let isTinted: Bool } @@ -66,7 +66,7 @@ struct InlineTextButton: View { .accessibilityLabel(components.map { $0.string }.joined()) } - struct Style: ButtonStyle { + private struct Style: ButtonStyle { let components: [StringComponent] func makeBody(configuration: Configuration) -> some View { diff --git a/RiotSwiftUI/Modules/Onboarding/UseCase/View/OnboardingUseCase.swift b/RiotSwiftUI/Modules/Onboarding/UseCase/View/OnboardingUseCase.swift index 5431ebd16..f0f0ad403 100644 --- a/RiotSwiftUI/Modules/Onboarding/UseCase/View/OnboardingUseCase.swift +++ b/RiotSwiftUI/Modules/Onboarding/UseCase/View/OnboardingUseCase.swift @@ -25,7 +25,6 @@ struct OnboardingUseCase: View { // MARK: Private @Environment(\.theme) private var theme - @Environment(\.horizontalSizeClass) private var horizontalSizeClass // MARK: Public diff --git a/RiotTests/OnboardingTests.swift b/RiotTests/OnboardingTests.swift index 090bae3d0..4c43fd0bd 100644 --- a/RiotTests/OnboardingTests.swift +++ b/RiotTests/OnboardingTests.swift @@ -40,7 +40,7 @@ class OnboardingTests: XCTestCase { // When storing a use case result of personal messaging let result = OnboardingUseCaseViewModelResult.personalMessaging - properties.store(useCaseResult: result) + properties.useCase = result.userSessionPropertyValue // Then the use case property should return personal messaging XCTAssertEqual(properties.useCase, .personalMessaging, "The use case should be Personal Messaging") @@ -52,7 +52,7 @@ class OnboardingTests: XCTestCase { // When storing a skipped use case result let result = OnboardingUseCaseViewModelResult.skipped - properties.store(useCaseResult: result) + properties.useCase = result.userSessionPropertyValue // Then the use case property should return skipped XCTAssertEqual(properties.useCase, .skipped) @@ -64,7 +64,7 @@ class OnboardingTests: XCTestCase { // When storing a custom server case result let result = OnboardingUseCaseViewModelResult.customServer - properties.store(useCaseResult: result) + properties.useCase = result.userSessionPropertyValue // Then the use case property should return nil XCTAssertNil(properties.useCase) @@ -74,7 +74,7 @@ class OnboardingTests: XCTestCase { // 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) + properties.useCase = result.userSessionPropertyValue XCTAssertEqual(properties.useCase, .workMessaging, "The use case should be Work Messaging") // When deleting the user properties @@ -84,4 +84,19 @@ class OnboardingTests: XCTestCase { XCTAssertNil(properties.useCase) } + func testUseCasePersistence() { + // Given a set of user properties with the Personal Messaging use case + var properties: UserSessionProperties? = UserSessionProperties(userId: userId) + let result = OnboardingUseCaseViewModelResult.personalMessaging + properties?.useCase = result.userSessionPropertyValue + XCTAssertEqual(properties?.useCase, .personalMessaging, "The use case should be Personal Messaging") + + // When the app is relaunched and a new user properties instance is creates + properties = nil + let newProperties = UserSessionProperties(userId: userId) + + // Then the use case property should still return Personal Messaging + XCTAssertEqual(newProperties.useCase, .personalMessaging, "The use case should be Personal Messaging") + } + } From 3fd3077fd86164592fbd2295c830062ebb47cd81 Mon Sep 17 00:00:00 2001 From: Doug Date: Thu, 10 Feb 2022 12:59:13 +0000 Subject: [PATCH 4/4] Updates following PR feedback. --- Riot/Managers/Settings/RiotSettings.swift | 8 +++++++- Riot/Managers/UserSessions/UserSession.swift | 16 +++++++-------- .../UserSessions/UserSessionProperties.swift | 20 ++++++++++++------- .../UserSessions/UserSessionsService.swift | 2 +- .../AuthenticationCoordinator.swift | 2 +- .../AuthenticationViewController.h | 6 +++--- .../AuthenticationViewController.m | 16 +++++++-------- .../Onboarding/OnboardingCoordinator.swift | 6 +++--- 8 files changed, 44 insertions(+), 32 deletions(-) diff --git a/Riot/Managers/Settings/RiotSettings.swift b/Riot/Managers/Settings/RiotSettings.swift index 1b1af2591..fdff093ce 100644 --- a/Riot/Managers/Settings/RiotSettings.swift +++ b/Riot/Managers/Settings/RiotSettings.swift @@ -99,7 +99,7 @@ final class RiotSettings: NSObject { @UserDefault(key: "userInterfaceTheme", defaultValue: nil, storage: defaults) var userInterfaceTheme - // MARK: Other + // MARK: Analytics & Rageshakes /// Whether the user was previously shown the Matomo analytics prompt. var hasSeenAnalyticsPrompt: Bool { @@ -130,6 +130,12 @@ final class RiotSettings: NSObject { @UserDefault(key: "enableRageShake", defaultValue: false, storage: defaults) var enableRageShake + // MARK: User + + /// A dictionary of dictionaries keyed by user ID for storage of the `UserSessionProperties` from any active `UserSession`s. + @UserDefault(key: "userSessionProperties", defaultValue: [:], storage: defaults) + var userSessionProperties: [String: [String: Any]] + // MARK: Labs /// Indicates if CallKit ringing is enabled for group calls. This setting does not disable the CallKit integration for group calls, only relates to ringing. diff --git a/Riot/Managers/UserSessions/UserSession.swift b/Riot/Managers/UserSessions/UserSession.swift index fa5b883ac..8795812e6 100644 --- a/Riot/Managers/UserSessions/UserSession.swift +++ b/Riot/Managers/UserSessions/UserSession.swift @@ -33,21 +33,21 @@ 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 + let userId: String /// 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 { - fatalError("[UserSession] identifier: account.mxCredentials.userId should be defined") - } - return userId - } + let userProperties: UserSessionProperties // MARK: - Setup init(account: MXKAccount, matrixSession: MXSession) { + guard let userId = account.mxCredentials.userId else { + fatalError("[UserSession] identifier: account.mxCredentials.userId should be defined") + } + self.account = account self.matrixSession = matrixSession + self.userId = userId + self.userProperties = UserSessionProperties(userId: userId) super.init() } } diff --git a/Riot/Managers/UserSessions/UserSessionProperties.swift b/Riot/Managers/UserSessions/UserSessionProperties.swift index e637aa3fc..4bac87579 100644 --- a/Riot/Managers/UserSessions/UserSessionProperties.swift +++ b/Riot/Managers/UserSessions/UserSessionProperties.swift @@ -21,7 +21,6 @@ class UserSessionProperties: NSObject { // MARK: - Constants private enum Constants { - static let suiteName = BuildSettings.baseBundleIdentifier + ".UserSession" static let useCaseKey = "useCase" } @@ -31,10 +30,16 @@ class UserSessionProperties: NSObject { /// The user ID for these properties private let userId: String - /// The underlying dictionary that stores the properties in user defaults. + + /// The underlying dictionary for this userId from user defaults. private var dictionary: [String: Any] { - didSet { - UserDefaults(suiteName: Constants.suiteName)?.set(dictionary, forKey: userId) + get { + RiotSettings.shared.userSessionProperties[userId] ?? [:] + } + set { + var sharedProperties = RiotSettings.shared.userSessionProperties + sharedProperties[userId] = newValue + RiotSettings.shared.userSessionProperties = sharedProperties } } @@ -65,8 +70,6 @@ class UserSessionProperties: NSObject { /// - 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() } @@ -75,6 +78,9 @@ class UserSessionProperties: NSObject { /// Clear all of the stored properties. func delete() { dictionary = [:] - UserDefaults(suiteName: Constants.suiteName)?.removeObject(forKey: userId) + + var sharedProperties = RiotSettings.shared.userSessionProperties + sharedProperties[userId] = nil + RiotSettings.shared.userSessionProperties = sharedProperties } } diff --git a/Riot/Managers/UserSessions/UserSessionsService.swift b/Riot/Managers/UserSessions/UserSessionsService.swift index c5842dfed..c94497161 100644 --- a/Riot/Managers/UserSessions/UserSessionsService.swift +++ b/Riot/Managers/UserSessions/UserSessionsService.swift @@ -132,7 +132,7 @@ class UserSessionsService: NSObject { } // Clear any stored user properties from this session. - userSession.properties.delete() + userSession.userProperties.delete() self.userSessions.removeAll { (userSession) -> Bool in return userId == userSession.userId diff --git a/Riot/Modules/Authentication/AuthenticationCoordinator.swift b/Riot/Modules/Authentication/AuthenticationCoordinator.swift index 9cdb59ddb..e2ba6ee2e 100644 --- a/Riot/Modules/Authentication/AuthenticationCoordinator.swift +++ b/Riot/Modules/Authentication/AuthenticationCoordinator.swift @@ -63,7 +63,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc } func showCustomServer() { - authenticationViewController.hideCustomServers(false) + authenticationViewController.setCustomServerFieldsVisible(true) } func update(externalRegistrationParameters: [AnyHashable: Any]) { diff --git a/Riot/Modules/Authentication/AuthenticationViewController.h b/Riot/Modules/Authentication/AuthenticationViewController.h index 6d412400f..f3699125d 100644 --- a/Riot/Modules/Authentication/AuthenticationViewController.h +++ b/Riot/Modules/Authentication/AuthenticationViewController.h @@ -54,9 +54,9 @@ /// returns YES if the SSO login can be continued. - (BOOL)continueSSOLoginWithToken:(NSString*)loginToken txnId:(NSString*)txnId; -/// Hides/shows the custom homeserver field. -/// @param hidden YES to hide, NO to show. -- (void)hideCustomServers:(BOOL)hidden; +/// Show or hide the custom server textfields. +/// @param isVisible YES to show, NO to hide. +- (void)setCustomServerFieldsVisible:(BOOL)isVisible; @end diff --git a/Riot/Modules/Authentication/AuthenticationViewController.m b/Riot/Modules/Authentication/AuthenticationViewController.m index 5579bc416..0ff35511e 100644 --- a/Riot/Modules/Authentication/AuthenticationViewController.m +++ b/Riot/Modules/Authentication/AuthenticationViewController.m @@ -160,7 +160,7 @@ static const CGFloat kAuthInputContainerViewMinHeightConstraintConstant = 150.0; } self.serverOptionsContainer.hidden = !BuildSettings.authScreenShowCustomServerOptions; - [self hideCustomServers:YES]; + [self setCustomServerFieldsVisible:NO]; // Soft logout section self.softLogoutClearDataButton.layer.cornerRadius = 5; @@ -888,7 +888,7 @@ static const CGFloat kAuthInputContainerViewMinHeightConstraintConstant = 150.0; { if (sender == self.customServersTickButton) { - [self hideCustomServers:!self.customServersContainer.hidden]; + [self setCustomServerFieldsVisible:self.customServersContainer.hidden]; } else if (sender == self.forgotPasswordButton) { @@ -1236,14 +1236,14 @@ static const CGFloat kAuthInputContainerViewMinHeightConstraintConstant = 150.0; [self.view layoutIfNeeded]; } -- (void)hideCustomServers:(BOOL)hidden +- (void)setCustomServerFieldsVisible:(BOOL)isVisible { - if (self.customServersContainer.isHidden == hidden) + if (self.customServersContainer.isHidden != isVisible) { return; } - if (hidden) + if (!isVisible) { [self.homeServerTextField resignFirstResponder]; [self.identityServerTextField resignFirstResponder]; @@ -1361,7 +1361,7 @@ static const CGFloat kAuthInputContainerViewMinHeightConstraintConstant = 150.0; [self.authenticationActivityIndicator startAnimating]; // Hide the custom server details in order to save customized inputs - [self hideCustomServers:YES]; + [self setCustomServerFieldsVisible:NO]; MXKAccount *account = [[MXKAccountManager sharedManager] accountForUserId:userId]; MXSession *session = account.mxSession; @@ -1586,7 +1586,7 @@ static const CGFloat kAuthInputContainerViewMinHeightConstraintConstant = 150.0; { // wellKnown matches with application default servers // Hide custom servers - [self hideCustomServers:YES]; + [self setCustomServerFieldsVisible:NO]; } else { @@ -1618,7 +1618,7 @@ static const CGFloat kAuthInputContainerViewMinHeightConstraintConstant = 150.0; } // And show custom servers - [self hideCustomServers:NO]; + [self setCustomServerFieldsVisible:YES]; } #pragma mark - KeyVerificationCoordinatorBridgePresenterDelegate diff --git a/Riot/Modules/Onboarding/OnboardingCoordinator.swift b/Riot/Modules/Onboarding/OnboardingCoordinator.swift index 3dd2e3e5c..571140f43 100644 --- a/Riot/Modules/Onboarding/OnboardingCoordinator.swift +++ b/Riot/Modules/Onboarding/OnboardingCoordinator.swift @@ -138,7 +138,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { switch result { case .register: - showUseCase() + showUseCaseSelectionScreen() case .login: showAuthenticationScreen() } @@ -146,7 +146,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { @available(iOS 14.0, *) /// Show the use case screen for new users. - private func showUseCase() { + private func showUseCaseSelectionScreen() { let coordinator = OnboardingUseCaseCoordinator() coordinator.completion = { [weak self, weak coordinator] result in guard let self = self, let coordinator = coordinator else { return } @@ -227,7 +227,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { let useCaseResult = useCaseResult, let userSession = UserSessionsService.shared.mainUserSession { // Store the value in the user's session - userSession.properties.useCase = useCaseResult.userSessionPropertyValue + userSession.userProperties.useCase = useCaseResult.userSessionPropertyValue } } }