mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 12:07:55 +00:00
#40: Add the login screen from EI.
- Remove SSO and replace fallback with OIDC.
This commit is contained in:
@@ -9,6 +9,9 @@
|
||||
import Foundation
|
||||
|
||||
final class BuildSettings {
|
||||
|
||||
// MARK: - Servers
|
||||
static let defaultHomeserverURLString = "https://matrix.org"
|
||||
|
||||
// MARK: - Bug report
|
||||
static let bugReportServiceBaseUrlString = "https://riot.im/bugreports"
|
||||
|
||||
@@ -10,6 +10,14 @@ import Foundation
|
||||
// swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length
|
||||
// swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces
|
||||
extension ElementL10n {
|
||||
/// Forgot password
|
||||
public static let authenticationLoginForgotPassword = ElementL10n.tr("Untranslated", "authentication_login_forgot_password")
|
||||
/// Welcome back!
|
||||
public static let authenticationLoginTitle = ElementL10n.tr("Untranslated", "authentication_login_title")
|
||||
/// Join millions for free on the largest public server
|
||||
public static let authenticationServerInfoMatrixDescription = ElementL10n.tr("Untranslated", "authentication_server_info_matrix_description")
|
||||
/// Choose your server to store your data
|
||||
public static let authenticationServerInfoTitle = ElementL10n.tr("Untranslated", "authentication_server_info_title")
|
||||
/// Bubbled Timeline
|
||||
public static let roomTimelineStyleBubbledLongDescription = ElementL10n.tr("Untranslated", "room_timeline_style_bubbled_long_description")
|
||||
/// Plain Timeline
|
||||
|
||||
@@ -133,6 +133,21 @@ private var logger: SwiftyBeaver.Type = {
|
||||
logger.error(message, file, function, line: line)
|
||||
}
|
||||
|
||||
public static func failure(_ message: @autoclosure () -> Any, _
|
||||
file: String = #file,
|
||||
_ function: String = #function,
|
||||
line: Int = #line,
|
||||
context: Any? = nil) {
|
||||
logger.error(message(), file, function, line: line, context: context)
|
||||
assertionFailure("\(message())")
|
||||
}
|
||||
|
||||
@available(swift, obsoleted: 5.4)
|
||||
@objc public static func logFailure(_ message: String, file: String, function: String, line: Int) {
|
||||
logger.error(message, file, function, line: line)
|
||||
assertionFailure(message)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
fileprivate static func configureLogger(_ logger: SwiftyBeaver.Type, withConfiguration configuration: MXLogConfiguration) {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
/// A type that describes an alert to be shown to the user.
|
||||
///
|
||||
/// The alert info can be added to the view state bindings and used as an alert's `item`:
|
||||
/// ```
|
||||
/// MyView
|
||||
/// .alert(item: $viewModel.alertInfo) { $0.alert }
|
||||
/// ```
|
||||
struct AlertInfo<T: Hashable>: Identifiable {
|
||||
/// An identifier that can be used to distinguish one error from another.
|
||||
let id: T
|
||||
/// The alert's title.
|
||||
let title: String
|
||||
/// The alert's message (optional).
|
||||
var message: String?
|
||||
/// The alert's primary button title and action. Defaults to an Ok button with no action.
|
||||
var primaryButton: (title: String, action: (() -> Void)?) = (ElementL10n.ok, nil)
|
||||
/// The alert's secondary button title and action.
|
||||
var secondaryButton: (title: String, action: (() -> Void)?)?
|
||||
}
|
||||
|
||||
extension AlertInfo {
|
||||
/// Initialises the type with the title from an `Error`'s localised description along with the default Ok button.
|
||||
///
|
||||
/// Currently this initialiser creates an alert for every error, however in the future it may be updated to filter
|
||||
/// out some specific errors such as cancellation and networking issues that create too much noise or are
|
||||
/// indicated to the user using other mechanisms.
|
||||
init(error: Error) where T == String {
|
||||
self.init(id: error.localizedDescription,
|
||||
title: error.localizedDescription)
|
||||
}
|
||||
|
||||
/// Initialises the type with a generic title and message for an unknown error along with the default Ok button.
|
||||
/// - Parameters:
|
||||
/// - id: An ID that identifies the error.
|
||||
/// - error: The Error that occurred.
|
||||
init(id: T) {
|
||||
self.id = id
|
||||
title = ElementL10n.dialogTitleError
|
||||
message = ElementL10n.unknownError
|
||||
}
|
||||
}
|
||||
|
||||
extension AlertInfo {
|
||||
private var messageText: Text? {
|
||||
guard let message = message else { return nil }
|
||||
return Text(message)
|
||||
}
|
||||
|
||||
/// Returns a SwiftUI `Alert` created from this alert info, using default button
|
||||
/// styles for both primary and (if set) secondary buttons.
|
||||
var alert: Alert {
|
||||
if let secondaryButton = secondaryButton {
|
||||
return Alert(title: Text(title),
|
||||
message: messageText,
|
||||
primaryButton: alertButton(for: primaryButton),
|
||||
secondaryButton: alertButton(for: secondaryButton))
|
||||
} else {
|
||||
return Alert(title: Text(title),
|
||||
message: messageText,
|
||||
dismissButton: alertButton(for: primaryButton))
|
||||
}
|
||||
}
|
||||
|
||||
private func alertButton(for buttonParameters: (title: String, action: (() -> Void)?)) -> Alert.Button {
|
||||
guard let action = buttonParameters.action else {
|
||||
return .default(Text(buttonParameters.title))
|
||||
}
|
||||
|
||||
return .default(Text(buttonParameters.title), action: action)
|
||||
}
|
||||
}
|
||||
@@ -22,16 +22,14 @@ final class LabelledActivityIndicatorView: UIView {
|
||||
static let padding = UIEdgeInsets(top: 20, left: 40, bottom: 15, right: 40)
|
||||
static let activityIndicatorScale = CGFloat(1.5)
|
||||
static let cornerRadius: CGFloat = 12.0
|
||||
static let stackBackgroundOpacity: CGFloat = 0.9
|
||||
static let stackSpacing: CGFloat = 15
|
||||
static let backgroundOpacity: CGFloat = 0.5
|
||||
}
|
||||
|
||||
private let stackBackgroundView: UIView = {
|
||||
let view = UIView()
|
||||
let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
|
||||
view.layer.cornerRadius = Constants.cornerRadius
|
||||
view.alpha = Constants.stackBackgroundOpacity
|
||||
view.backgroundColor = .gray.withAlphaComponent(0.75)
|
||||
view.clipsToBounds = true
|
||||
return view
|
||||
}()
|
||||
|
||||
@@ -67,6 +65,7 @@ final class LabelledActivityIndicatorView: UIView {
|
||||
private func setup(text: String) {
|
||||
setupStackView()
|
||||
label.text = text
|
||||
label.textColor = .element.primaryContent
|
||||
}
|
||||
|
||||
private func setupStackView() {
|
||||
|
||||
@@ -72,12 +72,27 @@ class RoundedToastView: UIView {
|
||||
|
||||
private func setup(viewState: ToastViewState) {
|
||||
|
||||
backgroundColor = .gray.withAlphaComponent(0.75)
|
||||
backgroundColor = .clear
|
||||
clipsToBounds = true
|
||||
|
||||
setupBackgroundMaterial()
|
||||
setupStackView()
|
||||
stackView.addArrangedSubview(toastView(for: viewState.style))
|
||||
stackView.addArrangedSubview(label)
|
||||
label.text = viewState.label
|
||||
label.textColor = .element.primaryContent
|
||||
}
|
||||
|
||||
private func setupBackgroundMaterial() {
|
||||
let material = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
|
||||
addSubview(material)
|
||||
material.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
material.topAnchor.constraint(equalTo: topAnchor),
|
||||
material.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
material.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
material.trailingAnchor.constraint(equalTo: trailingAnchor)
|
||||
])
|
||||
}
|
||||
|
||||
private func setupStackView() {
|
||||
@@ -101,10 +116,10 @@ class RoundedToastView: UIView {
|
||||
case .loading:
|
||||
return activityIndicator
|
||||
case .success:
|
||||
imageView.image = UIImage(systemName: "checkmark.circle")
|
||||
imageView.image = UIImage(systemName: "checkmark")
|
||||
return imageView
|
||||
case .error:
|
||||
imageView.image = UIImage(systemName: "x.circle")
|
||||
imageView.image = UIImage(systemName: "xmark")
|
||||
return imageView
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,8 +55,6 @@ class AuthenticationCoordinator: Coordinator {
|
||||
switch action {
|
||||
case .login:
|
||||
self.showLoginScreen()
|
||||
case .register:
|
||||
fatalError("Not implemented")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,8 +67,9 @@ class AuthenticationCoordinator: Coordinator {
|
||||
}
|
||||
|
||||
private func showLoginScreen() {
|
||||
let parameters = LoginScreenCoordinatorParameters()
|
||||
let coordinator = LoginScreenCoordinator(parameters: parameters)
|
||||
let homeserver = LoginHomeserver(address: BuildSettings.defaultHomeserverURLString)
|
||||
let parameters = LoginCoordinatorParameters(navigationRouter: navigationRouter, homeserver: homeserver)
|
||||
let coordinator = LoginCoordinator(parameters: parameters)
|
||||
|
||||
coordinator.callback = { [weak self, weak coordinator] action in
|
||||
guard let self = self, let coordinator = coordinator else {
|
||||
@@ -78,9 +77,9 @@ class AuthenticationCoordinator: Coordinator {
|
||||
}
|
||||
|
||||
switch action {
|
||||
case .login(let result):
|
||||
case .login(let username, let password):
|
||||
Task {
|
||||
switch await self.login(username: result.username, password: result.password) {
|
||||
switch await self.login(username: username, password: password) {
|
||||
case .success(let userSession):
|
||||
self.delegate?.authenticationCoordinator(self, didLoginWithSession: userSession)
|
||||
self.remove(childCoordinator: coordinator)
|
||||
@@ -90,6 +89,8 @@ class AuthenticationCoordinator: Coordinator {
|
||||
MXLog.error("Failed logging in user with error: \(error)")
|
||||
}
|
||||
}
|
||||
case .continueWithOIDC:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
//
|
||||
// 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 MatrixRustSDK
|
||||
|
||||
struct LoginCoordinatorParameters {
|
||||
let navigationRouter: NavigationRouterType
|
||||
/// The homeserver to be shown initially.
|
||||
let homeserver: LoginHomeserver
|
||||
}
|
||||
|
||||
enum LoginCoordinatorAction: CustomStringConvertible {
|
||||
/// Login with the associated username and password.
|
||||
case login(username: String, password: String)
|
||||
/// Continue using OIDC.
|
||||
case continueWithOIDC
|
||||
|
||||
/// A string representation of the action, ignoring any associated values that could leak PII.
|
||||
var description: String {
|
||||
switch self {
|
||||
case .login:
|
||||
return "login"
|
||||
case .continueWithOIDC:
|
||||
return "continueWithOIDC"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class LoginCoordinator: Coordinator, Presentable {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private let parameters: LoginCoordinatorParameters
|
||||
private let loginHostingController: UIViewController
|
||||
private var loginViewModel: LoginViewModelProtocol
|
||||
|
||||
private var currentTask: Task<Void, Error>? {
|
||||
willSet {
|
||||
currentTask?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private var navigationRouter: NavigationRouterType { parameters.navigationRouter }
|
||||
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
|
||||
private var activityIndicator: UserIndicator?
|
||||
|
||||
// MARK: Public
|
||||
|
||||
// Must be used only internally
|
||||
var childCoordinators: [Coordinator] = []
|
||||
var callback: (@MainActor (LoginCoordinatorAction) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(parameters: LoginCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
let viewModel = LoginViewModel(homeserver: parameters.homeserver)
|
||||
loginViewModel = viewModel
|
||||
|
||||
let view = LoginScreen(viewModel: viewModel.context)
|
||||
loginHostingController = UIHostingController(rootView: view)
|
||||
|
||||
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: loginHostingController)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
func start() {
|
||||
MXLog.debug("[LoginCoordinator] did start.")
|
||||
|
||||
loginViewModel.callback = { [weak self] action in
|
||||
guard let self = self else { return }
|
||||
MXLog.debug("[LoginCoordinator] LoginViewModel did callback with result: \(action).")
|
||||
|
||||
switch action {
|
||||
case .selectServer:
|
||||
self.presentServerSelectionScreen()
|
||||
case .parseUsername(let username):
|
||||
self.parseUsername(username)
|
||||
case .forgotPassword:
|
||||
self.showForgotPasswordScreen()
|
||||
case .login(let username, let password):
|
||||
self.login(username: username, password: password)
|
||||
case .continueWithOIDC:
|
||||
self.callback?(.continueWithOIDC)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
loginHostingController
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Show a blocking activity indicator whilst saving.
|
||||
private func startLoading(isInteractionBlocking: Bool) {
|
||||
activityIndicator = indicatorPresenter.present(.loading(label: ElementL10n.loading, isInteractionBlocking: isInteractionBlocking))
|
||||
|
||||
if !isInteractionBlocking {
|
||||
loginViewModel.update(isLoading: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a non-blocking indicator that an operation was successful.
|
||||
private func indicateSuccess() {
|
||||
activityIndicator = indicatorPresenter.present(.success(label: ElementL10n.dialogTitleSuccess))
|
||||
}
|
||||
|
||||
/// Show a non-blocking indicator that an operation failed.
|
||||
private func indicateFailure() {
|
||||
activityIndicator = indicatorPresenter.present(.error(label: ElementL10n.dialogTitleError))
|
||||
}
|
||||
|
||||
/// Hide the currently displayed activity indicator.
|
||||
private func stopLoading() {
|
||||
loginViewModel.update(isLoading: false)
|
||||
activityIndicator = nil
|
||||
}
|
||||
|
||||
/// Processes an error to either update the flow or display it to the user.
|
||||
private func handleError(_ error: Error) {
|
||||
loginViewModel.displayError(.alert(error.localizedDescription))
|
||||
}
|
||||
|
||||
/// Requests the authentication coordinator to log in using the specified credentials.
|
||||
private func login(username: String, password: String) {
|
||||
var username = loginViewModel.context.username
|
||||
|
||||
if !isMXID(username: username) {
|
||||
let homeserver = loginViewModel.context.viewState.homeserver
|
||||
username = "@\(username):\(homeserver.address)"
|
||||
}
|
||||
|
||||
callback?(.login(username: username, password: password))
|
||||
}
|
||||
|
||||
/// Parses the specified username and looks up the homeserver when a Matrix ID is entered.
|
||||
private func parseUsername(_ username: String) {
|
||||
guard isMXID(username: username) else { return }
|
||||
|
||||
let domain = String(username.split(separator: ":")[1])
|
||||
|
||||
let homeserver = LoginHomeserver(address: domain)
|
||||
updateViewModel(homeserver: homeserver)
|
||||
indicateSuccess()
|
||||
}
|
||||
|
||||
/// Checks whether the specified username is a Matrix ID or not.
|
||||
private func isMXID(username: String) -> Bool {
|
||||
let range = NSRange(location: 0, length: username.count)
|
||||
|
||||
let detector = try? NSRegularExpression(pattern: MatrixEntityRegex.userId.rawValue, options: .caseInsensitive)
|
||||
return detector?.numberOfMatches(in: username, range: range) ?? 0 > 0
|
||||
}
|
||||
|
||||
/// Updates the view model with a different homeserver.
|
||||
private func updateViewModel(homeserver: LoginHomeserver) {
|
||||
loginViewModel.update(homeserver: homeserver)
|
||||
indicateSuccess()
|
||||
}
|
||||
|
||||
/// Presents the server selection screen as a modal.
|
||||
private func presentServerSelectionScreen() {
|
||||
loginViewModel.displayError(.alert("Not implemented. Enter a full Matrix ID such as @user:server.com"))
|
||||
}
|
||||
|
||||
/// Shows the forgot password screen.
|
||||
private func showForgotPasswordScreen() {
|
||||
loginViewModel.displayError(.alert("Not implemented."))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
/// Information about a homeserver that is ready for display in the authentication flow.
|
||||
struct LoginHomeserver: Equatable {
|
||||
/// The homeserver string to be shown to the user.
|
||||
let address: String
|
||||
/// Whether or not the homeserver is matrix.org.
|
||||
let isMatrixDotOrg: Bool
|
||||
/// The types login supported by the homeserver.
|
||||
let loginMode: LoginMode
|
||||
}
|
||||
|
||||
extension LoginHomeserver {
|
||||
/// Temporary initialiser for use until the FFI has homeserver discovery etc.
|
||||
init(address: String) {
|
||||
let address = Self.sanitized(address).components(separatedBy: "://").last ?? address
|
||||
|
||||
self.address = address
|
||||
isMatrixDotOrg = address == "matrix.org"
|
||||
loginMode = .password
|
||||
}
|
||||
|
||||
/// Sanitizes a user entered homeserver address with the following rules
|
||||
/// - Trim any whitespace.
|
||||
/// - Lowercase the address.
|
||||
/// - Ensure the address contains a scheme, otherwise make it `https`.
|
||||
/// - Remove any trailing slashes.
|
||||
static func sanitized(_ address: String) -> String {
|
||||
var address = address.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
|
||||
if !address.contains("://") {
|
||||
address = "https://\(address)"
|
||||
}
|
||||
|
||||
address = address.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
|
||||
return address
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mocks
|
||||
|
||||
extension LoginHomeserver {
|
||||
/// A mock homeserver that is configured just like matrix.org.
|
||||
static var mockMatrixDotOrg: LoginHomeserver {
|
||||
LoginHomeserver(address: "matrix.org",
|
||||
isMatrixDotOrg: true,
|
||||
loginMode: .password)
|
||||
}
|
||||
|
||||
/// A mock homeserver that supports login and registration via a password but has no SSO providers.
|
||||
static var mockBasicServer: LoginHomeserver {
|
||||
LoginHomeserver(address: "example.com",
|
||||
isMatrixDotOrg: false,
|
||||
loginMode: .password)
|
||||
}
|
||||
|
||||
/// A mock homeserver that supports only supports authentication via a single SSO provider.
|
||||
static var mockOIDC: LoginHomeserver {
|
||||
LoginHomeserver(address: "company.com",
|
||||
isMatrixDotOrg: false,
|
||||
// swiftlint:disable:next force_unwrapping
|
||||
loginMode: .oidc(URL(string: "https://auth.company.com")!))
|
||||
}
|
||||
|
||||
/// A mock homeserver that only with no supported login flows.
|
||||
static var mockUnsupported: LoginHomeserver {
|
||||
LoginHomeserver(address: "server.net",
|
||||
isMatrixDotOrg: false,
|
||||
loginMode: .unsupported)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
/// The supported forms of login that a homeserver allows.
|
||||
enum LoginMode: Equatable {
|
||||
/// The login mode hasn't been determined yet.
|
||||
case unknown
|
||||
/// The homeserver supports login via OpenID Connect at the associated URL.
|
||||
case oidc(URL)
|
||||
/// The homeserver supports login with a password.
|
||||
case password
|
||||
/// The homeserver only allows login with unsupported mechanisms. Use fallback instead.
|
||||
case unsupported
|
||||
|
||||
var supportsOIDCFlow: Bool {
|
||||
switch self {
|
||||
case .oidc:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var supportsPasswordFlow: Bool {
|
||||
switch self {
|
||||
case .password:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var isUnsupported: Bool {
|
||||
switch self {
|
||||
case .unsupported:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//
|
||||
// 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 LoginViewModelAction: 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.
|
||||
case forgotPassword
|
||||
/// Login using the supplied credentials.
|
||||
case login(username: String, password: String)
|
||||
/// Continue using OIDC.
|
||||
case continueWithOIDC
|
||||
|
||||
/// 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:
|
||||
return "forgotPassword"
|
||||
case .login:
|
||||
return "login"
|
||||
case .continueWithOIDC:
|
||||
return "continueWithOIDC"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: View
|
||||
|
||||
struct LoginViewState: BindableState {
|
||||
/// Data about the selected homeserver.
|
||||
var homeserver: LoginHomeserver
|
||||
/// Whether a new homeserver is currently being loaded.
|
||||
var isLoading: Bool = false
|
||||
/// View state that can be bound to from SwiftUI.
|
||||
var bindings: LoginBindings
|
||||
|
||||
/// The types of login supported by the homeserver.
|
||||
var loginMode: LoginMode { homeserver.loginMode }
|
||||
|
||||
/// `true` if the username and password are ready to be submitted.
|
||||
var hasValidCredentials: Bool {
|
||||
!bindings.username.isEmpty && !bindings.password.isEmpty
|
||||
}
|
||||
|
||||
/// `true` when valid credentials have been entered and a homeserver has been loaded.
|
||||
var canSubmit: Bool {
|
||||
hasValidCredentials && !isLoading
|
||||
}
|
||||
}
|
||||
|
||||
struct LoginBindings {
|
||||
/// The username input by the user.
|
||||
var username = ""
|
||||
/// The password input by the user.
|
||||
var password = ""
|
||||
/// Information describing the currently displayed alert.
|
||||
var alertInfo: AlertInfo<LoginErrorType>?
|
||||
}
|
||||
|
||||
enum LoginViewAction {
|
||||
/// 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.
|
||||
case forgotPassword
|
||||
/// Continue using the input username and password.
|
||||
case next
|
||||
/// Continue using OIDC.
|
||||
case continueWithOIDC
|
||||
}
|
||||
|
||||
enum LoginErrorType: Hashable {
|
||||
/// A specific error message shown in an alert.
|
||||
case alert(String)
|
||||
/// Looking up the homeserver from the username failed.
|
||||
case invalidHomeserver
|
||||
/// The response from the homeserver was unexpected.
|
||||
case unknown
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
typealias LoginViewModelType = StateStoreViewModel<LoginViewState, LoginViewAction>
|
||||
|
||||
class LoginViewModel: LoginViewModelType, LoginViewModelProtocol {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Public
|
||||
|
||||
var callback: (@MainActor (LoginViewModelAction) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(homeserver: LoginHomeserver) {
|
||||
let bindings = LoginBindings()
|
||||
let viewState = LoginViewState(homeserver: homeserver, bindings: bindings)
|
||||
|
||||
super.init(initialViewState: viewState)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: LoginViewAction) async {
|
||||
switch viewAction {
|
||||
case .selectServer:
|
||||
callback?(.selectServer)
|
||||
case .parseUsername:
|
||||
callback?(.parseUsername(state.bindings.username))
|
||||
case .forgotPassword:
|
||||
callback?(.forgotPassword)
|
||||
case .next:
|
||||
callback?(.login(username: state.bindings.username, password: state.bindings.password))
|
||||
case .continueWithOIDC:
|
||||
callback?(.continueWithOIDC)
|
||||
}
|
||||
}
|
||||
|
||||
func update(isLoading: Bool) {
|
||||
guard state.isLoading != isLoading else { return }
|
||||
state.isLoading = isLoading
|
||||
}
|
||||
|
||||
func update(homeserver: LoginHomeserver) {
|
||||
state.homeserver = homeserver
|
||||
}
|
||||
|
||||
func displayError(_ type: LoginErrorType) {
|
||||
switch type {
|
||||
case .alert(let message):
|
||||
state.bindings.alertInfo = AlertInfo(id: type,
|
||||
title: ElementL10n.dialogTitleError,
|
||||
message: message)
|
||||
case .invalidHomeserver:
|
||||
state.bindings.alertInfo = AlertInfo(id: type,
|
||||
title: ElementL10n.dialogTitleError,
|
||||
message: ElementL10n.loginSigninMatrixIdErrorInvalidMatrixId)
|
||||
case .unknown:
|
||||
state.bindings.alertInfo = AlertInfo(id: type)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
@MainActor
|
||||
protocol LoginViewModelProtocol {
|
||||
|
||||
var callback: (@MainActor (LoginViewModelAction) -> Void)? { get set }
|
||||
var context: LoginViewModelType.Context { get }
|
||||
|
||||
/// Update the view to reflect that a new homeserver is being loaded.
|
||||
/// - Parameter isLoading: Whether or not the homeserver is being loaded.
|
||||
func update(isLoading: Bool)
|
||||
|
||||
/// Update the view with new homeserver information.
|
||||
/// - Parameter homeserver: The view data for the homeserver. This can be generated using `AuthenticationService.Homeserver.viewData`.
|
||||
func update(homeserver: LoginHomeserver)
|
||||
|
||||
/// Display an error to the user.
|
||||
/// - Parameter type: The type of error to be displayed.
|
||||
func displayError(_ type: LoginErrorType)
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
//
|
||||
// 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 LoginScreen: View {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
/// The focus state of the username text field.
|
||||
@FocusState private var isUsernameFocused: Bool
|
||||
/// The focus state of the password text field.
|
||||
@FocusState private var isPasswordFocused: Bool
|
||||
|
||||
// MARK: Public
|
||||
|
||||
@ObservedObject var viewModel: LoginViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
.padding(.top, UIConstants.topPaddingToNavigationBar)
|
||||
.padding(.bottom, 36)
|
||||
|
||||
serverInfo
|
||||
.padding(.leading, 12)
|
||||
|
||||
Rectangle()
|
||||
.fill(Color.element.quinaryContent)
|
||||
.frame(height: 1)
|
||||
.padding(.vertical, 21)
|
||||
|
||||
switch viewModel.viewState.loginMode {
|
||||
case .password:
|
||||
loginForm
|
||||
case .oidc:
|
||||
oidcButton
|
||||
default:
|
||||
loginUnavailableText
|
||||
}
|
||||
|
||||
}
|
||||
.readableFrame()
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
.background(Color.element.background.ignoresSafeArea())
|
||||
.alert(item: $viewModel.alertInfo) { $0.alert }
|
||||
}
|
||||
|
||||
/// The header containing a Welcome Back title.
|
||||
var header: some View {
|
||||
Text(ElementL10n.authenticationLoginTitle)
|
||||
.font(.element.title2B)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.element.primaryContent)
|
||||
}
|
||||
|
||||
/// The sever information section that includes a button to select a different server.
|
||||
var serverInfo: some View {
|
||||
LoginServerInfoSection(address: viewModel.viewState.homeserver.address,
|
||||
showMatrixDotOrgInfo: viewModel.viewState.homeserver.isMatrixDotOrg) {
|
||||
viewModel.send(viewAction: .selectServer)
|
||||
}
|
||||
}
|
||||
|
||||
/// The form with text fields for username and password, along with a submit button.
|
||||
var loginForm: some View {
|
||||
VStack(spacing: 14) {
|
||||
TextField(ElementL10n.loginSigninUsernameHint, text: $viewModel.username)
|
||||
.focused($isUsernameFocused)
|
||||
.textFieldStyle(.elementInput())
|
||||
.disableAutocorrection(true)
|
||||
.textContentType(.username)
|
||||
.autocapitalization(.none)
|
||||
.submitLabel(.next)
|
||||
.onChange(of: isUsernameFocused, perform: usernameFocusChanged)
|
||||
.onSubmit { isPasswordFocused = true }
|
||||
.accessibilityIdentifier("usernameTextField")
|
||||
|
||||
Spacer().frame(height: 20)
|
||||
|
||||
SecureField(ElementL10n.loginSignupPasswordHint, text: $viewModel.password)
|
||||
.focused($isPasswordFocused)
|
||||
.textFieldStyle(.elementInput())
|
||||
.textContentType(.password)
|
||||
.submitLabel(.done)
|
||||
.onSubmit(submit)
|
||||
.accessibilityIdentifier("passwordTextField")
|
||||
|
||||
Button { viewModel.send(viewAction: .forgotPassword) } label: {
|
||||
Text(ElementL10n.authenticationLoginForgotPassword)
|
||||
.font(.element.body)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
Button(action: submit) {
|
||||
Text(ElementL10n.loginSignupSubmit)
|
||||
}
|
||||
.buttonStyle(.elementAction(.xLarge))
|
||||
.disabled(!viewModel.viewState.canSubmit)
|
||||
.accessibilityIdentifier("nextButton")
|
||||
}
|
||||
}
|
||||
|
||||
/// The OIDC button that can be used for login.
|
||||
var oidcButton: some View {
|
||||
Button { viewModel.send(viewAction: .continueWithOIDC) } label: {
|
||||
Text(ElementL10n.loginContinue)
|
||||
}
|
||||
.buttonStyle(.elementAction(.xLarge))
|
||||
.accessibilityIdentifier("oidcButton")
|
||||
}
|
||||
|
||||
/// Text shown if neither password or OIDC login is supported.
|
||||
var loginUnavailableText: some View {
|
||||
Text(ElementL10n.autodiscoverWellKnownError)
|
||||
.font(.body)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.element.primaryContent)
|
||||
.frame(maxWidth: .infinity)
|
||||
.accessibilityIdentifier("unsupportedServerText")
|
||||
}
|
||||
|
||||
/// Parses the username for a homeserver.
|
||||
private func usernameFocusChanged(isFocussed: Bool) {
|
||||
guard !isFocussed, !viewModel.username.isEmpty else { return }
|
||||
viewModel.send(viewAction: .parseUsername)
|
||||
}
|
||||
|
||||
/// Sends the `next` view action so long as valid credentials have been input.
|
||||
private func submit() {
|
||||
guard viewModel.viewState.canSubmit else { return }
|
||||
viewModel.send(viewAction: .next)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct Login_Previews: PreviewProvider {
|
||||
static let credentialsViewModel: LoginViewModel = {
|
||||
let viewModel = LoginViewModel(homeserver: .mockMatrixDotOrg)
|
||||
viewModel.context.username = "alice"
|
||||
viewModel.context.password = "password"
|
||||
return viewModel
|
||||
}()
|
||||
|
||||
static var previews: some View {
|
||||
screen(for: LoginViewModel(homeserver: .mockMatrixDotOrg))
|
||||
screen(for: credentialsViewModel)
|
||||
screen(for: LoginViewModel(homeserver: .mockBasicServer))
|
||||
screen(for: LoginViewModel(homeserver: .mockOIDC))
|
||||
}
|
||||
|
||||
static func screen(for viewModel: LoginViewModel) -> some View {
|
||||
NavigationView {
|
||||
LoginScreen(viewModel: viewModel.context)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.tint(.element.accent)
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
}
|
||||
}
|
||||
@@ -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 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
|
||||
/// Whether or not to show the matrix.org description.
|
||||
let showMatrixDotOrgInfo: Bool
|
||||
/// The action performed when tapping the edit button.
|
||||
let editAction: () -> Void
|
||||
|
||||
// MARK: - Views
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(ElementL10n.authenticationServerInfoTitle)
|
||||
.font(.element.subheadline)
|
||||
.foregroundColor(.element.secondaryContent)
|
||||
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(address)
|
||||
.font(.element.body)
|
||||
.foregroundColor(.element.primaryContent)
|
||||
|
||||
if showMatrixDotOrgInfo {
|
||||
Text(ElementL10n.authenticationServerInfoMatrixDescription)
|
||||
.font(.element.caption1)
|
||||
.foregroundColor(.element.tertiaryContent)
|
||||
.accessibilityIdentifier("serverDescriptionText")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: editAction) {
|
||||
Text(ElementL10n.edit)
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.buttonStyle(.elementGhost())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
//
|
||||
// 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 LoginScreenCoordinatorParameters {
|
||||
|
||||
}
|
||||
|
||||
enum LoginScreenCoordinatorAction {
|
||||
case login((username: String, password: String))
|
||||
}
|
||||
|
||||
final class LoginScreenCoordinator: Coordinator, Presentable {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private let parameters: LoginScreenCoordinatorParameters
|
||||
private let loginScreenHostingController: UIViewController
|
||||
private var loginScreenViewModel: LoginScreenViewModelProtocol
|
||||
|
||||
// MARK: Public
|
||||
|
||||
// Must be used only internally
|
||||
var childCoordinators: [Coordinator] = []
|
||||
var callback: ((LoginScreenCoordinatorAction) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(parameters: LoginScreenCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
loginScreenViewModel = LoginScreenViewModel()
|
||||
let view = LoginScreen(context: loginScreenViewModel.context)
|
||||
|
||||
loginScreenHostingController = UIHostingController(rootView: view)
|
||||
loginScreenHostingController.isModalInPresentation = true
|
||||
|
||||
loginScreenViewModel.callback = { [weak self] action in
|
||||
MXLog.debug("[LoginScreenCoordinator] LoginScreenViewModel did complete.")
|
||||
guard let self = self else { return }
|
||||
switch action {
|
||||
case .login(let credentials):
|
||||
self.callback?(.login(credentials))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
func start() {
|
||||
|
||||
}
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
return self.loginScreenHostingController
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
enum LoginScreenViewModelAction {
|
||||
case login((username: String, password: String))
|
||||
}
|
||||
|
||||
enum LoginScreenViewAction {
|
||||
case login
|
||||
}
|
||||
|
||||
struct LoginScreenViewState: BindableState {
|
||||
var bindings: LoginScreenViewStateBindings
|
||||
var hasCredentials: Bool { !bindings.username.isEmpty && !bindings.password.isEmpty }
|
||||
}
|
||||
|
||||
struct LoginScreenViewStateBindings {
|
||||
var username: String
|
||||
var password: String
|
||||
}
|
||||
|
||||
struct LoginScreenErrorAlertInfo: Identifiable {
|
||||
enum AlertType {
|
||||
case genericFailure
|
||||
}
|
||||
|
||||
let id: AlertType
|
||||
let title: String
|
||||
let subtitle: String
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
typealias LoginScreenViewModelType = StateStoreViewModel<LoginScreenViewState, LoginScreenViewAction>
|
||||
|
||||
class LoginScreenViewModel: LoginScreenViewModelType, LoginScreenViewModelProtocol {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
// MARK: Public
|
||||
|
||||
var callback: ((LoginScreenViewModelAction) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init() {
|
||||
super.init(initialViewState: LoginScreenViewState(bindings: LoginScreenViewStateBindings(username: "",
|
||||
password: "")))
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: LoginScreenViewAction) async {
|
||||
switch viewAction {
|
||||
case .login:
|
||||
callback?(.login((username: context.username, password: context.password)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
@MainActor
|
||||
protocol LoginScreenViewModelProtocol {
|
||||
var callback: ((LoginScreenViewModelAction) -> Void)? { get set }
|
||||
var context: LoginScreenViewModelType.Context { get }
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
struct LoginScreen: View {
|
||||
|
||||
@ObservedObject var context: LoginScreenViewModel.Context
|
||||
|
||||
enum Field { case username, password }
|
||||
@FocusState private var focussedField: Field?
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
TextField("Username", text: $context.username)
|
||||
.textFieldStyle(.elementInput())
|
||||
.disableAutocorrection(true)
|
||||
.textContentType(.username)
|
||||
.autocapitalization(.none)
|
||||
.focused($focussedField, equals: .username)
|
||||
.submitLabel(.next)
|
||||
.onSubmit { focussedField = .password }
|
||||
|
||||
SecureField("Password", text: $context.password)
|
||||
.textFieldStyle(.elementInput())
|
||||
.textContentType(.password)
|
||||
.focused($focussedField, equals: .password)
|
||||
.submitLabel(.go)
|
||||
.onSubmit(submit)
|
||||
|
||||
Button("Login", action: submit)
|
||||
.buttonStyle(.elementAction(.xLarge))
|
||||
.disabled(!context.viewState.hasCredentials)
|
||||
}
|
||||
.padding(.horizontal, 8.0)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.element.background.ignoresSafeArea())
|
||||
.navigationTitle("Login")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
func submit() {
|
||||
guard context.viewState.hasCredentials else { return }
|
||||
context.send(viewAction: .login)
|
||||
focussedField = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct LoginScreen_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let viewModel = LoginScreenViewModel()
|
||||
NavigationView {
|
||||
LoginScreen(context: viewModel.context)
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
}
|
||||
}
|
||||
@@ -54,8 +54,6 @@ final class SplashScreenCoordinator: Coordinator, Presentable {
|
||||
switch action {
|
||||
case .login:
|
||||
self.callback?(.login)
|
||||
case .register:
|
||||
self.callback?(.register)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import SwiftUI
|
||||
// MARK: - Coordinator
|
||||
|
||||
enum SplashScreenCoordinatorAction {
|
||||
case register
|
||||
case login
|
||||
}
|
||||
|
||||
@@ -33,7 +32,6 @@ struct SplashScreenPageContent {
|
||||
// MARK: View model
|
||||
|
||||
enum SplashScreenViewModelAction {
|
||||
case register
|
||||
case login
|
||||
}
|
||||
|
||||
@@ -91,6 +89,5 @@ struct SplashScreenBindings {
|
||||
}
|
||||
|
||||
enum SplashScreenViewAction {
|
||||
case register
|
||||
case login
|
||||
}
|
||||
|
||||
@@ -39,18 +39,8 @@ class SplashScreenViewModel: SplashScreenViewModelType, SplashScreenViewModelPro
|
||||
|
||||
override func process(viewAction: SplashScreenViewAction) async {
|
||||
switch viewAction {
|
||||
case .register:
|
||||
register()
|
||||
case .login:
|
||||
login()
|
||||
callback?(.login)
|
||||
}
|
||||
}
|
||||
|
||||
private func register() {
|
||||
callback?(.register)
|
||||
}
|
||||
|
||||
private func login() {
|
||||
callback?(.login)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import DesignKit
|
||||
|
||||
/// The splash screen shown at the beginning of the onboarding flow.
|
||||
struct SplashScreen: View {
|
||||
@@ -219,6 +220,6 @@ struct SplashScreen_Previews: PreviewProvider {
|
||||
|
||||
static var previews: some View {
|
||||
SplashScreen(viewModel: viewModel.context)
|
||||
.accentColor(.element.accent)
|
||||
.tint(.element.accent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import Foundation
|
||||
|
||||
enum UITestScreenIdentifier: String {
|
||||
case login
|
||||
case loginOIDC
|
||||
case loginUnsupported
|
||||
case simpleRegular
|
||||
case simpleUpgrade
|
||||
case settings
|
||||
|
||||
@@ -50,7 +50,17 @@ struct MockScreen: Identifiable {
|
||||
var coordinator: Coordinator & Presentable {
|
||||
switch id {
|
||||
case .login:
|
||||
return LoginScreenCoordinator(parameters: .init())
|
||||
let router = NavigationRouter(navigationController: ElementNavigationController())
|
||||
return LoginCoordinator(parameters: .init(navigationRouter: router,
|
||||
homeserver: .mockMatrixDotOrg))
|
||||
case .loginOIDC:
|
||||
let router = NavigationRouter(navigationController: ElementNavigationController())
|
||||
return LoginCoordinator(parameters: .init(navigationRouter: router,
|
||||
homeserver: .mockOIDC))
|
||||
case .loginUnsupported:
|
||||
let router = NavigationRouter(navigationController: ElementNavigationController())
|
||||
return LoginCoordinator(parameters: .init(navigationRouter: router,
|
||||
homeserver: .mockUnsupported))
|
||||
case .simpleRegular:
|
||||
return TemplateSimpleScreenCoordinator(parameters: .init(promptType: .regular))
|
||||
case .simpleUpgrade:
|
||||
|
||||
Reference in New Issue
Block a user