mirror of
https://gitlab.opencode.de/bwi/bundesmessenger/clients/bundesmessenger-ios.git
synced 2026-04-23 18:12:44 +02:00
5720: Update from develop
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
+23
-4
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-5
@@ -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))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -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 {
|
||||
|
||||
+4
-3
@@ -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)
|
||||
}
|
||||
|
||||
+20
-4
@@ -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.")
|
||||
}
|
||||
}
|
||||
|
||||
+30
-6
@@ -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
|
||||
|
||||
+107
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -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.")
|
||||
}
|
||||
}
|
||||
+64
@@ -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)
|
||||
}
|
||||
}
|
||||
+4
-5
@@ -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
|
||||
|
||||
+5
-6
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user