Merge pull request #5861 from vector-im/doug/5652_ftue_personalisation

FTUE personalisation screens
This commit is contained in:
Doug 2022-03-21 18:54:11 +00:00 committed by GitHub
commit d10c387460
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
49 changed files with 1981 additions and 147 deletions

View file

@ -369,7 +369,7 @@ final class BuildSettings: NSObject {
static let authEnableRefreshTokens = false
// MARK: - Onboarding
static let onboardingShowAccountPersonalisation = false
static let onboardingShowAccountPersonalization = false
// MARK: - Unified Search
static let unifiedSearchScreenShowPublicDirectory = true

View file

@ -110,10 +110,10 @@ extension ElementFonts: Fonts {
public var title2: SharedFont {
let uiFont = self.font(forTextStyle: .title2)
if #available(iOS 13.0, *) {
return SharedFont(uiFont: uiFont, font: Font(uiFont))
} else if #available(iOS 14.0, *) {
if #available(iOS 14.0, *) {
return SharedFont(uiFont: uiFont, font: .title2)
} else if #available(iOS 13.0, *) {
return SharedFont(uiFont: uiFont, font: Font(uiFont))
} else {
return SharedFont(uiFont: uiFont)
}
@ -122,10 +122,10 @@ extension ElementFonts: Fonts {
public var title2B: SharedFont {
let uiFont = self.title2.uiFont.vc_bold
if #available(iOS 13.0, *) {
return SharedFont(uiFont: uiFont, font: Font(uiFont))
} else if #available(iOS 14.0, *) {
if #available(iOS 14.0, *) {
return SharedFont(uiFont: uiFont, font: .title2.bold())
} else if #available(iOS 13.0, *) {
return SharedFont(uiFont: uiFont, font: Font(uiFont))
} else {
return SharedFont(uiFont: uiFont)
}
@ -134,10 +134,10 @@ extension ElementFonts: Fonts {
public var title3: SharedFont {
let uiFont = self.font(forTextStyle: .title3)
if #available(iOS 13.0, *) {
return SharedFont(uiFont: uiFont, font: Font(uiFont))
} else if #available(iOS 14.0, *) {
if #available(iOS 14.0, *) {
return SharedFont(uiFont: uiFont, font: .title3)
} else if #available(iOS 13.0, *) {
return SharedFont(uiFont: uiFont, font: Font(uiFont))
} else {
return SharedFont(uiFont: uiFont)
}
@ -146,10 +146,10 @@ extension ElementFonts: Fonts {
public var title3SB: SharedFont {
let uiFont = self.title3.uiFont.vc_semiBold
if #available(iOS 13.0, *) {
return SharedFont(uiFont: uiFont, font: Font(uiFont))
} else if #available(iOS 14.0, *) {
if #available(iOS 14.0, *) {
return SharedFont(uiFont: uiFont, font: .title3.weight(.semibold))
} else if #available(iOS 13.0, *) {
return SharedFont(uiFont: uiFont, font: Font(uiFont))
} else {
return SharedFont(uiFont: uiFont)
}
@ -258,10 +258,10 @@ extension ElementFonts: Fonts {
public var caption2: SharedFont {
let uiFont = self.font(forTextStyle: .caption2)
if #available(iOS 13.0, *) {
return SharedFont(uiFont: uiFont, font: Font(uiFont))
} else if #available(iOS 14.0, *) {
if #available(iOS 14.0, *) {
return SharedFont(uiFont: uiFont, font: .caption2)
} else if #available(iOS 13.0, *) {
return SharedFont(uiFont: uiFont, font: Font(uiFont))
} else {
return SharedFont(uiFont: uiFont)
}
@ -270,10 +270,10 @@ extension ElementFonts: Fonts {
public var caption2SB: SharedFont {
let uiFont = self.caption2.uiFont.vc_semiBold
if #available(iOS 13.0, *) {
return SharedFont(uiFont: uiFont, font: Font(uiFont))
} else if #available(iOS 14.0, *) {
if #available(iOS 14.0, *) {
return SharedFont(uiFont: uiFont, font: .caption2.weight(.semibold))
} else if #available(iOS 13.0, *) {
return SharedFont(uiFont: uiFont, font: Font(uiFont))
} else {
return SharedFont(uiFont: uiFont)
}

View file

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "onboarding_avatar_camera.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View file

@ -0,0 +1,4 @@
<svg width="23" height="19" viewBox="0 0 23 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.26126 0.625C7.93955 0.625 6.8205 1.56156 6.56548 2.83072C6.52727 3.02086 6.45364 3.20457 6.32574 3.35036L5.77376 3.97953C5.59971 4.17792 5.3486 4.29167 5.08469 4.29167H2.16659C1.15406 4.29167 0.333252 5.11248 0.333252 6.125V16.2083C0.333252 17.2209 1.15406 18.0417 2.16658 18.0417H20.4999C21.5124 18.0417 22.3333 17.2209 22.3333 16.2083V6.125C22.3333 5.11248 21.5124 4.29167 20.4999 4.29167H17.5818C17.3179 4.29167 17.0668 4.17792 16.8927 3.97953L16.3408 3.35036C16.2129 3.20457 16.1392 3.02086 16.101 2.83071C15.846 1.56156 14.727 0.625 13.4052 0.625H9.26126ZM14.9999 10.7083C14.9999 12.7333 13.3583 14.3749 11.3333 14.3749C9.30821 14.3749 7.66659 12.7333 7.66659 10.7083C7.66659 8.68321 9.30821 7.04159 11.3333 7.04159C13.3583 7.04159 14.9999 8.68321 14.9999 10.7083Z" fill="#737D8C"/>
<path d="M2.62492 2.91659C2.37179 2.91659 2.16659 3.12179 2.16659 3.37492C2.16659 3.62805 2.37179 3.83325 2.62492 3.83325H4.45825C4.71138 3.83325 4.91659 3.62805 4.91659 3.37492C4.91659 3.12179 4.71138 2.91659 4.45825 2.91659H2.62492Z" fill="#737D8C"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "onboarding_avatar_edit.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View file

@ -0,0 +1,4 @@
<svg width="22" height="21" viewBox="0 0 22 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.63013 13.0435C3.64248 12.9655 3.67763 12.8929 3.73116 12.8349L15.1405 0.462333C15.2903 0.299929 15.5434 0.289681 15.7058 0.439442L18.0582 2.60876C18.2206 2.75852 18.2309 3.01158 18.0811 3.17399L6.67174 15.5465C6.6182 15.6046 6.54868 15.6455 6.47192 15.6641L3.66863 16.3437C3.39123 16.411 3.13469 16.1744 3.1793 15.8925L3.63013 13.0435Z" fill="#737D8C"/>
<path d="M1.83301 17.2204C1.00458 17.2204 0.333008 17.892 0.333008 18.7204C0.333008 19.5488 1.00458 20.2204 1.83301 20.2204L19.833 20.2204C20.6614 20.2204 21.333 19.5488 21.333 18.7204C21.333 17.892 20.6614 17.2204 19.833 17.2204L1.83301 17.2204Z" fill="#737D8C"/>
</svg>

After

Width:  |  Height:  |  Size: 734 B

View file

@ -16,8 +16,23 @@
/** These strings will be ignored by Weblate. Useful for WIP **/
// MARK: Onboarding Personalisation WIP
// MARK: Onboarding Personalization WIP
"onboarding_congratulations_title" = "Congratulations!";
"onboarding_congratulations_message" = "Your account\n%@\nhas been created.";
"onboarding_congratulations_personalise_button" = "Personalise profile";
"onboarding_congratulations_message" = "Your account %@ has been created.";
"onboarding_congratulations_personalize_button" = "Personalise profile";
"onboarding_congratulations_home_button" = "Take me home";
"onboarding_personalization_save" = "Save and continue";
"onboarding_personalization_skip" = "Skip this step";
"onboarding_display_name_title" = "Choose a display name";
"onboarding_display_name_message" = "This will be shown when you send messages.";
"onboarding_display_name_placeholder" = "Display Name";
"onboarding_display_name_hint" = "You can change this later";
"onboarding_display_name_max_length" = "Your display name must be less than 256 characters";
"onboarding_avatar_title" = "Add a profile picture";
"onboarding_avatar_message" = "You can change this anytime.";
"onboarding_avatar_accessibility_label" = "Profile picture";
"image_picker_action_files" = "Choose from files";

View file

@ -57,7 +57,6 @@
"rename" = "Rename";
"collapse" = "collapse";
"send_to" = "Send to %@";
"sending" = "Sending";
"close" = "Close";
"skip" = "Skip";
"joined" = "Joined";
@ -75,6 +74,12 @@
"ok" = "OK";
"error" = "Error";
"suggest" = "Suggest";
"edit" = "Edit";
// Activities
"loading" = "Loading";
"sending" = "Sending";
"saving" = "Saving";
// Call Bar
"callbar_only_single_active" = "Tap to return to the call (%@)";

View file

@ -124,6 +124,8 @@ internal class Asset: NSObject {
internal static let onboardingSplashScreenPage3Dark = ImageAsset(name: "OnboardingSplashScreenPage3Dark")
internal static let onboardingSplashScreenPage4 = ImageAsset(name: "OnboardingSplashScreenPage4")
internal static let onboardingSplashScreenPage4Dark = ImageAsset(name: "OnboardingSplashScreenPage4Dark")
internal static let onboardingAvatarCamera = ImageAsset(name: "onboarding_avatar_camera")
internal static let onboardingAvatarEdit = ImageAsset(name: "onboarding_avatar_edit")
internal static let onboardingCongratulationsIcon = ImageAsset(name: "onboarding_congratulations_icon")
internal static let onboardingUseCaseCommunity = ImageAsset(name: "onboarding_use_case_community")
internal static let onboardingUseCaseCommunityDark = ImageAsset(name: "onboarding_use_case_community_dark")

View file

@ -1651,6 +1651,10 @@ public class VectorL10n: NSObject {
public static var e2eRoomKeyRequestTitle: String {
return VectorL10n.tr("Vector", "e2e_room_key_request_title")
}
/// Edit
public static var edit: String {
return VectorL10n.tr("Vector", "edit")
}
/// Activities
public static var emojiPickerActivityCategory: String {
return VectorL10n.tr("Vector", "emoji_picker_activity_category")
@ -2711,6 +2715,10 @@ public class VectorL10n: NSObject {
public static var liveLocationSharingBannerTitle: String {
return VectorL10n.tr("Vector", "live_location_sharing_banner_title")
}
/// Loading
public static var loading: String {
return VectorL10n.tr("Vector", "loading")
}
/// To discover contacts already using Matrix, %@ can send email addresses and phone numbers in your address book to your chosen Matrix identity server. Where supported, personal data is hashed before sending - please check your identity server's privacy policy for more details.
public static func localContactsAccessDiscoveryWarning(_ p1: String) -> String {
return VectorL10n.tr("Vector", "local_contacts_access_discovery_warning", p1)
@ -5731,6 +5739,10 @@ public class VectorL10n: NSObject {
public static var save: String {
return VectorL10n.tr("Vector", "save")
}
/// Saving
public static var saving: String {
return VectorL10n.tr("Vector", "saving")
}
/// Search
public static var searchDefaultPlaceholder: String {
return VectorL10n.tr("Vector", "search_default_placeholder")

View file

@ -10,22 +10,66 @@ import Foundation
// swiftlint:disable function_parameter_count identifier_name line_length type_body_length
public extension VectorL10n {
/// Choose from files
static var imagePickerActionFiles: String {
return VectorL10n.tr("Untranslated", "image_picker_action_files")
}
/// Profile picture
static var onboardingAvatarAccessibilityLabel: String {
return VectorL10n.tr("Untranslated", "onboarding_avatar_accessibility_label")
}
/// You can change this anytime.
static var onboardingAvatarMessage: String {
return VectorL10n.tr("Untranslated", "onboarding_avatar_message")
}
/// Add a profile picture
static var onboardingAvatarTitle: String {
return VectorL10n.tr("Untranslated", "onboarding_avatar_title")
}
/// Take me home
static var onboardingCongratulationsHomeButton: String {
return VectorL10n.tr("Untranslated", "onboarding_congratulations_home_button")
}
/// Your account\n%@\nhas been created.
/// Your account %@ has been created.
public static func onboardingCongratulationsMessage(_ p1: String) -> String {
return VectorL10n.tr("Untranslated", "onboarding_congratulations_message", p1)
}
/// Personalise profile
static var onboardingCongratulationsPersonaliseButton: String {
return VectorL10n.tr("Untranslated", "onboarding_congratulations_personalise_button")
static var onboardingCongratulationsPersonalizeButton: String {
return VectorL10n.tr("Untranslated", "onboarding_congratulations_personalize_button")
}
/// Congratulations!
static var onboardingCongratulationsTitle: String {
return VectorL10n.tr("Untranslated", "onboarding_congratulations_title")
}
/// You can change this later
static var onboardingDisplayNameHint: String {
return VectorL10n.tr("Untranslated", "onboarding_display_name_hint")
}
/// Your display name must be less than 256 characters
static var onboardingDisplayNameMaxLength: String {
return VectorL10n.tr("Untranslated", "onboarding_display_name_max_length")
}
/// This will be shown when you send messages.
static var onboardingDisplayNameMessage: String {
return VectorL10n.tr("Untranslated", "onboarding_display_name_message")
}
/// Display Name
static var onboardingDisplayNamePlaceholder: String {
return VectorL10n.tr("Untranslated", "onboarding_display_name_placeholder")
}
/// Choose a display name
static var onboardingDisplayNameTitle: String {
return VectorL10n.tr("Untranslated", "onboarding_display_name_title")
}
/// Save and continue
static var onboardingPersonalizationSave: String {
return VectorL10n.tr("Untranslated", "onboarding_personalization_save")
}
/// Skip this step
static var onboardingPersonalizationSkip: String {
return VectorL10n.tr("Untranslated", "onboarding_personalization_skip")
}
}
// swiftlint:enable function_parameter_count identifier_name line_length type_body_length

View file

@ -19,7 +19,7 @@ import UIKit
import AVFoundation
@objc protocol CameraPresenterDelegate: AnyObject {
func cameraPresenter(_ presenter: CameraPresenter, didSelectImageData imageData: Data, withUTI uti: MXKUTI?)
func cameraPresenter(_ presenter: CameraPresenter, didSelectImage image: UIImage)
func cameraPresenter(_ presenter: CameraPresenter, didSelectVideoAt url: URL)
func cameraPresenterDidCancel(_ cameraPresenter: CameraPresenter)
}
@ -27,12 +27,6 @@ import AVFoundation
/// CameraPresenter enables to present native camera
@objc final class CameraPresenter: NSObject {
// MARK: - Constants
private enum Constants {
static let jpegCompressionQuality: CGFloat = 1.0
}
// MARK: - Properties
// MARK: Private
@ -131,8 +125,8 @@ extension CameraPresenter: UIImagePickerControllerDelegate {
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
if let videoURL = info[.mediaURL] as? URL {
self.delegate?.cameraPresenter(self, didSelectVideoAt: videoURL)
} else if let image = (info[.editedImage] ?? info[.originalImage]) as? UIImage, let imageData = image.jpegData(compressionQuality: Constants.jpegCompressionQuality) {
self.delegate?.cameraPresenter(self, didSelectImageData: imageData, withUTI: MXKUTI.jpeg)
} else if let image = (info[.editedImage] ?? info[.originalImage]) as? UIImage {
self.delegate?.cameraPresenter(self, didSelectImage: image)
}
}

View file

@ -27,6 +27,12 @@ import AVFoundation
@objcMembers
final class SingleImagePickerPresenter: NSObject {
// MARK: - Constants
private enum Constants {
static let jpegCompressionQuality: CGFloat = 1.0
}
// MARK: - Properties
// MARK: Private
@ -117,8 +123,10 @@ final class SingleImagePickerPresenter: NSObject {
// MARK: - CameraPresenterDelegate
extension SingleImagePickerPresenter: CameraPresenterDelegate {
func cameraPresenter(_ cameraPresenter: CameraPresenter, didSelectImageData imageData: Data, withUTI uti: MXKUTI?) {
self.delegate?.singleImagePickerPresenter(self, didSelectImageData: imageData, withUTI: uti)
func cameraPresenter(_ cameraPresenter: CameraPresenter, didSelectImage image: UIImage) {
if let imageData = image.jpegData(compressionQuality: Constants.jpegCompressionQuality) {
self.delegate?.singleImagePickerPresenter(self, didSelectImageData: imageData, withUTI: MXKUTI.jpeg)
}
}
func cameraPresenterDidCancel(_ cameraPresenter: CameraPresenter) {

View file

@ -0,0 +1,110 @@
//
// Copyright 2022 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 UIKit
import PhotosUI
import CommonKit
@available(iOS 14.0, *)
protocol MediaPickerPresenterDelegate: AnyObject {
func mediaPickerPresenter(_ presenter: MediaPickerPresenter, didPickImage image: UIImage)
func mediaPickerPresenterDidCancel(_ presenter: MediaPickerPresenter)
}
/// A picker for photos and videos from the user's photo library on iOS 14+ using the
/// new `PHPickerViewController` that doesn't require permission to be granted.
///
/// **Note:** If you need to support iOS 12 & 13, then you will need to use the older
/// `MediaPickerCoordinator`/`MediaPickerViewController` instead.
@available(iOS 14.0, *)
final class MediaPickerPresenter: NSObject {
// MARK: - Properties
// MARK: Private
private weak var pickerViewController: UIViewController?
private var indicatorPresenter: UserIndicatorTypePresenterProtocol?
private var loadingIndicator: UserIndicator?
// MARK: Public
weak var delegate: MediaPickerPresenterDelegate?
// MARK: - Public
// TODO: Support videos and multi-selection
func presentPicker(from presentingViewController: UIViewController, with filter: PHPickerFilter?, animated: Bool) {
var configuration = PHPickerConfiguration(photoLibrary: .shared())
configuration.selectionLimit = 1
configuration.filter = filter
let pickerViewController = PHPickerViewController(configuration: configuration)
pickerViewController.delegate = self
self.pickerViewController = pickerViewController
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: pickerViewController)
presentingViewController.present(pickerViewController, animated: true, completion: nil)
}
func dismiss(animated: Bool, completion: (() -> Void)?) {
guard let pickerViewController = pickerViewController else { return }
pickerViewController.dismiss(animated: animated, completion: completion)
}
// MARK: - Private
func showLoadingIndicator() {
loadingIndicator = indicatorPresenter?.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true))
}
func hideLoadingIndicator() {
loadingIndicator = nil
}
}
// MARK: - PHPickerViewControllerDelegate
@available(iOS 14, *)
extension MediaPickerPresenter: PHPickerViewControllerDelegate {
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
// TODO: Handle videos and multi-selection
guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else {
self.delegate?.mediaPickerPresenterDidCancel(self)
return
}
showLoadingIndicator()
provider.loadObject(ofClass: UIImage.self) { [weak self] image, error in
guard let self = self else { return }
guard let image = image as? UIImage else {
DispatchQueue.main.async {
self.hideLoadingIndicator()
self.delegate?.mediaPickerPresenterDidCancel(self)
}
return
}
DispatchQueue.main.async {
self.hideLoadingIndicator()
self.delegate?.mediaPickerPresenter(self, didPickImage: image)
}
}
}
}

View file

@ -65,6 +65,9 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
private var authenticationType: MXKAuthenticationType?
private var session: MXSession?
private var shouldShowDisplayNameScreen = false
private var shouldShowAvatarScreen = false
/// Whether all of the onboarding steps have been completed or not. `false` if there are more screens to be shown.
private var onboardingFinished = false
/// Whether authentication is complete. `true` once authenticated, verified and the app is ready to be shown.
@ -182,6 +185,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
}
/// Displays the next view in the flow after the use case screen.
@available(iOS 14.0, *)
private func useCaseSelectionCoordinator(_ coordinator: OnboardingUseCaseSelectionCoordinator, didCompleteWith result: OnboardingUseCaseViewModelResult) {
useCaseResult = result
showAuthenticationScreen()
@ -247,12 +251,13 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
self.session = session
self.authenticationType = authenticationType
// May need to move the spinner and key verification up to here in order to coordinate properly.
// Check whether another screen should be shown.
if #available(iOS 14.0, *) {
if authenticationType == .register, let userId = session.credentials.userId, BuildSettings.onboardingShowAccountPersonalisation {
showCongratulationsScreen(userId: userId)
if authenticationType == .register,
let userId = session.credentials.userId,
let userSession = UserSessionsService.shared.userSession(withUserId: userId),
BuildSettings.onboardingShowAccountPersonalization {
checkHomeserverCapabilities(for: userSession)
return
} else if Analytics.shared.shouldShowAnalyticsPrompt {
showAnalyticsPrompt(for: session)
@ -265,6 +270,24 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
completeIfReady()
}
/// Checks the capabilities of the user's homeserver in order to determine
/// whether or not the display name and avatar can be updated.
///
/// Once complete this method will start the post authentication flow automatically.
@available(iOS 14.0, *)
private func checkHomeserverCapabilities(for userSession: UserSession) {
userSession.matrixSession.matrixRestClient.capabilities { [weak self] capabilities in
guard let self = self else { return }
self.shouldShowDisplayNameScreen = capabilities?.setDisplayName?.isEnabled == true
self.shouldShowAvatarScreen = capabilities?.setAvatarUrl?.isEnabled == true
self.beginPostAuthentication(for: userSession)
} failure: { [weak self] _ in
MXLog.warning("[OnboardingCoordinator] Homeserver capabilities not returned. Skipping personalisation")
self?.beginPostAuthentication(for: userSession)
}
}
/// Displays the next view in the flow after the authentication screen.
private func authenticationCoordinatorDidComplete(_ coordinator: AuthenticationCoordinatorProtocol) {
isShowingAuthentication = false
@ -287,11 +310,19 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
// MARK: - Post-Authentication
/// Starts the part of the flow that comes after authentication for new users.
@available(iOS 14.0, *)
private func showCongratulationsScreen(userId: String) {
private func beginPostAuthentication(for userSession: UserSession) {
showCongratulationsScreen(for: userSession)
}
/// Show the congratulations screen for new users. The screen will be configured based on the homeserver's capabilities.
@available(iOS 14.0, *)
private func showCongratulationsScreen(for userSession: UserSession) {
MXLog.debug("[OnboardingCoordinator] showCongratulationsScreen")
let parameters = OnboardingCongratulationsCoordinatorParameters(userId: userId)
let parameters = OnboardingCongratulationsCoordinatorParameters(userSession: userSession,
personalizationDisabled: !shouldShowDisplayNameScreen && !shouldShowAvatarScreen)
let coordinator = OnboardingCongratulationsCoordinator(parameters: parameters)
coordinator.completion = { [weak self, weak coordinator] result in
@ -308,21 +339,25 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
}
}
/// Displays the next view in the flow after the congratulations screen.
@available(iOS 14.0, *)
private func congratulationsCoordinator(_ coordinator: OnboardingCongratulationsCoordinator, didCompleteWith result: OnboardingCongratulationsViewModelResult) {
if let session = session {
switch result {
case .personaliseProfile:
// TODO: Profile screens here instead.
if Analytics.shared.shouldShowAnalyticsPrompt {
showAnalyticsPrompt(for: session)
return
}
case .takeMeHome:
if Analytics.shared.shouldShowAnalyticsPrompt {
showAnalyticsPrompt(for: session)
return
}
private func congratulationsCoordinator(_ coordinator: OnboardingCongratulationsCoordinator, didCompleteWith result: OnboardingCongratulationsCoordinatorResult) {
switch result {
case .personalizeProfile(let userSession):
if shouldShowDisplayNameScreen {
showDisplayNameScreen(for: userSession)
return
} else if shouldShowAvatarScreen {
showAvatarScreen(for: userSession)
return
} else if Analytics.shared.shouldShowAnalyticsPrompt {
showAnalyticsPrompt(for: userSession.matrixSession)
return
}
case .takeMeHome(let userSession):
if Analytics.shared.shouldShowAnalyticsPrompt {
showAnalyticsPrompt(for: userSession.matrixSession)
return
}
}
@ -330,6 +365,84 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
completeIfReady()
}
/// Show the display name personalization screen for new users using the supplied user session.
@available(iOS 14.0, *)
private func showDisplayNameScreen(for userSession: UserSession) {
MXLog.debug("[OnboardingCoordinator]: showDisplayNameScreen")
let parameters = OnboardingDisplayNameCoordinatorParameters(userSession: userSession)
let coordinator = OnboardingDisplayNameCoordinator(parameters: parameters)
coordinator.completion = { [weak self, weak coordinator] session in
guard let self = self, let coordinator = coordinator else { return }
self.displayNameCoordinator(coordinator, didCompleteWith: session)
}
add(childCoordinator: coordinator)
coordinator.start()
navigationRouter.setRootModule(coordinator, hideNavigationBar: false, animated: true) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
}
/// Displays the next view in the flow after the display name screen.
@available(iOS 14.0, *)
private func displayNameCoordinator(_ coordinator: OnboardingDisplayNameCoordinator, didCompleteWith userSession: UserSession) {
if shouldShowAvatarScreen {
showAvatarScreen(for: userSession)
return
} else if Analytics.shared.shouldShowAnalyticsPrompt {
showAnalyticsPrompt(for: userSession.matrixSession)
return
}
onboardingFinished = true
completeIfReady()
}
/// Show the avatar personalization screen for new users using the supplied user session.
@available(iOS 14.0, *)
private func showAvatarScreen(for userSession: UserSession) {
MXLog.debug("[OnboardingCoordinator]: showAvatarScreen")
let parameters = OnboardingAvatarCoordinatorParameters(userSession: userSession)
let coordinator = OnboardingAvatarCoordinator(parameters: parameters)
coordinator.completion = { [weak self, weak coordinator] session in
guard let self = self, let coordinator = coordinator else { return }
self.avatarCoordinator(coordinator, didCompleteWith: session)
}
add(childCoordinator: coordinator)
coordinator.start()
if navigationRouter.modules.isEmpty || !shouldShowDisplayNameScreen {
navigationRouter.setRootModule(coordinator, hideNavigationBar: false, animated: true) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
} else {
navigationRouter.push(coordinator, animated: true) { [weak self] in
self?.remove(childCoordinator: coordinator)
}
}
}
/// Displays the next view in the flow after the avatar screen.
@available(iOS 14.0, *)
private func avatarCoordinator(_ coordinator: OnboardingAvatarCoordinator, didCompleteWith userSession: UserSession) {
if Analytics.shared.shouldShowAnalyticsPrompt {
showAnalyticsPrompt(for: userSession.matrixSession)
return
}
onboardingFinished = true
completeIfReady()
}
/// Shows the analytics prompt for the supplied session.
///
/// Check `Analytics.shared.shouldShowAnalyticsPrompt` before calling this method.
@available(iOS 14.0, *)
private func showAnalyticsPrompt(for session: MXSession) {
MXLog.debug("[OnboardingCoordinator]: Invite the user to send analytics")
@ -351,6 +464,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
}
}
/// Displays the next view in the flow after the analytics screen.
private func analyticsPromptCoordinatorDidComplete(_ coordinator: AnalyticsPromptCoordinator) {
onboardingFinished = true
completeIfReady()
@ -358,6 +472,8 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
// MARK: - Finished
/// Calls the coordinator's completion handler if both `onboardingFinished` and `authenticationFinished`
/// are true. Otherwise displays any pending screens and waits to be called again.
private func completeIfReady() {
guard onboardingFinished else {
MXLog.debug("[OnboardingCoordinator] Delaying onboarding completion until all screens have been shown.")

View file

@ -71,13 +71,14 @@ struct AnalyticsPrompt: View {
Text(VectorL10n.analyticsPromptTitle(AppInfo.current.displayName))
.font(theme.fonts.title2B)
.multilineTextAlignment(.center)
.foregroundColor(theme.colors.primaryContent)
.padding(.bottom, 2)
messageText
.font(theme.fonts.body)
.foregroundColor(theme.colors.secondaryContent)
.multilineTextAlignment(.center)
.foregroundColor(theme.colors.secondaryContent)
Divider()
.background(theme.colors.quinaryContent)
@ -117,8 +118,11 @@ struct AnalyticsPrompt: View {
.padding(.top, 50)
.padding(.horizontal, horizontalPadding)
}
.frame(maxWidth: OnboardingConstants.maxContentWidth)
.frame(maxWidth: .infinity)
buttons
.frame(maxWidth: OnboardingConstants.maxContentWidth)
.padding(.horizontal, horizontalPadding)
.padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 0 : 16)
}

View file

@ -35,22 +35,15 @@ struct AvatarImage: View {
case .empty:
ProgressView()
case .placeholder(let firstCharacter, let colorIndex):
Text(firstCharacter)
.padding(4)
.frame(width: CGFloat(size.rawValue), height: CGFloat(size.rawValue))
.foregroundColor(.white)
.background(theme.colors.namesAndAvatars[colorIndex])
.clipShape(Circle())
// Make the text resizable (i.e. Make it large and then allow it to scale down)
.font(.system(size: 200))
.minimumScaleFactor(0.001)
PlaceholderAvatarImage(firstCharacter: firstCharacter,
colorIndex: colorIndex)
case .avatar(let image):
Image(uiImage: image)
.resizable()
.frame(width: CGFloat(size.rawValue), height: CGFloat(size.rawValue))
.clipShape(Circle())
}
}
.frame(width: CGFloat(size.rawValue), height: CGFloat(size.rawValue))
.clipShape(Circle())
.onAppear {
viewModel.inject(dependencies: dependencies)
viewModel.loadAvatar(

View file

@ -0,0 +1,60 @@
//
// Copyright 2022 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 reusable view that will show a standard placeholder avatar with the
/// supplied character and colour index for the `namesAndAvatars` color array.
///
/// This view has a forced 1:1 aspect ratio but will appear very large until a `.frame`
/// modifier is applied.
struct PlaceholderAvatarImage: View {
// MARK: - Private
@Environment(\.theme) private var theme
// MARK: - Public
let firstCharacter: Character
let colorIndex: Int
// MARK: - Views
var body: some View {
ZStack {
theme.colors.namesAndAvatars[colorIndex]
Text(String(firstCharacter))
.padding(4)
.foregroundColor(.white)
// Make the text resizable (i.e. Make it large and then allow it to scale down)
.font(.system(size: 200))
.minimumScaleFactor(0.001)
}
.aspectRatio(1, contentMode: .fill)
}
}
@available(iOS 14.0, *)
struct Previews_TemplateAvatarImage_Previews: PreviewProvider {
static var previews: some View {
PlaceholderAvatarImage(firstCharacter: "X", colorIndex: 1)
.clipShape(Circle())
.frame(width: 150, height: 100)
}
}

View file

@ -35,7 +35,7 @@ struct SpaceAvatarImage: View {
case .empty:
ProgressView()
case .placeholder(let firstCharacter, let colorIndex):
Text(firstCharacter)
Text(String(firstCharacter))
.padding(10)
.frame(width: CGFloat(size.rawValue), height: CGFloat(size.rawValue))
.foregroundColor(.white)

View file

@ -42,10 +42,11 @@ class AvatarViewModel: InjectableObject, ObservableObject {
colorCount: Int,
avatarSize: AvatarSize) {
self.viewState = .placeholder(
firstCharacterCapitalized(displayName),
stableColorIndex(matrixItemId: matrixItemId, colorCount: colorCount)
)
let placeholderViewModel = PlaceholderAvatarViewModel(displayName: displayName,
matrixItemId: matrixItemId,
colorCount: colorCount)
self.viewState = .placeholder(placeholderViewModel.firstCharacterCapitalized, placeholderViewModel.stableColorIndex)
guard let mxContentUri = mxContentUri, mxContentUri.count > 0 else {
return
@ -60,31 +61,4 @@ class AvatarViewModel: InjectableObject, ObservableObject {
}
.store(in: &cancellables)
}
/// Get the first character of a string capialized or else an empty string.
/// - Parameter string: The input string to get the capitalized letter from.
/// - Returns: The capitalized first letter.
private func firstCharacterCapitalized(_ string: String?) -> String {
guard let character = string?.first else {
return ""
}
return String(character).capitalized
}
/// Provides the same color each time for a specified matrixId
///
/// Same algorithm as in AvatarGenerator.
/// - Parameters:
/// - matrixItemId: the matrix id used as input to create the stable index.
/// - colorCount: The number of total colors we want to index in to.
/// - Returns: The stable index.
private func stableColorIndex(matrixItemId: String, colorCount: Int) -> Int {
// Sum all characters
let sum = matrixItemId.utf8
.map({ UInt($0) })
.reduce(0, +)
// modulo the color count
return Int(sum) % colorCount
}
}

View file

@ -19,6 +19,6 @@ import UIKit
enum AvatarViewState {
case empty
case placeholder(String, Int)
case placeholder(Character, Int)
case avatar(UIImage)
}

View file

@ -0,0 +1,47 @@
//
// Copyright 2022 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
/// Simple view model that computes the placeholder avatar properties.
struct PlaceholderAvatarViewModel {
/// The displayname used to create the `firstCharacterCapitalized`.
let displayName: String?
/// The matrix id used as input to create the `stableColorIndex` from.
let matrixItemId: String
/// The number of total colors available for the `stableColorIndex`.
let colorCount: Int
/// Get the first character of the display name capitalized or else a space character.
var firstCharacterCapitalized: Character {
return displayName?.capitalized.first ?? " "
}
/// Provides the same color each time for a specified matrixId
///
/// Same algorithm as in AvatarGenerator.
/// - Parameters:
/// - matrixItemId: the matrix id used as input to create the stable index.
/// - Returns: The stable index.
var stableColorIndex: Int {
// Sum all characters
let sum = matrixItemId.utf8
.map({ UInt($0) })
.reduce(0, +)
// modulo the color count
return Int(sum) % colorCount
}
}

View file

@ -20,6 +20,8 @@ import Foundation
@available(iOS 14.0, *)
enum MockAppScreens {
static let appScreens: [MockScreenState.Type] = [
MockOnboardingAvatarScreenState.self,
MockOnboardingDisplayNameScreenState.self,
MockOnboardingCongratulationsScreenState.self,
MockOnboardingUseCaseSelectionScreenState.self,
MockOnboardingSplashScreenScreenState.self,

View file

@ -23,29 +23,34 @@ struct PrimaryActionButtonStyle: ButtonStyle {
var customColor: Color? = nil
private var fontColor: Color {
// Always white unless disabled with a dark theme.
.white.opacity(theme.isDark && !isEnabled ? 0.3 : 1.0)
}
private var backgroundColor: Color {
customColor ?? theme.colors.accent
}
func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.padding(12.0)
.frame(maxWidth: .infinity)
.foregroundColor(.white)
.foregroundColor(fontColor)
.font(theme.fonts.body)
.background(backgroundColor(configuration.isPressed))
.opacity(isEnabled ? 1.0 : 0.6)
.background(backgroundColor.opacity(backgroundOpacity(when: configuration.isPressed)))
.cornerRadius(8.0)
}
func backgroundColor(_ isPressed: Bool) -> Color {
if let customColor = customColor {
return customColor
}
return isPressed ? theme.colors.accent.opacity(0.6) : theme.colors.accent
func backgroundOpacity(when isPressed: Bool) -> CGFloat {
guard isEnabled else { return 0.3 }
return isPressed ? 0.6 : 1.0
}
}
@available(iOS 14.0, *)
struct PrimaryActionButtonStyle_Previews: PreviewProvider {
static var previews: some View {
static var buttons: some View {
Group {
VStack {
Button("Enabled") { }
@ -67,4 +72,11 @@ struct PrimaryActionButtonStyle_Previews: PreviewProvider {
.padding()
}
}
static var previews: some View {
buttons
.theme(.light).preferredColorScheme(.light)
buttons
.theme(.dark).preferredColorScheme(.dark)
}
}

View file

@ -0,0 +1,198 @@
//
// 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 CommonKit
struct OnboardingAvatarCoordinatorParameters {
let userSession: UserSession
}
@available(iOS 14.0, *)
final class OnboardingAvatarCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
private let parameters: OnboardingAvatarCoordinatorParameters
private let onboardingAvatarHostingController: VectorHostingController
private var onboardingAvatarViewModel: OnboardingAvatarViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var waitingIndicator: UserIndicator?
private lazy var cameraPresenter: CameraPresenter = {
let presenter = CameraPresenter()
presenter.delegate = self
return presenter
}()
private lazy var mediaPickerPresenter: MediaPickerPresenter = {
let presenter = MediaPickerPresenter()
presenter.delegate = self
return presenter
}()
private lazy var mediaUploader: MXMediaLoader = MXMediaManager.prepareUploader(withMatrixSession: parameters.userSession.matrixSession,
initialRange: 0,
andRange: 1.0)
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var completion: ((UserSession) -> Void)?
// MARK: - Setup
init(parameters: OnboardingAvatarCoordinatorParameters) {
self.parameters = parameters
let viewModel = OnboardingAvatarViewModel(userId: parameters.userSession.userId,
displayName: parameters.userSession.account.userDisplayName,
avatarColorCount: DefaultThemeSwiftUI().colors.namesAndAvatars.count)
let view = OnboardingAvatarScreen(viewModel: viewModel.context)
onboardingAvatarViewModel = viewModel
onboardingAvatarHostingController = VectorHostingController(rootView: view)
onboardingAvatarHostingController.vc_removeBackTitle()
onboardingAvatarHostingController.enableNavigationBarScrollEdgeAppearance = true
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingAvatarHostingController)
}
// MARK: - Public
func start() {
MXLog.debug("[OnboardingAvatarCoordinator] did start.")
onboardingAvatarViewModel.completion = { [weak self] result in
guard let self = self else { return }
MXLog.debug("[OnboardingAvatarCoordinator] OnboardingAvatarViewModel did complete with result: \(result).")
switch result {
case .pickImage:
self.pickImage()
case .takePhoto:
self.takePhoto()
case .save(let avatar):
self.setAvatar(avatar)
case .skip:
self.completion?(self.parameters.userSession)
}
}
}
func toPresentable() -> UIViewController {
return self.onboardingAvatarHostingController
}
// MARK: - Private
/// Show a blocking activity indicator whilst saving.
private func startWaiting() {
waitingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.saving, isInteractionBlocking: true))
}
/// Hide the currently displayed activity indicator.
private func stopWaiting() {
waitingIndicator = nil
}
/// Present an image picker for the device photo library.
private func pickImage() {
let controller = toPresentable()
mediaPickerPresenter.presentPicker(from: controller, with: .images, animated: true)
}
/// Present a camera view to take a photo to use for the avatar.
private func takePhoto() {
let controller = toPresentable()
cameraPresenter.presentCamera(from: controller, with: [.image], animated: true)
}
/// Set the supplied image as user's avatar, completing the screen's display if successful.
func setAvatar(_ image: UIImage?) {
guard let image = image else {
MXLog.error("[OnboardingAvatarCoordinator] setAvatar called with a nil image.")
return
}
startWaiting()
guard let avatarData = MXKTools.forceImageOrientationUp(image)?.jpegData(compressionQuality: 0.5) else {
MXLog.error("[OnboardingAvatarCoordinator] Failed to create jpeg data.")
self.stopWaiting()
self.onboardingAvatarViewModel.processError(nil)
return
}
mediaUploader.uploadData(avatarData, filename: nil, mimeType: "image/jpeg") { [weak self] urlString in
guard let self = self else { return }
guard let urlString = urlString else {
MXLog.error("[OnboardingAvatarCoordinator] Missing URL string for avatar.")
self.stopWaiting()
self.onboardingAvatarViewModel.processError(nil)
return
}
self.parameters.userSession.account.setUserAvatarUrl(urlString) { [weak self] in
guard let self = self else { return }
self.stopWaiting()
self.completion?(self.parameters.userSession)
} failure: { [weak self] error in
guard let self = self else { return }
self.stopWaiting()
self.onboardingAvatarViewModel.processError(error as NSError?)
}
} failure: { [weak self] error in
guard let self = self else { return }
self.stopWaiting()
self.onboardingAvatarViewModel.processError(error as NSError?)
}
}
}
// MARK: - MediaPickerPresenterDelegate
@available(iOS 14.0, *)
extension OnboardingAvatarCoordinator: MediaPickerPresenterDelegate {
func mediaPickerPresenter(_ presenter: MediaPickerPresenter, didPickImage image: UIImage) {
onboardingAvatarViewModel.updateAvatarImage(with: image)
presenter.dismiss(animated: true, completion: nil)
}
func mediaPickerPresenterDidCancel(_ presenter: MediaPickerPresenter) {
presenter.dismiss(animated: true, completion: nil)
}
}
// MARK: - CameraPresenterDelegate
@available(iOS 14.0, *)
extension OnboardingAvatarCoordinator: CameraPresenterDelegate {
func cameraPresenter(_ presenter: CameraPresenter, didSelectImage image: UIImage) {
onboardingAvatarViewModel.updateAvatarImage(with: image)
presenter.dismiss(animated: true, completion: nil)
}
func cameraPresenter(_ presenter: CameraPresenter, didSelectVideoAt url: URL) {
presenter.dismiss(animated: true, completion: nil)
}
func cameraPresenterDidCancel(_ presenter: CameraPresenter) {
presenter.dismiss(animated: true, completion: nil)
}
}

View file

@ -0,0 +1,77 @@
//
// 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 MockOnboardingAvatarScreenState: MockScreenState, CaseIterable {
// A case for each state you want to represent
// with specific, minimal associated data that will allow you
// mock that screen.
case placeholderAvatar(userId: String, displayName: String)
case userSelectedAvatar(userId: String, displayName: String)
/// The associated screen
var screenType: Any.Type {
OnboardingAvatarScreen.self
}
/// A list of screen state definitions
static var allCases: [MockOnboardingAvatarScreenState] {
let userId = "@example:matrix.org"
let displayName = "Jane"
return [
.placeholderAvatar(userId: userId, displayName: displayName),
.userSelectedAvatar(userId: userId, displayName: displayName)
]
}
/// Generate the view struct for the screen state.
var screenView: ([Any], AnyView) {
let avatarColorCount = DefaultThemeSwiftUI().colors.namesAndAvatars.count
let viewModel: OnboardingAvatarViewModel
switch self {
case .placeholderAvatar(let userId, let displayName):
viewModel = OnboardingAvatarViewModel(userId: userId, displayName: displayName, avatarColorCount: avatarColorCount)
case .userSelectedAvatar(let userId, let displayName):
viewModel = OnboardingAvatarViewModel(userId: userId, displayName: displayName, avatarColorCount: avatarColorCount)
viewModel.updateAvatarImage(with: Asset.Images.appSymbol.image)
}
return (
[self, viewModel],
AnyView(OnboardingAvatarScreen(viewModel: viewModel.context)
.addDependency(MockAvatarService.example))
)
}
}
@available(iOS 14.0, *)
extension MockOnboardingAvatarScreenState: CustomStringConvertible {
// Added to have different descriptions in the SwiftUI target's list.
var description: String {
switch self {
case .placeholderAvatar:
return "placeholderAvatar"
case .userSelectedAvatar:
return "userSelectedAvatar"
}
}
}

View file

@ -0,0 +1,63 @@
//
// 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 UIKit
// MARK: View model
enum OnboardingAvatarViewModelResult {
/// The user would like to choose an image from their photo library.
case pickImage
/// The user would like to take a photo to use as their avatar.
case takePhoto
/// The user would like to set specified image as their avatar.
case save(UIImage?)
/// Move on to the next screen in the flow without setting an avatar.
case skip
}
// MARK: View
struct OnboardingAvatarViewState: BindableState {
/// The letter shown in the placeholder avatar.
let placeholderAvatarLetter: Character
/// The color index to use for the placeholder avatar's background.
let placeholderAvatarColorIndex: Int
/// The image selected by the user to use as their avatar.
var avatar: UIImage?
var bindings: OnboardingAvatarBindings
/// The image shown in the avatar's button.
var buttonImage: ImageAsset {
avatar == nil ? Asset.Images.onboardingAvatarCamera : Asset.Images.onboardingAvatarEdit
}
}
struct OnboardingAvatarBindings {
/// The currently displayed alert's info value otherwise `nil`.
var alertInfo: AlertInfo<Int>?
}
enum OnboardingAvatarViewAction {
/// The user would like to choose an image from their photo library.
case pickImage
/// The user would like to take a photo to use as their avatar.
case takePhoto
/// The user would like to save their chosen avatar image.
case save
/// Move on to the next screen in the flow without setting an avatar.
case skip
}

View file

@ -0,0 +1,67 @@
//
// 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 Combine
@available(iOS 14, *)
typealias OnboardingAvatarViewModelType = StateStoreViewModel<OnboardingAvatarViewState,
Never,
OnboardingAvatarViewAction>
@available(iOS 14, *)
class OnboardingAvatarViewModel: OnboardingAvatarViewModelType, OnboardingAvatarViewModelProtocol {
// MARK: - Properties
// MARK: Private
// MARK: Public
var completion: ((OnboardingAvatarViewModelResult) -> Void)?
// MARK: - Setup
init(userId: String, displayName: String?, avatarColorCount: Int) {
let placeholderViewModel = PlaceholderAvatarViewModel(displayName: displayName, matrixItemId: userId, colorCount: avatarColorCount)
let initialViewState = OnboardingAvatarViewState(placeholderAvatarLetter: placeholderViewModel.firstCharacterCapitalized,
placeholderAvatarColorIndex: placeholderViewModel.stableColorIndex,
bindings: OnboardingAvatarBindings())
super.init(initialViewState: initialViewState)
}
// MARK: - Public
override func process(viewAction: OnboardingAvatarViewAction) {
switch viewAction {
case .pickImage:
completion?(.pickImage)
case .takePhoto:
completion?(.takePhoto)
case .save:
completion?(.save(state.avatar))
case .skip:
completion?(.skip)
}
}
func updateAvatarImage(with image: UIImage?) {
state.avatar = image
}
func processError(_ error: NSError?) {
state.bindings.alertInfo = AlertInfo(error: error)
}
}

View file

@ -0,0 +1,31 @@
//
// 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
protocol OnboardingAvatarViewModelProtocol {
var completion: ((OnboardingAvatarViewModelResult) -> Void)? { get set }
@available(iOS 14, *)
var context: OnboardingAvatarViewModelType.Context { get }
/// Update the view model to show the image that the user has picked.
func updateAvatarImage(with image: UIImage?)
/// Update the view model to show that an error has occurred.
/// - Parameter error: The error to be displayed or `nil` to display a generic alert.
func processError(_ error: NSError?)
}

View file

@ -0,0 +1,70 @@
//
// 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 OnboardingAvatarUITests: MockScreenTest {
override class var screenType: MockScreenState.Type {
return MockOnboardingAvatarScreenState.self
}
override class func createTest() -> MockScreenTest {
return OnboardingAvatarUITests(selector: #selector(verifyOnboardingAvatarScreen))
}
func verifyOnboardingAvatarScreen() throws {
guard let screenState = screenState as? MockOnboardingAvatarScreenState else { fatalError("no screen") }
switch screenState {
case .placeholderAvatar(let userId, let displayName):
verifyPlaceholderAvatar(userId: userId, displayName: displayName)
case .userSelectedAvatar:
verifyUserSelectedAvatar()
}
}
func verifyPlaceholderAvatar(userId: String, displayName: String) {
guard let firstLetter = displayName.uppercased().first else {
XCTFail("Unable to get the first letter of the display name.")
return
}
let placeholderAvatar = app.staticTexts["placeholderAvatar"]
XCTAssertTrue(placeholderAvatar.exists, "The placeholder avatar should be shown.")
XCTAssertEqual(placeholderAvatar.label, String(firstLetter), "The placeholder avatar should show the first letter of the display name.")
let avatarImage = app.images["avatarImage"]
XCTAssertFalse(avatarImage.exists, "The avatar image should be hidden as no selection has been made.")
let saveButton = app.buttons["saveButton"]
XCTAssertTrue(saveButton.exists, "There should be a save button.")
XCTAssertFalse(saveButton.isEnabled, "The save button should not be enabled.")
}
func verifyUserSelectedAvatar() {
let placeholderAvatar = app.otherElements["placeholderAvatar"]
XCTAssertFalse(placeholderAvatar.exists, "The placeholder avatar should be hidden.")
let avatarImage = app.images["avatarImage"]
XCTAssertTrue(avatarImage.exists, "The selected avatar should be shown.")
let saveButton = app.buttons["saveButton"]
XCTAssertTrue(saveButton.exists, "There should be a save button.")
XCTAssertTrue(saveButton.isEnabled, "The save button should be enabled.")
}
}

View file

@ -0,0 +1,57 @@
//
// 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 Combine
@testable import RiotSwiftUI
@available(iOS 14.0, *)
class OnboardingAvatarViewModelTests: XCTestCase {
private enum Constants {
static let userId = "@user:matrix.org"
static let displayName = "Alice"
static let avatarColorCount = DefaultThemeSwiftUI().colors.namesAndAvatars.count
static let avatarImage = Asset.Images.appSymbol.image
}
var viewModel: OnboardingAvatarViewModelProtocol!
var context: OnboardingAvatarViewModelType.Context!
override func setUpWithError() throws {
viewModel = OnboardingAvatarViewModel(userId: Constants.userId,
displayName: Constants.displayName,
avatarColorCount: Constants.avatarColorCount)
context = viewModel.context
}
func testInitialState() {
XCTAssertEqual(context.viewState.placeholderAvatarLetter, "A")
XCTAssertNil(context.viewState.avatar)
XCTAssertNil(context.viewState.bindings.alertInfo)
}
func testUpdatingAvatar() {
// Given the default view model
XCTAssertNil(context.viewState.avatar, "The default view state should not have an avatar.")
// When updating the image
viewModel.updateAvatarImage(with: Constants.avatarImage)
// Then the view state should contain the new image
XCTAssertEqual(context.viewState.avatar, Constants.avatarImage, "The view state should contain the new avatar image.")
}
}

View file

@ -0,0 +1,157 @@
//
// 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 14.0, *)
struct OnboardingAvatarScreen: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme
@State private var isPresentingPickerSelection = false
// MARK: Public
@ObservedObject var viewModel: OnboardingAvatarViewModel.Context
var body: some View {
ScrollView {
VStack(spacing: 0) {
avatar
.padding(.horizontal, 2)
.padding(.bottom, 40)
header
.padding(.bottom, 40)
buttons
}
.padding(.horizontal)
.padding(.top, 8)
.frame(maxWidth: OnboardingConstants.maxContentWidth)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.accentColor(theme.colors.accent)
.background(theme.colors.background.ignoresSafeArea())
.alert(item: $viewModel.alertInfo) { $0.alert }
}
/// The user's avatar along with a picker button
var avatar: some View {
Group {
if let avatarImage = viewModel.viewState.avatar {
Image(uiImage: avatarImage)
.resizable()
.scaledToFill()
.accessibilityIdentifier("avatarImage")
} else {
PlaceholderAvatarImage(firstCharacter: viewModel.viewState.placeholderAvatarLetter,
colorIndex: viewModel.viewState.placeholderAvatarColorIndex)
.accessibilityIdentifier("placeholderAvatar")
}
}
.clipShape(Circle())
.frame(width: 120, height: 120)
.overlay(cameraButton, alignment: .bottomTrailing)
.onTapGesture { isPresentingPickerSelection = true }
.actionSheet(isPresented: $isPresentingPickerSelection) { pickerSelectionActionSheet }
.accessibilityElement(children: .ignore)
.accessibilityLabel(VectorL10n.onboardingAvatarAccessibilityLabel)
.accessibilityValue(VectorL10n.edit)
}
/// The button to indicate the user can tap to select an avatar
/// Note: The whole avatar is tappable to make this easier.
var cameraButton: some View {
ZStack {
Circle()
.foregroundColor(theme.colors.background)
.shadow(color: .black.opacity(0.15), radius: 2.4, y: 2.4)
Image(viewModel.viewState.buttonImage.name)
.renderingMode(.template)
.foregroundColor(theme.colors.secondaryContent)
}
.frame(width: 40, height: 40)
}
/// The action sheet that asks how the user would like to set their avatar.
var pickerSelectionActionSheet: ActionSheet {
ActionSheet(title: Text(VectorL10n.onboardingAvatarTitle), buttons: [
.default(Text(VectorL10n.imagePickerActionCamera)) {
viewModel.send(viewAction: .takePhoto)
},
.default(Text(VectorL10n.imagePickerActionLibrary)) {
viewModel.send(viewAction: .pickImage)
},
.cancel()
])
}
/// The screen's title and message views.
var header: some View {
VStack(spacing: 8) {
Text(VectorL10n.onboardingAvatarTitle)
.font(theme.fonts.title2B)
.multilineTextAlignment(.center)
.foregroundColor(theme.colors.primaryContent)
Text(VectorL10n.onboardingAvatarMessage)
.font(theme.fonts.subheadline)
.multilineTextAlignment(.center)
.foregroundColor(theme.colors.secondaryContent)
}
}
/// The main action buttons in the form.
var buttons: some View {
VStack(spacing: 8) {
Button(VectorL10n.onboardingPersonalizationSave) {
viewModel.send(viewAction: .save)
}
.buttonStyle(PrimaryActionButtonStyle())
.disabled(viewModel.viewState.avatar == nil)
.accessibilityIdentifier("saveButton")
Button { viewModel.send(viewAction: .skip) } label: {
Text(VectorL10n.onboardingPersonalizationSkip)
.font(theme.fonts.body)
.padding(12)
}
}
}
}
// MARK: - Previews
@available(iOS 15.0, *)
struct OnboardingAvatar_Previews: PreviewProvider {
static let stateRenderer = MockOnboardingAvatarScreenState.stateRenderer
static var previews: some View {
stateRenderer.screenGroup(addNavigation: true)
.navigationViewStyle(.stack)
.theme(.light).preferredColorScheme(.light)
stateRenderer.screenGroup(addNavigation: true)
.navigationViewStyle(.stack)
.theme(.dark).preferredColorScheme(.dark)
}
}

View file

@ -17,7 +17,18 @@
import SwiftUI
struct OnboardingCongratulationsCoordinatorParameters {
let userId: String
/// The user session used to determine the user ID to display.
let userSession: UserSession
/// When `true` the "Personalise Profile" button will be hidden, preventing the
/// user from setting a displayname or avatar.
let personalizationDisabled: Bool
}
enum OnboardingCongratulationsCoordinatorResult {
/// Show the display name and/or avatar screens for the user to personalize their profile.
case personalizeProfile(UserSession)
/// Continue the flow by skipping the display name and avatar screens.
case takeMeHome(UserSession)
}
final class OnboardingCongratulationsCoordinator: Coordinator, Presentable {
@ -34,7 +45,7 @@ final class OnboardingCongratulationsCoordinator: Coordinator, Presentable {
// Must be used only internally
var childCoordinators: [Coordinator] = []
var completion: ((OnboardingCongratulationsViewModelResult) -> Void)?
var completion: ((OnboardingCongratulationsCoordinatorResult) -> Void)?
// MARK: - Setup
@ -42,7 +53,9 @@ final class OnboardingCongratulationsCoordinator: Coordinator, Presentable {
init(parameters: OnboardingCongratulationsCoordinatorParameters) {
self.parameters = parameters
let viewModel = OnboardingCongratulationsViewModel(userId: parameters.userId)
// TODO: Add confetti when personalizationDisabled is false
let viewModel = OnboardingCongratulationsViewModel(userId: parameters.userSession.userId,
personalizationDisabled: parameters.personalizationDisabled)
let view = OnboardingCongratulationsScreen(viewModel: viewModel.context)
onboardingCongratulationsViewModel = viewModel
onboardingCongratulationsHostingController = VectorHostingController(rootView: view)
@ -54,7 +67,13 @@ final class OnboardingCongratulationsCoordinator: Coordinator, Presentable {
onboardingCongratulationsViewModel.completion = { [weak self] result in
guard let self = self else { return }
MXLog.debug("[OnboardingCongratulationsCoordinator] OnboardingCongratulationsViewModel did complete with result: \(result).")
self.completion?(result)
switch result {
case .personalizeProfile:
self.completion?(.personalizeProfile(self.parameters.userSession))
case .takeMeHome:
self.completion?(.takeMeHome(self.parameters.userSession))
}
}
}

View file

@ -24,7 +24,8 @@ enum MockOnboardingCongratulationsScreenState: MockScreenState, CaseIterable {
// A case for each state you want to represent
// with specific, minimal associated data that will allow you
// mock that screen.
case congratulations
case regular
case personalizationDisabled
/// The associated screen
var screenType: Any.Type {
@ -33,14 +34,18 @@ enum MockOnboardingCongratulationsScreenState: MockScreenState, CaseIterable {
/// Generate the view struct for the screen state.
var screenView: ([Any], AnyView) {
let viewModel = OnboardingCongratulationsViewModel(userId: "@testuser:example.com")
let viewModel: OnboardingCongratulationsViewModel
// can simulate service and viewModel actions here if needs be.
switch self {
case .regular:
viewModel = OnboardingCongratulationsViewModel(userId: "@testuser:example.com")
case .personalizationDisabled:
viewModel = OnboardingCongratulationsViewModel(userId: "@testuser:example.com", personalizationDisabled: true)
}
return (
[self, viewModel],
AnyView(OnboardingCongratulationsScreen(viewModel: viewModel.context)
.addDependency(MockAvatarService.example))
AnyView(OnboardingCongratulationsScreen(viewModel: viewModel.context))
)
}
}

View file

@ -21,14 +21,15 @@ import Foundation
// MARK: View model
enum OnboardingCongratulationsViewModelResult {
case personaliseProfile
case personalizeProfile
case takeMeHome
}
// MARK: View
struct OnboardingCongratulationsViewState: BindableState {
var userId: String
let userId: String
let personalizationDisabled: Bool
}
enum OnboardingCongratulationsViewAction {

View file

@ -33,8 +33,9 @@ class OnboardingCongratulationsViewModel: OnboardingCongratulationsViewModelType
// MARK: - Setup
init(userId: String, initialCount: Int = 0) {
super.init(initialViewState: OnboardingCongratulationsViewState(userId: userId))
init(userId: String, personalizationDisabled: Bool = false) {
super.init(initialViewState: OnboardingCongratulationsViewState(userId: userId,
personalizationDisabled: personalizationDisabled))
}
// MARK: - Public
@ -42,7 +43,7 @@ class OnboardingCongratulationsViewModel: OnboardingCongratulationsViewModelType
override func process(viewAction: OnboardingCongratulationsViewAction) {
switch viewAction {
case .personaliseProfile:
completion?(.personaliseProfile)
completion?(.personalizeProfile)
case .takeMeHome:
completion?(.takeMeHome)
}

View file

@ -31,10 +31,26 @@ class OnboardingCongratulationsUITests: MockScreenTest {
func verifyOnboardingCongratulationsScreen() throws {
guard let screenState = screenState as? MockOnboardingCongratulationsScreenState else { fatalError("no screen") }
switch screenState {
case .congratulations:
// There isn't anything to test here
break
case .regular:
verifyButtons()
case .personalizationDisabled:
verifyButtonsWhenPersonalizationIsDisabled()
}
}
func verifyButtons() {
let personalizeButton = app.buttons["personalizeButton"]
XCTAssertTrue(personalizeButton.exists, "The personalization button should be shown.")
let homeButton = app.buttons["homeButton"]
XCTAssertTrue(homeButton.exists, "The home button should always be shown.")
}
func verifyButtonsWhenPersonalizationIsDisabled() {
let personalizeButton = app.buttons["personalizeButton"]
XCTAssertFalse(personalizeButton.exists, "The personalization button should be hidden.")
let homeButton = app.buttons["homeButton"]
XCTAssertTrue(homeButton.exists, "The home button should always be shown.")
}
}

View file

@ -45,7 +45,7 @@ struct OnboardingCongratulationsScreen: View {
Spacer()
buttons
footer
.padding(.horizontal, horizontalPadding)
.padding(.bottom, 24)
.padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 0 : 16)
@ -62,8 +62,10 @@ struct OnboardingCongratulationsScreen: View {
/// The main content of the view to be shown in a scroll view.
var mainContent: some View {
VStack(spacing: 62) {
VStack(spacing: 42) {
Image(Asset.Images.onboardingCongratulationsIcon.name)
.resizable()
.frame(width: 90, height: 90)
.accessibilityHidden(true)
VStack(spacing: 8) {
@ -79,23 +81,45 @@ struct OnboardingCongratulationsScreen: View {
}
}
/// The action buttons shown at the bottom of the view.
var buttons: some View {
@ViewBuilder
var footer: some View {
if viewModel.viewState.personalizationDisabled {
homeButton
} else {
actionButtons
}
}
/// The default action buttons shown at the bottom of the view.
var actionButtons: some View {
VStack(spacing: 12) {
Button { viewModel.send(viewAction: .personaliseProfile) } label: {
Text(VectorL10n.onboardingCongratulationsPersonaliseButton)
.font(theme.fonts.bodySB)
Text(VectorL10n.onboardingCongratulationsPersonalizeButton)
.font(theme.fonts.body)
.foregroundColor(theme.colors.accent)
}
.buttonStyle(PrimaryActionButtonStyle(customColor: .white))
.accessibilityIdentifier("personalizeButton")
Button { viewModel.send(viewAction: .takeMeHome) } label: {
Text(VectorL10n.onboardingCongratulationsHomeButton)
.font(theme.fonts.body)
.padding(.vertical, 12)
}
.accessibilityIdentifier("homeButton")
}
}
/// The single "Take me home" button shown when personlization isn't supported.
var homeButton: some View {
Button { viewModel.send(viewAction: .takeMeHome) } label: {
Text(VectorL10n.onboardingCongratulationsHomeButton)
.font(theme.fonts.body)
.foregroundColor(theme.colors.accent)
}
.buttonStyle(PrimaryActionButtonStyle(customColor: .white))
.accessibilityIdentifier("homeButton")
}
}
// MARK: - Previews

View file

@ -0,0 +1,107 @@
//
// 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 CommonKit
struct OnboardingDisplayNameCoordinatorParameters {
let userSession: UserSession
}
@available(iOS 14.0, *)
final class OnboardingDisplayNameCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
private let parameters: OnboardingDisplayNameCoordinatorParameters
private let onboardingDisplayNameHostingController: VectorHostingController
private var onboardingDisplayNameViewModel: OnboardingDisplayNameViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var waitingIndicator: UserIndicator?
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var completion: ((UserSession) -> Void)?
// MARK: - Setup
init(parameters: OnboardingDisplayNameCoordinatorParameters) {
self.parameters = parameters
// Don't pre-fill the display name from the MXID to encourage the user to enter something
let viewModel = OnboardingDisplayNameViewModel()
let view = OnboardingDisplayNameScreen(viewModel: viewModel.context)
onboardingDisplayNameViewModel = viewModel
onboardingDisplayNameHostingController = VectorHostingController(rootView: view)
onboardingDisplayNameHostingController.vc_removeBackTitle()
onboardingDisplayNameHostingController.enableNavigationBarScrollEdgeAppearance = true
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingDisplayNameHostingController)
}
// MARK: - Public
func start() {
MXLog.debug("[OnboardingDisplayNameCoordinator] did start.")
onboardingDisplayNameViewModel.completion = { [weak self] result in
guard let self = self else { return }
MXLog.debug("[OnboardingDisplayNameCoordinator] OnboardingDisplayNameViewModel did complete.")
switch result {
case .save(let displayName):
self.setDisplayName(displayName)
case .skip:
self.completion?(self.parameters.userSession)
}
}
}
func toPresentable() -> UIViewController {
return self.onboardingDisplayNameHostingController
}
// MARK: - Private
/// Show a blocking activity indicator whilst saving.
private func startWaiting() {
waitingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.saving, isInteractionBlocking: true))
}
/// Hide the currently displayed activity indicator.
private func stopWaiting() {
waitingIndicator = nil
}
/// Set the supplied string as user's display name, completing the screen's display if successful.
private func setDisplayName(_ displayName: String) {
startWaiting()
parameters.userSession.account.setUserDisplayName(displayName) { [weak self] in
guard let self = self else { return }
self.stopWaiting()
self.completion?(self.parameters.userSession)
} failure: { [weak self] error in
guard let self = self else { return }
self.stopWaiting()
self.onboardingDisplayNameViewModel.processError(error as NSError?)
}
}
}

View file

@ -0,0 +1,63 @@
//
// 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 MockOnboardingDisplayNameScreenState: MockScreenState, CaseIterable {
// A case for each state you want to represent
// with specific, minimal associated data that will allow you
// mock that screen.
case emptyTextField
case filledTextField(displayName: String)
case longDisplayName(displayName: String)
/// The associated screen
var screenType: Any.Type {
OnboardingDisplayNameScreen.self
}
/// A list of screen state definitions
static var allCases: [MockOnboardingDisplayNameScreenState] {
[
MockOnboardingDisplayNameScreenState.emptyTextField,
MockOnboardingDisplayNameScreenState.filledTextField(displayName: "Test User"),
MockOnboardingDisplayNameScreenState.longDisplayName(displayName: """
Bacon ipsum dolor amet filet mignon chicken kevin andouille. Doner shoulder beef, brisket bresaola turkey jowl venison. Ham hock cow turducken, chislic venison doner short loin strip steak tri-tip jowl. Sirloin pork belly hamburger ribeye. Tail capicola alcatra short ribs turkey doner.
""")
]
}
/// Generate the view struct for the screen state.
var screenView: ([Any], AnyView) {
let viewModel: OnboardingDisplayNameViewModel
switch self {
case .emptyTextField:
viewModel = OnboardingDisplayNameViewModel()
case .filledTextField(let displayName), .longDisplayName(displayName: let displayName):
viewModel = OnboardingDisplayNameViewModel(displayName: displayName)
}
// can simulate service and viewModel actions here if needs be.
return (
[self, viewModel], AnyView(OnboardingDisplayNameScreen(viewModel: viewModel.context))
)
}
}

View file

@ -0,0 +1,55 @@
//
// 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: View model
enum OnboardingDisplayNameViewModelResult {
/// The user would like to save the entered display name.
case save(String)
/// Move on to the next screen in the flow without setting a display name.
case skip
}
// MARK: View
struct OnboardingDisplayNameViewState: BindableState {
var bindings: OnboardingDisplayNameBindings
/// Any error that occurred during display name validation otherwise `nil`.
var validationErrorMessage: String?
/// The string to be displayed in the text field's footer.
var textFieldFooterMessage: String {
validationErrorMessage ?? VectorL10n.onboardingDisplayNameHint
}
}
struct OnboardingDisplayNameBindings {
/// The display name string entered by the user.
var displayName: String
/// The currently displayed alert's info value otherwise `nil`.
var alertInfo: AlertInfo<Int>?
}
enum OnboardingDisplayNameViewAction {
/// The display name needs validation.
case validateDisplayName
/// The user would like to save the entered display name.
case save
/// Move on to the next screen in the flow without setting a display name.
case skip
}

View file

@ -0,0 +1,70 @@
//
// 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 Combine
@available(iOS 14, *)
typealias OnboardingDisplayNameViewModelType = StateStoreViewModel<OnboardingDisplayNameViewState,
Never,
OnboardingDisplayNameViewAction>
@available(iOS 14, *)
class OnboardingDisplayNameViewModel: OnboardingDisplayNameViewModelType, OnboardingDisplayNameViewModelProtocol {
// MARK: - Properties
// MARK: Private
// MARK: Public
var completion: ((OnboardingDisplayNameViewModelResult) -> Void)?
// MARK: - Setup
init(displayName: String = "") {
super.init(initialViewState: OnboardingDisplayNameViewState(bindings: OnboardingDisplayNameBindings(displayName: displayName)))
validateDisplayName()
}
// MARK: - Public
override func process(viewAction: OnboardingDisplayNameViewAction) {
switch viewAction {
case .validateDisplayName:
validateDisplayName()
case .save:
completion?(.save(state.bindings.displayName))
case .skip:
completion?(.skip)
}
}
func processError(_ error: NSError?) {
state.bindings.alertInfo = AlertInfo(error: error)
}
// MARK: - Private
/// Checks for a display name that exceeds 256 characters and updates the footer error if needed.
private func validateDisplayName() {
if state.bindings.displayName.count > 256 {
guard state.validationErrorMessage == nil else { return }
state.validationErrorMessage = VectorL10n.onboardingDisplayNameMaxLength
} else if state.validationErrorMessage != nil {
state.validationErrorMessage = nil
}
}
}

View file

@ -0,0 +1,28 @@
//
// 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 OnboardingDisplayNameViewModelProtocol {
var completion: ((OnboardingDisplayNameViewModelResult) -> Void)? { get set }
@available(iOS 14, *)
var context: OnboardingDisplayNameViewModelType.Context { get }
/// Update the view model to show that an error has occurred.
/// - Parameter error: The error to be displayed or `nil` to display a generic alert.
func processError(_ error: NSError?)
}

View file

@ -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
import RiotSwiftUI
@available(iOS 14.0, *)
class OnboardingDisplayNameUITests: MockScreenTest {
override class var screenType: MockScreenState.Type {
return MockOnboardingDisplayNameScreenState.self
}
override class func createTest() -> MockScreenTest {
return OnboardingDisplayNameUITests(selector: #selector(verifyOnboardingDisplayNameScreen))
}
func verifyOnboardingDisplayNameScreen() throws {
guard let screenState = screenState as? MockOnboardingDisplayNameScreenState else { fatalError("no screen") }
switch screenState {
case .emptyTextField:
verifyEmptyTextField()
case .filledTextField(let displayName):
verifyDisplayName(displayName: displayName)
case .longDisplayName(displayName: let displayName):
verifyLongDisplayName(displayName: displayName)
}
}
func verifyEmptyTextField() {
let textField = app.textFields.element
XCTAssertTrue(textField.exists, "The textfield should always be shown.")
XCTAssertEqual(textField.value as? String, VectorL10n.onboardingDisplayNamePlaceholder, "When the textfield is empty, the value should match the placeholder.")
XCTAssertEqual(textField.placeholderValue, VectorL10n.onboardingDisplayNamePlaceholder, "The textfield's placeholder should be set.")
let footer = app.staticTexts["textFieldFooter"]
XCTAssertTrue(footer.exists, "The textfield's footer should always be shown.")
XCTAssertEqual(footer.label, VectorL10n.onboardingDisplayNameHint, "The footer should display a hint when no text is set.")
let saveButton = app.buttons["saveButton"]
XCTAssertTrue(saveButton.exists, "There should be a save button.")
XCTAssertFalse(saveButton.isEnabled, "The save button should not be enabled.")
}
func verifyDisplayName(displayName: String) {
let textField = app.textFields.element
XCTAssertTrue(textField.exists, "The textfield should always be shown.")
XCTAssertEqual(textField.value as? String, displayName, "When a name has been set, it should show in the textfield.")
XCTAssertEqual(textField.placeholderValue, VectorL10n.onboardingDisplayNamePlaceholder, "The textfield's placeholder should be set.")
let saveButton = app.buttons["saveButton"]
XCTAssertTrue(saveButton.exists, "There should be a save button.")
XCTAssertTrue(saveButton.isEnabled, "The save button should be enabled.")
let footer = app.staticTexts["textFieldFooter"]
XCTAssertTrue(footer.exists, "The textfield's footer should always be shown.")
XCTAssertEqual(footer.label, VectorL10n.onboardingDisplayNameHint, "The footer should display a hint when an acceptable name is entered.")
}
func verifyLongDisplayName(displayName: String) {
let textField = app.textFields.element
XCTAssertTrue(textField.exists, "The textfield should always be shown.")
XCTAssertEqual(textField.value as? String, displayName, "When a name has been set, it should show in the textfield.")
XCTAssertEqual(textField.placeholderValue, VectorL10n.onboardingDisplayNamePlaceholder, "The textfield's placeholder should be set.")
let footer = app.staticTexts["textFieldFooter"]
XCTAssertTrue(footer.exists, "The textfield's footer should always be shown.")
XCTAssertEqual(footer.label, VectorL10n.onboardingDisplayNameMaxLength, "The footer should display an error when the display name is too long.")
let saveButton = app.buttons["saveButton"]
XCTAssertTrue(saveButton.exists, "There should be a save button.")
XCTAssertFalse(saveButton.isEnabled, "The save button should not be enabled.")
}
}

View file

@ -0,0 +1,64 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import XCTest
import Combine
@testable import RiotSwiftUI
@available(iOS 14.0, *)
class OnboardingDisplayNameViewModelTests: XCTestCase {
var viewModel: OnboardingDisplayNameViewModel!
var context: OnboardingDisplayNameViewModelType.Context!
override func setUpWithError() throws {
viewModel = nil
context = nil
}
func setUp(with displayName: String) {
viewModel = OnboardingDisplayNameViewModel(displayName: displayName)
context = viewModel.context
}
func testValidDisplayName() {
// Given a short display name
let displayName = "Alice"
setUp(with: displayName)
// When validating the display name
viewModel.process(viewAction: .validateDisplayName)
// Then no error message should be set
XCTAssertEqual(context.viewState.bindings.displayName, displayName, "The display name should match the value used at init.")
XCTAssertNil(context.viewState.validationErrorMessage, "There should not be an error message in the view state.")
}
func testInvalidDisplayName() {
// Given a short display name
let displayName = """
Bacon ipsum dolor amet filet mignon chicken kevin andouille. Doner shoulder beef, brisket bresaola turkey jowl venison. Ham hock cow turducken, chislic venison doner short loin strip steak tri-tip jowl. Sirloin pork belly hamburger ribeye. Tail capicola alcatra short ribs turkey doner.
"""
setUp(with: displayName)
// When validating the display name
viewModel.process(viewAction: .validateDisplayName)
// Then no error message should be set
XCTAssertEqual(context.viewState.bindings.displayName, displayName, "The display name should match the value used at init.")
XCTAssertNotNil(context.viewState.validationErrorMessage, "There should be an error message in the view state.")
}
}

View file

@ -0,0 +1,139 @@
//
// 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 OnboardingDisplayNameScreen: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme: ThemeSwiftUI
@State private var isEditingTextField = false
private var textFieldFooterColor: Color {
viewModel.viewState.validationErrorMessage == nil ? theme.colors.tertiaryContent : theme.colors.alert
}
// MARK: Public
@ObservedObject var viewModel: OnboardingDisplayNameViewModel.Context
// MARK: - Views
var body: some View {
ScrollView {
VStack(spacing: 0) {
header
.padding(.bottom, 32)
textField
.padding(.horizontal, 2)
.padding(.bottom, 20)
buttons
}
.padding(.horizontal)
.padding(.top, 8)
.frame(maxWidth: OnboardingConstants.maxContentWidth)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.accentColor(theme.colors.accent)
.background(theme.colors.background.ignoresSafeArea())
.alert(item: $viewModel.alertInfo) { $0.alert }
.onChange(of: viewModel.displayName) { _ in
viewModel.send(viewAction: .validateDisplayName)
}
}
/// The icon, title and message views.
var header: some View {
VStack(spacing: 8) {
Image(Asset.Images.onboardingCongratulationsIcon.name)
.resizable()
.renderingMode(.template)
.foregroundColor(theme.colors.accent)
.frame(width: 90, height: 90)
.background(Circle().foregroundColor(.white).padding(2))
.padding(.bottom, 8)
.accessibilityHidden(true)
Text(VectorL10n.onboardingDisplayNameTitle)
.font(theme.fonts.title2B)
.multilineTextAlignment(.center)
.foregroundColor(theme.colors.primaryContent)
Text(VectorL10n.onboardingDisplayNameMessage)
.font(theme.fonts.subheadline)
.multilineTextAlignment(.center)
.foregroundColor(theme.colors.secondaryContent)
}
}
/// The text field used to enter the displayname along with a hint.
var textField: some View {
VStack(spacing: 4) {
TextField(VectorL10n.onboardingDisplayNamePlaceholder, text: $viewModel.displayName) {
isEditingTextField = $0
}
.textFieldStyle(BorderedInputFieldStyle(theme: _theme,
isEditing: isEditingTextField,
isError: viewModel.viewState.validationErrorMessage != nil))
Text(viewModel.viewState.textFieldFooterMessage)
.font(theme.fonts.footnote)
.foregroundColor(textFieldFooterColor)
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityIdentifier("textFieldFooter")
}
}
/// The main action buttons in the form.
var buttons: some View {
VStack(spacing: 8) {
Button(VectorL10n.onboardingPersonalizationSave) {
viewModel.send(viewAction: .save)
}
.buttonStyle(PrimaryActionButtonStyle())
.disabled(viewModel.displayName.isEmpty || viewModel.viewState.validationErrorMessage != nil)
.accessibilityIdentifier("saveButton")
Button { viewModel.send(viewAction: .skip) } label: {
Text(VectorL10n.onboardingPersonalizationSkip)
.font(theme.fonts.body)
.padding(12)
}
}
}
}
// MARK: - Previews
@available(iOS 15.0, *)
struct OnboardingDisplayName_Previews: PreviewProvider {
static let stateRenderer = MockOnboardingDisplayNameScreenState.stateRenderer
static var previews: some View {
stateRenderer.screenGroup(addNavigation: true)
.navigationViewStyle(.stack)
.theme(.light).preferredColorScheme(.light)
stateRenderer.screenGroup(addNavigation: true)
.navigationViewStyle(.stack)
.theme(.dark).preferredColorScheme(.dark)
}
}

View file

@ -20,13 +20,14 @@ protocol OnboardingSplashScreenCoordinatorProtocol: Coordinator, Presentable {
var completion: ((OnboardingSplashScreenViewModelResult) -> Void)? { get set }
}
@available(iOS 14.0, *)
final class OnboardingSplashScreenCoordinator: OnboardingSplashScreenCoordinatorProtocol {
// MARK: - Properties
// MARK: Private
private let onboardingSplashScreenHostingController: UIViewController
private let onboardingSplashScreenHostingController: VectorHostingController
private var onboardingSplashScreenViewModel: OnboardingSplashScreenViewModelProtocol
// MARK: Public
@ -37,14 +38,12 @@ final class OnboardingSplashScreenCoordinator: OnboardingSplashScreenCoordinator
// MARK: - Setup
@available(iOS 14.0, *)
init() {
let viewModel = OnboardingSplashScreenViewModel()
let view = OnboardingSplashScreen(viewModel: viewModel.context)
onboardingSplashScreenViewModel = viewModel
let hostingController = VectorHostingController(rootView: view)
hostingController.vc_removeBackTitle()
onboardingSplashScreenHostingController = hostingController
onboardingSplashScreenHostingController = VectorHostingController(rootView: view)
onboardingSplashScreenHostingController.vc_removeBackTitle()
}
// MARK: - Public

View file

@ -16,13 +16,14 @@
import SwiftUI
@available(iOS 14.0, *)
final class OnboardingUseCaseSelectionCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
private let onboardingUseCaseHostingController: UIViewController
private let onboardingUseCaseHostingController: VectorHostingController
private var onboardingUseCaseViewModel: OnboardingUseCaseViewModelProtocol
// MARK: Public
@ -33,16 +34,14 @@ final class OnboardingUseCaseSelectionCoordinator: Coordinator, Presentable {
// MARK: - Setup
@available(iOS 14.0, *)
init() {
let viewModel = OnboardingUseCaseViewModel()
let view = OnboardingUseCaseSelectionScreen(viewModel: viewModel.context)
onboardingUseCaseViewModel = viewModel
let hostingController = VectorHostingController(rootView: view)
hostingController.vc_removeBackTitle()
hostingController.enableNavigationBarScrollEdgeAppearance = true
onboardingUseCaseHostingController = hostingController
onboardingUseCaseHostingController = VectorHostingController(rootView: view)
onboardingUseCaseHostingController.vc_removeBackTitle()
onboardingUseCaseHostingController.enableNavigationBarScrollEdgeAppearance = true
}
// MARK: - Public

1
changelog.d/5652.wip Normal file
View file

@ -0,0 +1 @@
Onboarding: Add screens for setting a display name and avatar when signing up for the first time.