Server Confirmation Screen (#959)

* Add ServerConfirmationScreen.
This commit is contained in:
Doug
2023-05-30 10:31:18 +01:00
committed by GitHub
parent 088ca622a1
commit 74b36ce870
56 changed files with 643 additions and 201 deletions
@@ -14,6 +14,7 @@
// limitations under the License.
//
import Combine
import SwiftUI
@MainActor
@@ -26,6 +27,8 @@ class AuthenticationCoordinator: CoordinatorProtocol {
private let authenticationService: AuthenticationServiceProxyProtocol
private let navigationStackCoordinator: NavigationStackCoordinator
private var cancellables: Set<AnyCancellable> = []
weak var delegate: AuthenticationCoordinatorDelegate?
init(authenticationService: AuthenticationServiceProxyProtocol,
@@ -64,17 +67,20 @@ class AuthenticationCoordinator: CoordinatorProtocol {
switch await authenticationService.configure(for: ServiceLocator.shared.settings.defaultHomeserverAddress) {
case .success:
stopLoading()
showLoginScreen()
showServerConfirmationScreen()
case .failure:
stopLoading()
showServerSelectionScreen()
showServerSelectionScreen(isModallyPresented: false)
}
}
private func showServerSelectionScreen() {
private func showServerSelectionScreen(isModallyPresented: Bool) {
let navigationCoordinator = NavigationStackCoordinator()
let userIndicatorController: UserIndicatorControllerProtocol! = isModallyPresented ? UserIndicatorController(rootCoordinator: navigationCoordinator) : ServiceLocator.shared.userIndicatorController
let parameters = ServerSelectionScreenCoordinatorParameters(authenticationService: authenticationService,
userIndicatorController: ServiceLocator.shared.userIndicatorController,
isModallyPresented: false)
userIndicatorController: userIndicatorController,
isModallyPresented: isModallyPresented)
let coordinator = ServerSelectionScreenCoordinator(parameters: parameters)
coordinator.callback = { [weak self] action in
@@ -82,18 +88,46 @@ class AuthenticationCoordinator: CoordinatorProtocol {
switch action {
case .updated:
self.showLoginScreen()
if isModallyPresented {
navigationStackCoordinator.setSheetCoordinator(nil)
} else {
showLoginScreen()
}
case .dismiss:
MXLog.failure("ServerSelectionScreen is requesting dismiss when part of a stack.")
navigationStackCoordinator.setSheetCoordinator(nil)
}
}
if isModallyPresented {
navigationCoordinator.setRootCoordinator(coordinator)
navigationStackCoordinator.setSheetCoordinator(userIndicatorController)
} else {
navigationStackCoordinator.push(coordinator)
}
}
private func showServerConfirmationScreen() {
let parameters = ServerConfirmationScreenCoordinatorParameters(authenticationService: authenticationService,
authenticationFlow: .login)
let coordinator = ServerConfirmationScreenCoordinator(parameters: parameters)
coordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .confirm:
self.showLoginScreen()
case .changeServer:
self.showServerSelectionScreen(isModallyPresented: true)
}
}
.store(in: &cancellables)
navigationStackCoordinator.push(coordinator)
}
private func showLoginScreen() {
let parameters = LoginScreenCoordinatorParameters(authenticationService: authenticationService,
navigationStackCoordinator: navigationStackCoordinator)
let parameters = LoginScreenCoordinatorParameters(authenticationService: authenticationService)
let coordinator = LoginScreenCoordinator(parameters: parameters)
coordinator.callback = { [weak self] action in
@@ -20,8 +20,6 @@ import SwiftUI
struct LoginScreenCoordinatorParameters {
/// The service used to authenticate the user.
let authenticationService: AuthenticationServiceProxyProtocol
/// The navigation router used to present the server selection screen.
let navigationStackCoordinator: NavigationStackCoordinator
}
enum LoginScreenCoordinatorAction {
@@ -37,7 +35,6 @@ final class LoginScreenCoordinator: CoordinatorProtocol {
private let oidcAuthenticationPresenter: OIDCAuthenticationPresenter
private var authenticationService: AuthenticationServiceProxyProtocol { parameters.authenticationService }
private var navigationStackCoordinator: NavigationStackCoordinator { parameters.navigationStackCoordinator }
var callback: (@MainActor (LoginScreenCoordinatorAction) -> Void)?
@@ -46,7 +43,7 @@ final class LoginScreenCoordinator: CoordinatorProtocol {
init(parameters: LoginScreenCoordinatorParameters) {
self.parameters = parameters
viewModel = LoginScreenViewModel(homeserver: parameters.authenticationService.homeserver)
viewModel = LoginScreenViewModel(homeserver: parameters.authenticationService.homeserver.value)
oidcAuthenticationPresenter = OIDCAuthenticationPresenter(authenticationService: parameters.authenticationService)
}
@@ -58,8 +55,6 @@ final class LoginScreenCoordinator: CoordinatorProtocol {
guard let self else { return }
switch action {
case .selectServer:
self.presentServerSelectionScreen()
case .parseUsername(let username):
self.parseUsername(username)
case .forgotPassword:
@@ -193,35 +188,10 @@ final class LoginScreenCoordinator: CoordinatorProtocol {
/// Updates the view model with a different homeserver.
private func updateViewModel() {
viewModel.update(homeserver: authenticationService.homeserver)
viewModel.update(homeserver: authenticationService.homeserver.value)
indicateSuccess()
}
/// Presents the server selection screen as a modal.
private func presentServerSelectionScreen() {
let parameters = ServerSelectionScreenCoordinatorParameters(authenticationService: authenticationService,
userIndicatorController: ServiceLocator.shared.userIndicatorController,
isModallyPresented: false)
let coordinator = ServerSelectionScreenCoordinator(parameters: parameters)
coordinator.callback = { [weak self, weak coordinator] action in
guard let self, let coordinator else { return }
self.serverSelectionCoordinator(coordinator, didCompleteWith: action)
}
navigationStackCoordinator.push(coordinator)
}
/// Handles the result from the server selection modal, dismissing it after updating the view.
private func serverSelectionCoordinator(_ coordinator: ServerSelectionScreenCoordinator,
didCompleteWith action: ServerSelectionScreenCoordinatorAction) {
if action == .updated {
updateViewModel()
}
navigationStackCoordinator.pop()
}
/// Shows the forgot password screen.
private func showForgotPasswordScreen() {
viewModel.displayError(.alert("Not implemented."))
@@ -17,8 +17,6 @@
import Foundation
enum LoginScreenViewModelAction: CustomStringConvertible {
/// The user would like to select another server.
case selectServer
/// Parse the username and update the homeserver if included.
case parseUsername(String)
/// The user would like to reset their password.
@@ -31,8 +29,6 @@ enum LoginScreenViewModelAction: CustomStringConvertible {
/// A string representation of the action, ignoring any associated values that could leak PII.
var description: String {
switch self {
case .selectServer:
return "selectServer"
case .parseUsername:
return "parseUsername"
case .forgotPassword:
@@ -77,8 +73,6 @@ struct LoginScreenBindings {
}
enum LoginScreenViewAction {
/// The user would like to select another server.
case selectServer
/// Parse the username to detect if a homeserver is included.
case parseUsername
/// The user would like to reset their password.
@@ -30,8 +30,6 @@ class LoginScreenViewModel: LoginScreenViewModelType, LoginScreenViewModelProtoc
override func process(viewAction: LoginScreenViewAction) {
switch viewAction {
case .selectServer:
callback?(.selectServer)
case .parseUsername:
callback?(.parseUsername(state.bindings.username))
case .forgotPassword:
@@ -31,9 +31,6 @@ struct LoginScreen: View {
.padding(.top, UIConstants.titleTopPaddingToNavigationBar)
.padding(.bottom, 32)
serverInfo
.padding(.bottom, 32)
switch context.viewState.loginMode {
case .password:
loginForm
@@ -51,19 +48,18 @@ struct LoginScreen: View {
.alert(item: $context.alertInfo) { $0.alert }
}
/// The header containing a Welcome Back title.
/// The header containing the title and icon.
var header: some View {
Text(L10n.screenLoginTitle)
.font(.compound.headingLGBold)
.multilineTextAlignment(.center)
.foregroundColor(.element.primaryContent)
}
/// The sever information section that includes a button to select a different server.
var serverInfo: some View {
LoginServerInfoSection(address: context.viewState.homeserver.address) {
context.send(viewAction: .selectServer)
VStack(spacing: 8) {
AuthenticationIconImage(image: Image(systemName: "lock.fill"))
.padding(.bottom, 8)
Text(L10n.screenLoginTitleWithHomeserver(context.viewState.homeserver.address))
.font(.compound.headingMDBold)
.multilineTextAlignment(.center)
.foregroundColor(.element.primaryContent)
}
.padding(.horizontal, 16)
}
/// The form with text fields for username and password, along with a submit button.
@@ -1,65 +0,0 @@
//
// 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
/// A view that shows information about the chosen homeserver,
/// along with an edit button to pick a different one.
struct LoginServerInfoSection: View {
// MARK: - Public
/// The address shown for the server.
let address: String
/// The action performed when tapping the edit button.
let editAction: () -> Void
// MARK: - Views
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(L10n.screenLoginServerHeader)
.font(.compound.bodySM)
.foregroundColor(.element.primaryContent)
.padding(.horizontal, 16)
Button(action: editAction) {
HStack {
Text(address)
.font(.compound.bodyMDSemibold)
.foregroundColor(.element.primaryContent)
.padding(.horizontal, 16)
.padding(.vertical)
Spacer()
Image(systemName: "chevron.right")
.font(.compound.bodyMD.bold())
.foregroundColor(.element.tertiaryContent)
.padding(.trailing, 16)
}
.background(RoundedRectangle(cornerRadius: 14).fill(Color.element.system))
}
.accessibilityIdentifier(A11yIdentifiers.loginScreen.changeServer)
}
}
}
struct LoginServerInfoSection_Previews: PreviewProvider {
static var previews: some View {
LoginServerInfoSection(address: "matrix.org", editAction: { })
.padding()
}
}
@@ -0,0 +1,64 @@
//
// 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 Combine
import SwiftUI
struct ServerConfirmationScreenCoordinatorParameters {
let authenticationService: AuthenticationServiceProxyProtocol
let authenticationFlow: AuthenticationFlow
}
enum ServerConfirmationScreenCoordinatorAction {
case confirm
case changeServer
}
final class ServerConfirmationScreenCoordinator: CoordinatorProtocol {
private let parameters: ServerConfirmationScreenCoordinatorParameters
private var viewModel: ServerConfirmationScreenViewModelProtocol
private let actionsSubject: PassthroughSubject<ServerConfirmationScreenCoordinatorAction, Never> = .init()
private var cancellables: Set<AnyCancellable> = .init()
var actions: AnyPublisher<ServerConfirmationScreenCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(parameters: ServerConfirmationScreenCoordinatorParameters) {
self.parameters = parameters
viewModel = ServerConfirmationScreenViewModel(authenticationService: parameters.authenticationService,
authenticationFlow: parameters.authenticationFlow)
}
func start() {
viewModel.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .confirm:
self.actionsSubject.send(.confirm)
case .changeServer:
self.actionsSubject.send(.changeServer)
}
}
.store(in: &cancellables)
}
func toPresentable() -> AnyView {
AnyView(ServerConfirmationScreen(context: viewModel.context))
}
}
@@ -0,0 +1,64 @@
//
// 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
enum ServerConfirmationScreenViewModelAction {
/// The user would like to continue with the current homeserver.
case confirm
/// The user would like to change to a different homeserver.
case changeServer
}
struct ServerConfirmationScreenViewState: BindableState {
/// The homeserver address input by the user.
var homeserverAddress: String
/// The flow being attempted on the selected homeserver.
let authenticationFlow: AuthenticationFlow
/// The screen's title.
var title: String {
switch authenticationFlow {
case .login:
return L10n.screenServerConfirmationTitleLogin(homeserverAddress)
case .register:
return L10n.screenServerConfirmationTitleRegister(homeserverAddress)
}
}
/// The message shown beneath the title.
var message: String {
switch authenticationFlow {
case .login:
if homeserverAddress == "matrix.org" {
return L10n.screenServerConfirmationMessageLoginMatrixDotOrg
} else if homeserverAddress == "element.io" {
return L10n.screenServerConfirmationMessageLoginElementDotIo
} else {
return ""
}
case .register:
return L10n.screenServerConfirmationMessageRegister
}
}
}
enum ServerConfirmationScreenViewAction {
/// The user would like to continue with the current homeserver.
case confirm
/// The user would like to change to a different homeserver.
case changeServer
}
@@ -0,0 +1,52 @@
//
// 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 Combine
import SwiftUI
typealias ServerConfirmationScreenViewModelType = StateStoreViewModel<ServerConfirmationScreenViewState, ServerConfirmationScreenViewAction>
class ServerConfirmationScreenViewModel: ServerConfirmationScreenViewModelType, ServerConfirmationScreenViewModelProtocol {
private var actionsSubject: PassthroughSubject<ServerConfirmationScreenViewModelAction, Never> = .init()
var actions: AnyPublisher<ServerConfirmationScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(authenticationService: AuthenticationServiceProxyProtocol, authenticationFlow: AuthenticationFlow) {
super.init(initialViewState: ServerConfirmationScreenViewState(homeserverAddress: authenticationService.homeserver.value.address,
authenticationFlow: authenticationFlow))
authenticationService.homeserver
.receive(on: DispatchQueue.main)
.sink { [weak self] homeserver in
guard let self else { return }
state.homeserverAddress = homeserver.address
}
.store(in: &cancellables)
}
// MARK: - Public
override func process(viewAction: ServerConfirmationScreenViewAction) {
switch viewAction {
case .confirm:
actionsSubject.send(.confirm)
case .changeServer:
actionsSubject.send(.changeServer)
}
}
}
@@ -0,0 +1,23 @@
//
// 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 Combine
@MainActor
protocol ServerConfirmationScreenViewModelProtocol {
var actions: AnyPublisher<ServerConfirmationScreenViewModelAction, Never> { get }
var context: ServerConfirmationScreenViewModelType.Context { get }
}
@@ -0,0 +1,99 @@
//
// 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
struct ServerConfirmationScreen: View {
@ObservedObject var context: ServerConfirmationScreenViewModel.Context
var body: some View {
ScrollView {
header
.padding(.top, UIConstants.iconTopPaddingToNavigationBar)
.padding(.horizontal, 16)
.readableFrame()
}
.background(Color.element.background.ignoresSafeArea())
.safeAreaInset(edge: .bottom) {
buttons
.padding(.horizontal, 16)
.padding(.vertical, 16)
.readableFrame()
.background(Color.element.background.ignoresSafeArea())
}
}
/// The main content of the view to be shown in a scroll view.
var header: some View {
VStack(spacing: 8) {
AuthenticationIconImage(image: Image(systemName: "person.crop.circle.fill"))
.padding(.bottom, 8)
Text(context.viewState.title)
.font(.compound.headingMDBold)
.multilineTextAlignment(.center)
.foregroundColor(.element.primaryContent)
.fixedSize(horizontal: false, vertical: true)
Text(context.viewState.message)
.font(.compound.bodyMD)
.multilineTextAlignment(.center)
.foregroundColor(.element.tertiaryContent)
}
.padding(.horizontal, 16)
}
/// The action buttons shown at the bottom of the view.
var buttons: some View {
VStack(spacing: 24) {
Button { context.send(viewAction: .confirm) } label: {
Text(L10n.actionContinue)
}
.buttonStyle(.elementAction(.xLarge))
.accessibilityIdentifier(A11yIdentifiers.serverConfirmationScreen.continue)
Button { context.send(viewAction: .changeServer) } label: {
Text(L10n.screenServerConfirmationChangeServer)
.font(.compound.bodyLGSemibold)
.padding(.vertical, 14)
}
.accessibilityIdentifier(A11yIdentifiers.serverConfirmationScreen.changeServer)
}
}
}
// MARK: - Previews
struct ServerConfirmationScreen_Previews: PreviewProvider {
static let loginViewModel = ServerConfirmationScreenViewModel(authenticationService: MockAuthenticationServiceProxy(),
authenticationFlow: .login)
static let registerViewModel = ServerConfirmationScreenViewModel(authenticationService: MockAuthenticationServiceProxy(),
authenticationFlow: .register)
static var previews: some View {
NavigationStack {
ServerConfirmationScreen(context: loginViewModel.context)
.toolbar(.visible, for: .navigationBar)
}
.previewDisplayName("Login")
NavigationStack {
ServerConfirmationScreen(context: registerViewModel.context)
.toolbar(.visible, for: .navigationBar)
}
.previewDisplayName("Register")
}
}
@@ -39,7 +39,7 @@ final class ServerSelectionScreenCoordinator: CoordinatorProtocol {
init(parameters: ServerSelectionScreenCoordinatorParameters) {
self.parameters = parameters
viewModel = ServerSelectionScreenViewModel(homeserverAddress: parameters.authenticationService.homeserver.address,
viewModel = ServerSelectionScreenViewModel(homeserverAddress: parameters.authenticationService.homeserver.value.address,
isModallyPresented: parameters.isModallyPresented)
userIndicatorController = parameters.userIndicatorController
}
@@ -53,7 +53,7 @@ final class SoftLogoutScreenCoordinator: CoordinatorProtocol {
let homeserver = parameters.authenticationService.homeserver
viewModel = SoftLogoutScreenViewModel(credentials: parameters.credentials,
homeserver: homeserver,
homeserver: homeserver.value,
keyBackupNeeded: parameters.keyBackupNeeded)
oidcAuthenticationPresenter = OIDCAuthenticationPresenter(authenticationService: parameters.authenticationService)
@@ -87,7 +87,7 @@ struct SoftLogoutScreen: View {
.accessibilityIdentifier(A11yIdentifiers.softLogoutScreen.password)
Button { context.send(viewAction: .forgotPassword) } label: {
Text(UntranslatedL10n.softLogoutForgotPassword)
Text(L10n.actionForgotPassword)
.font(.compound.bodyLG)
}
.frame(maxWidth: .infinity, alignment: .trailing)