5720: Update from develop

This commit is contained in:
MaximeE
2022-03-22 09:41:25 +01:00
104 changed files with 2599 additions and 272 deletions
@@ -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)
}
@@ -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(
@@ -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)
}
}
@@ -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)
@@ -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
}
}
@@ -19,6 +19,6 @@ import UIKit
enum AvatarViewState {
case empty
case placeholder(String, Int)
case placeholder(Character, Int)
case avatar(UIImage)
}
@@ -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
}
}
@@ -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,
@@ -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)
}
}
@@ -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)
}
}
@@ -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"
}
}
}
@@ -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
}
@@ -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)
}
}
@@ -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?)
}
@@ -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.")
}
}
@@ -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.")
}
}
@@ -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)
}
}
@@ -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))
}
}
}
@@ -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))
)
}
}
@@ -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 {
@@ -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)
}
@@ -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.")
}
}
@@ -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
@@ -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?)
}
}
}
@@ -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))
)
}
}
@@ -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
}
@@ -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
}
}
}
@@ -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?)
}
@@ -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.")
}
}
@@ -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.")
}
}
@@ -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)
}
}
@@ -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
@@ -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
@@ -54,7 +54,7 @@ struct LocationSharingOptionButton_Previews: PreviewProvider {
LocationSharingOptionButton(text: "Share live location") {
} content: {
LocationSharingOptionButtonIcon(fillColor: Color.purple, image: Asset.Images.locationLiveIcon.image)
LocationSharingOptionButtonIcon(fillColor: Color.purple, image: Asset.Images.liveLocationIcon.image)
}
}
}
@@ -99,7 +99,7 @@ struct LocationSharingView: View {
LocationSharingOptionButton(text: VectorL10n.locationSharingLiveShareTitle) {
// TODO: - Start live location sharing
} content: {
LocationSharingOptionButtonIcon(fillColor: Color.purple, image: Asset.Images.locationLiveIcon.image)
LocationSharingOptionButtonIcon(fillColor: Color.purple, image: Asset.Images.liveLocationIcon.image)
}
.disabled(!context.viewState.shareButtonEnabled)
}
@@ -46,8 +46,8 @@ class UserSuggestionViewModel: UserSuggestionViewModelType, UserSuggestionViewMo
super.init(initialViewState: UserSuggestionViewState(items: items))
userSuggestionService.items.sink { items in
self.state.items = items.map({ item in
userSuggestionService.items.sink { [weak self] items in
self?.state.items = items.map({ item in
UserSuggestionViewStateItem(id: item.userId, avatar: item, displayName: item.displayName)
})
}.store(in: &cancellables)