Add FTUE display name screen.

Use UserSession instead of userId & MXSession in OnboardingCoordinator
This commit is contained in:
Doug
2022-03-07 16:30:26 +00:00
parent c1874d83a5
commit a7dbb37cfa
18 changed files with 717 additions and 33 deletions
@@ -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 SwiftUI
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
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var completion: ((UserSession) -> Void)?
// MARK: - Setup
init(parameters: OnboardingDisplayNameCoordinatorParameters) {
self.parameters = parameters
let viewModel = OnboardingDisplayNameViewModel.makeOnboardingDisplayNameViewModel(onboardingDisplayNameService: OnboardingDisplayNameService(userSession: parameters.userSession))
let view = OnboardingDisplayNameScreen(viewModel: viewModel.context)
onboardingDisplayNameViewModel = viewModel
onboardingDisplayNameHostingController = VectorHostingController(rootView: view)
onboardingDisplayNameHostingController.enableNavigationBarScrollEdgesAppearance = true
}
// MARK: - Public
func start() {
MXLog.debug("[OnboardingDisplayNameCoordinator] did start.")
onboardingDisplayNameViewModel.completion = { [weak self] in
guard let self = self else { return }
MXLog.debug("[OnboardingDisplayNameCoordinator] OnboardingDisplayNameViewModel did complete.")
self.completion?(self.parameters.userSession)
}
}
func toPresentable() -> UIViewController {
return self.onboardingDisplayNameHostingController
}
}
@@ -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 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 operationInProgress(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.operationInProgress(displayName: "Test User"),
]
}
/// Generate the view struct for the screen state.
var screenView: ([Any], AnyView) {
let service: MockOnboardingDisplayNameService
switch self {
case .emptyTextField:
service = MockOnboardingDisplayNameService()
case .filledTextField(let displayName):
service = MockOnboardingDisplayNameService(displayName: displayName)
case .operationInProgress(let displayName):
service = MockOnboardingDisplayNameService(displayName: displayName, isWaiting: true)
}
let viewModel = OnboardingDisplayNameViewModel.makeOnboardingDisplayNameViewModel(onboardingDisplayNameService: service)
// can simulate service and viewModel actions here if needs be.
return (
[service, viewModel], AnyView(OnboardingDisplayNameScreen(viewModel: viewModel.context))
)
}
}
@@ -0,0 +1,40 @@
//
// 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 {
// Can probably be removed
}
// MARK: View
struct OnboardingDisplayNameViewState: BindableState {
var isWaiting = false
var bindings: OnboardingDisplayNameBindings
}
struct OnboardingDisplayNameBindings {
var displayName: String
var alertInfo: AlertInfo<Int>?
}
enum OnboardingDisplayNameViewAction {
case save
case skip
}
@@ -0,0 +1,81 @@
//
// 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
private let onboardingDisplayNameService: OnboardingDisplayNameServiceProtocol
// MARK: Public
var completion: (() -> Void)?
// MARK: - Setup
static func makeOnboardingDisplayNameViewModel(onboardingDisplayNameService: OnboardingDisplayNameServiceProtocol) -> OnboardingDisplayNameViewModelProtocol {
return OnboardingDisplayNameViewModel(onboardingDisplayNameService: onboardingDisplayNameService)
}
private init(onboardingDisplayNameService: OnboardingDisplayNameServiceProtocol) {
self.onboardingDisplayNameService = onboardingDisplayNameService
super.init(initialViewState: Self.defaultState(onboardingDisplayNameService: onboardingDisplayNameService))
}
private static func defaultState(onboardingDisplayNameService: OnboardingDisplayNameServiceProtocol) -> OnboardingDisplayNameViewState {
// Start with a blank display name to encourage the user not to just use the first part of their MXID.
return OnboardingDisplayNameViewState(bindings: OnboardingDisplayNameBindings(displayName: ""))
}
// MARK: - Public
override func process(viewAction: OnboardingDisplayNameViewAction) {
switch viewAction {
case .save:
setDisplayName()
case .skip:
completion?()
}
}
// MARK: - Private
private func setDisplayName() {
state.isWaiting = true
onboardingDisplayNameService.setDisplayName(context.displayName) { [weak self] result in
guard let self = self else { return }
self.state.isWaiting = false
switch result {
case .success(_):
self.completion?()
case .failure(let error):
self.state.bindings.alertInfo = AlertInfo(error: error as NSError)
}
}
}
}
@@ -0,0 +1,26 @@
//
// 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: (() -> Void)? { get set }
@available(iOS 14, *)
static func makeOnboardingDisplayNameViewModel(onboardingDisplayNameService: OnboardingDisplayNameServiceProtocol) -> OnboardingDisplayNameViewModelProtocol
@available(iOS 14, *)
var context: OnboardingDisplayNameViewModelType.Context { get }
}
@@ -0,0 +1,52 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import Combine
@available(iOS 14.0, *)
class OnboardingDisplayNameService: OnboardingDisplayNameServiceProtocol {
enum ServiceError: Error {
case unknown
}
// MARK: - Properties
// MARK: Private
private let userSession: UserSession
// MARK: Public
var displayName: String? {
userSession.account.userDisplayName
}
// MARK: - Setup
init(userSession: UserSession) {
self.userSession = userSession
}
func setDisplayName(_ displayName: String, completion: @escaping (Result<Bool, Error>) -> Void) {
userSession.account.setUserDisplayName(displayName) {
completion(.success(true))
} failure: { error in
completion(.failure(error ?? ServiceError.unknown))
}
}
}
@@ -0,0 +1,35 @@
//
// 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 Combine
@available(iOS 14.0, *)
class MockOnboardingDisplayNameService: OnboardingDisplayNameServiceProtocol {
var displayName: String?
#warning("isWaiting isn't handled.")
init(displayName: String? = nil, isWaiting: Bool = false) {
self.displayName = displayName
}
func setDisplayName(_ displayName: String, completion: @escaping (Result<Bool, Error>) -> Void) {
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) {
self.displayName = displayName
completion(.success(true))
}
}
}
@@ -0,0 +1,27 @@
//
// 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 Combine
@available(iOS 14.0, *)
protocol OnboardingDisplayNameServiceProtocol {
/// The user's current display name read from the `UserSession`.
var displayName: String? { get }
/// Update the user's display name.
func setDisplayName(_ displayName: String, completion: @escaping (Result<Bool, Error>) -> Void)
}
@@ -0,0 +1,53 @@
//
// 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 .presence(let presence):
verifyOnboardingDisplayNamePresence(presence: presence)
case .longDisplayName(let name):
verifyOnboardingDisplayNameLongName(name: name)
}
}
func verifyOnboardingDisplayNamePresence(presence: OnboardingDisplayNamePresence) {
let presenceText = app.staticTexts["presenceText"]
XCTAssert(presenceText.exists)
XCTAssertEqual(presenceText.label, presence.title)
}
func verifyOnboardingDisplayNameLongName(name: String) {
let displayNameText = app.staticTexts["displayNameText"]
XCTAssert(displayNameText.exists)
XCTAssertEqual(displayNameText.label, name)
}
}
@@ -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 OnboardingDisplayNameViewModelTests: XCTestCase {
private enum Constants {
static let presenceInitialValue: OnboardingDisplayNamePresence = .offline
static let displayName = "Alice"
}
var service: MockOnboardingDisplayNameService!
var viewModel: OnboardingDisplayNameViewModelProtocol!
var context: OnboardingDisplayNameViewModelType.Context!
var cancellables = Set<AnyCancellable>()
override func setUpWithError() throws {
service = MockOnboardingDisplayNameService(displayName: Constants.displayName, presence: Constants.presenceInitialValue)
viewModel = OnboardingDisplayNameViewModel.makeOnboardingDisplayNameViewModel(onboardingDisplayNameService: service)
context = viewModel.context
}
func testInitialState() {
XCTAssertEqual(context.viewState.displayName, Constants.displayName)
XCTAssertEqual(context.viewState.presence, Constants.presenceInitialValue)
}
func testFirstPresenceReceived() throws {
let presencePublisher = context.$viewState.map(\.presence).removeDuplicates().collect(1).first()
XCTAssertEqual(try xcAwait(presencePublisher), [Constants.presenceInitialValue])
}
func testPresenceUpdatesReceived() throws {
let presencePublisher = context.$viewState.map(\.presence).removeDuplicates().collect(3).first()
let awaitDeferred = xcAwaitDeferred(presencePublisher)
let newPresenceValue1: OnboardingDisplayNamePresence = .online
let newPresenceValue2: OnboardingDisplayNamePresence = .idle
service.simulateUpdate(presence: newPresenceValue1)
service.simulateUpdate(presence: newPresenceValue2)
XCTAssertEqual(try awaitDeferred(), [Constants.presenceInitialValue, newPresenceValue1, newPresenceValue2])
}
}
@@ -0,0 +1,115 @@
//
// 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
// MARK: Public
@ObservedObject var viewModel: OnboardingDisplayNameViewModel.Context
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(maxHeight: .infinity)
}
.accentColor(theme.colors.accent)
.background(theme.colors.background.ignoresSafeArea())
.alert(item: $viewModel.alertInfo) { $0.alert }
}
/// The icon, title and message views.
var header: some View {
VStack(spacing: 8) {
Image(Asset.Images.onboardingCongratulationsIcon.name)
.renderingMode(.template)
.foregroundColor(theme.colors.accent)
.padding(.bottom, 8)
.accessibilityHidden(true)
Text(VectorL10n.onboardingDisplayNameTitle)
.font(theme.fonts.title2B)
.foregroundColor(theme.colors.primaryContent)
Text(VectorL10n.onboardingDisplayNameMessage)
.font(theme.fonts.subheadline)
.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))
Text(VectorL10n.onboardingDisplayNameHint)
.font(theme.fonts.caption2)
.foregroundColor(theme.colors.tertiaryContent)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
/// The main action buttons in the form.
var buttons: some View {
VStack(spacing: 8) {
Button(VectorL10n.onboardingDisplayNameSave) {
viewModel.send(viewAction: .save)
}
.buttonStyle(PrimaryActionButtonStyle())
.disabled(viewModel.displayName.isEmpty || viewModel.viewState.isWaiting)
#warning("Use font/theme")
Button { viewModel.send(viewAction: .skip) } label: {
Text(VectorL10n.onboardingDisplayNameSkip)
.padding(12)
}
}
}
}
// MARK: - Previews
@available(iOS 14.0, *)
struct OnboardingDisplayName_Previews: PreviewProvider {
static let stateRenderer = MockOnboardingDisplayNameScreenState.stateRenderer
static var previews: some View {
stateRenderer.screenGroup(addNavigation: true)
}
}