mirror of
https://gitlab.opencode.de/bwi/bundesmessenger/clients/bundesmessenger-ios.git
synced 2026-05-21 23:22:08 +02:00
Merge branch 'develop' into johannes/session-name-trumps-device-type-name
This commit is contained in:
@@ -24,6 +24,8 @@ struct AuthenticationHomeserverViewData: Equatable {
|
||||
let showLoginForm: Bool
|
||||
/// Whether or not to display the username and password text fields during registration.
|
||||
let showRegistrationForm: Bool
|
||||
/// Whether or not to display the QR login button during login.
|
||||
let showQRLogin: Bool
|
||||
/// The supported SSO login options.
|
||||
let ssoIdentityProviders: [SSOIdentityProvider]
|
||||
}
|
||||
@@ -36,6 +38,7 @@ extension AuthenticationHomeserverViewData {
|
||||
AuthenticationHomeserverViewData(address: "matrix.org",
|
||||
showLoginForm: true,
|
||||
showRegistrationForm: true,
|
||||
showQRLogin: false,
|
||||
ssoIdentityProviders: [
|
||||
SSOIdentityProvider(id: "1", name: "Apple", brand: "apple", iconURL: nil),
|
||||
SSOIdentityProvider(id: "2", name: "Facebook", brand: "facebook", iconURL: nil),
|
||||
@@ -50,6 +53,7 @@ extension AuthenticationHomeserverViewData {
|
||||
AuthenticationHomeserverViewData(address: "example.com",
|
||||
showLoginForm: true,
|
||||
showRegistrationForm: true,
|
||||
showQRLogin: false,
|
||||
ssoIdentityProviders: [])
|
||||
}
|
||||
|
||||
@@ -58,6 +62,7 @@ extension AuthenticationHomeserverViewData {
|
||||
AuthenticationHomeserverViewData(address: "company.com",
|
||||
showLoginForm: false,
|
||||
showRegistrationForm: false,
|
||||
showQRLogin: false,
|
||||
ssoIdentityProviders: [SSOIdentityProvider(id: "test", name: "SAML", brand: nil, iconURL: nil)])
|
||||
}
|
||||
|
||||
@@ -66,6 +71,7 @@ extension AuthenticationHomeserverViewData {
|
||||
AuthenticationHomeserverViewData(address: "company.com",
|
||||
showLoginForm: false,
|
||||
showRegistrationForm: false,
|
||||
showQRLogin: false,
|
||||
ssoIdentityProviders: [])
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -48,6 +48,10 @@ protocol AuthenticationRestClient: AnyObject {
|
||||
func forgetPassword(for email: String, clientSecret: String, sendAttempt: UInt) async throws -> String
|
||||
func resetPassword(parameters: CheckResetPasswordParameters) async throws
|
||||
func resetPassword(parameters: [String: Any]) async throws
|
||||
|
||||
// MARK: Versions
|
||||
|
||||
func supportedMatrixVersions() async throws -> MXMatrixVersions
|
||||
}
|
||||
|
||||
extension MXRestClient: AuthenticationRestClient { }
|
||||
|
||||
+5
-1
@@ -259,10 +259,14 @@ class AuthenticationService: NSObject {
|
||||
}
|
||||
|
||||
let loginFlow = try await getLoginFlowResult(client: client)
|
||||
|
||||
let supportsQRLogin = try await QRLoginService(client: client,
|
||||
mode: .notAuthenticated).isServiceAvailable()
|
||||
|
||||
let homeserver = AuthenticationState.Homeserver(address: loginFlow.homeserverAddress,
|
||||
addressFromUser: homeserverAddress,
|
||||
preferredLoginMode: loginFlow.loginMode)
|
||||
preferredLoginMode: loginFlow.loginMode,
|
||||
supportsQRLogin: supportsQRLogin)
|
||||
return (client, homeserver)
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,9 @@ struct AuthenticationState {
|
||||
|
||||
/// The preferred login mode for the server
|
||||
var preferredLoginMode: LoginMode = .unknown
|
||||
|
||||
/// Flag indicating whether the homeserver supports logging in via a QR code.
|
||||
var supportsQRLogin = false
|
||||
|
||||
/// The response returned when querying the homeserver for registration flows.
|
||||
var registrationFlow: RegistrationResult?
|
||||
@@ -67,6 +70,7 @@ struct AuthenticationState {
|
||||
AuthenticationHomeserverViewData(address: displayableAddress,
|
||||
showLoginForm: preferredLoginMode.supportsPasswordFlow,
|
||||
showRegistrationForm: registrationFlow != nil && !needsRegistrationFallback,
|
||||
showQRLogin: supportsQRLogin,
|
||||
ssoIdentityProviders: preferredLoginMode.ssoIdentityProviders ?? [])
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ enum AuthenticationLoginViewModelResult: CustomStringConvertible {
|
||||
case continueWithSSO(SSOIdentityProvider)
|
||||
/// Continue using the fallback page
|
||||
case fallback
|
||||
/// Continue with QR login
|
||||
case qrLogin
|
||||
|
||||
/// A string representation of the result, ignoring any associated values that could leak PII.
|
||||
var description: String {
|
||||
@@ -47,6 +49,8 @@ enum AuthenticationLoginViewModelResult: CustomStringConvertible {
|
||||
return "continueWithSSO: \(provider)"
|
||||
case .fallback:
|
||||
return "fallback"
|
||||
case .qrLogin:
|
||||
return "qrLogin"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,6 +103,8 @@ enum AuthenticationLoginViewAction {
|
||||
case fallback
|
||||
/// Continue using the supplied SSO provider.
|
||||
case continueWithSSO(SSOIdentityProvider)
|
||||
/// Continue using QR login
|
||||
case qrLogin
|
||||
}
|
||||
|
||||
enum AuthenticationLoginErrorType: Hashable {
|
||||
|
||||
@@ -50,6 +50,8 @@ class AuthenticationLoginViewModel: AuthenticationLoginViewModelType, Authentica
|
||||
Task { await callback?(.fallback) }
|
||||
case .continueWithSSO(let provider):
|
||||
Task { await callback?(.continueWithSSO(provider)) }
|
||||
case .qrLogin:
|
||||
Task { await callback?(.qrLogin) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+24
@@ -126,6 +126,8 @@ final class AuthenticationLoginCoordinator: Coordinator, Presentable {
|
||||
self.callback?(.continueWithSSO(identityProvider))
|
||||
case .fallback:
|
||||
self.callback?(.fallback)
|
||||
case .qrLogin:
|
||||
self.showQRLoginScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -282,6 +284,28 @@ final class AuthenticationLoginCoordinator: Coordinator, Presentable {
|
||||
|
||||
navigationRouter.present(modalRouter, animated: true)
|
||||
}
|
||||
|
||||
/// Shows the QR login screen.
|
||||
@MainActor private func showQRLoginScreen() {
|
||||
MXLog.debug("[AuthenticationLoginCoordinator] showQRLoginScreen")
|
||||
|
||||
let service = QRLoginService(client: parameters.authenticationService.client,
|
||||
mode: .notAuthenticated)
|
||||
let parameters = AuthenticationQRLoginStartCoordinatorParameters(navigationRouter: navigationRouter,
|
||||
qrLoginService: service)
|
||||
let coordinator = AuthenticationQRLoginStartCoordinator(parameters: parameters)
|
||||
coordinator.callback = { [weak self, weak coordinator] _ in
|
||||
guard let self = self, let coordinator = coordinator else { return }
|
||||
self.remove(childCoordinator: coordinator)
|
||||
}
|
||||
|
||||
coordinator.start()
|
||||
add(childCoordinator: coordinator)
|
||||
|
||||
navigationRouter.push(coordinator, animated: true) { [weak self] in
|
||||
self?.remove(childCoordinator: coordinator)
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the view model to reflect any changes made to the homeserver.
|
||||
@MainActor private func updateViewModel() {
|
||||
|
||||
@@ -50,6 +50,10 @@ struct AuthenticationLoginScreen: View {
|
||||
if viewModel.viewState.homeserver.showLoginForm {
|
||||
loginForm
|
||||
}
|
||||
|
||||
if viewModel.viewState.homeserver.showQRLogin {
|
||||
qrLoginButton
|
||||
}
|
||||
|
||||
if viewModel.viewState.homeserver.showLoginForm, viewModel.viewState.showSSOButtons {
|
||||
Text(VectorL10n.or)
|
||||
@@ -129,6 +133,16 @@ struct AuthenticationLoginScreen: View {
|
||||
.accessibilityIdentifier("nextButton")
|
||||
}
|
||||
}
|
||||
|
||||
/// A QR login button that can be used for login.
|
||||
var qrLoginButton: some View {
|
||||
Button(action: qrLogin) {
|
||||
Text(VectorL10n.authenticationLoginWithQr)
|
||||
}
|
||||
.buttonStyle(SecondaryActionButtonStyle(font: theme.fonts.bodySB))
|
||||
.padding(.vertical)
|
||||
.accessibilityIdentifier("qrLoginButton")
|
||||
}
|
||||
|
||||
/// A list of SSO buttons that can be used for login.
|
||||
var ssoButtons: some View {
|
||||
@@ -174,6 +188,11 @@ struct AuthenticationLoginScreen: View {
|
||||
func fallback() {
|
||||
viewModel.send(viewAction: .fallback)
|
||||
}
|
||||
|
||||
/// Sends the `qrLogin` view action.
|
||||
func qrLogin() {
|
||||
viewModel.send(viewAction: .qrLogin)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
struct QRLoginCode: Codable {
|
||||
var user: String?
|
||||
var initiator: QRLoginDataInitiatorDevice?
|
||||
var rendezvous: QRLoginRendezvous?
|
||||
}
|
||||
|
||||
enum QRLoginDataInitiatorDevice: String, Codable {
|
||||
case new = "new_device"
|
||||
case existing = "existing_device"
|
||||
}
|
||||
|
||||
struct QRLoginRendezvous: Codable {
|
||||
var transport: QRLoginRendezvousTransportDetails
|
||||
var algorithm: String?
|
||||
var key: String?
|
||||
}
|
||||
|
||||
struct QRLoginRendezvousTransportDetails: Codable {
|
||||
var type: String
|
||||
var uri: String?
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
//
|
||||
// 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 AVFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
import MatrixSDK
|
||||
import SwiftUI
|
||||
import ZXingObjC
|
||||
|
||||
// MARK: - QRLoginService
|
||||
|
||||
class QRLoginService: NSObject, QRLoginServiceProtocol {
|
||||
private let client: AuthenticationRestClient
|
||||
private var isCameraReady = false
|
||||
private lazy var zxCapture = ZXCapture()
|
||||
|
||||
private let cameraAccessManager = CameraAccessManager()
|
||||
|
||||
init(client: AuthenticationRestClient,
|
||||
mode: QRLoginServiceMode,
|
||||
state: QRLoginServiceState = .initial) {
|
||||
self.client = client
|
||||
self.mode = mode
|
||||
self.state = state
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: QRLoginServiceProtocol
|
||||
|
||||
let mode: QRLoginServiceMode
|
||||
|
||||
var state: QRLoginServiceState {
|
||||
didSet {
|
||||
if state != oldValue {
|
||||
callbacks.send(.didUpdateState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let callbacks = PassthroughSubject<QRLoginServiceCallback, Never>()
|
||||
|
||||
func isServiceAvailable() async throws -> Bool {
|
||||
guard BuildSettings.enableQRLogin else {
|
||||
return false
|
||||
}
|
||||
return try await client.supportedMatrixVersions().supportsQRLogin
|
||||
}
|
||||
|
||||
func generateQRCode() async throws -> QRLoginCode {
|
||||
let transport = QRLoginRendezvousTransportDetails(type: "http.v1",
|
||||
uri: "")
|
||||
let rendezvous = QRLoginRendezvous(transport: transport,
|
||||
algorithm: "m.rendezvous.v1.curve25519-aes-sha256",
|
||||
key: "")
|
||||
return QRLoginCode(user: client.credentials.userId,
|
||||
initiator: .new,
|
||||
rendezvous: rendezvous)
|
||||
}
|
||||
|
||||
func scannerView() -> AnyView {
|
||||
let frame = UIScreen.main.bounds
|
||||
let view = UIView(frame: frame)
|
||||
zxCapture.layer.frame = frame
|
||||
view.layer.addSublayer(zxCapture.layer)
|
||||
return AnyView(ViewWrapper(view: view))
|
||||
}
|
||||
|
||||
func startScanning() {
|
||||
Task { @MainActor in
|
||||
if cameraAccessManager.isCameraAvailable {
|
||||
let granted = await cameraAccessManager.requestCameraAccessIfNeeded()
|
||||
if granted {
|
||||
state = .scanningQR
|
||||
zxCapture.delegate = self
|
||||
zxCapture.camera = zxCapture.back()
|
||||
zxCapture.start()
|
||||
} else {
|
||||
state = .failed(error: .noCameraAccess)
|
||||
}
|
||||
} else {
|
||||
state = .failed(error: .noCameraAvailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopScanning(destroy: Bool) {
|
||||
guard zxCapture.running else {
|
||||
return
|
||||
}
|
||||
|
||||
if destroy {
|
||||
zxCapture.hard_stop()
|
||||
} else {
|
||||
zxCapture.stop()
|
||||
}
|
||||
}
|
||||
|
||||
func processScannedQR(_ data: Data) {
|
||||
state = .connectingToDevice
|
||||
do {
|
||||
let code = try JSONDecoder().decode(QRLoginCode.self, from: data)
|
||||
MXLog.debug("[QRLoginService] processScannedQR: \(code)")
|
||||
// TODO: implement
|
||||
} catch {
|
||||
state = .failed(error: .invalidQR)
|
||||
}
|
||||
}
|
||||
|
||||
func confirmCode() {
|
||||
switch state {
|
||||
case .waitingForConfirmation(let code):
|
||||
// TODO: implement
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func restart() {
|
||||
state = .initial
|
||||
}
|
||||
|
||||
func reset() {
|
||||
stopScanning(destroy: false)
|
||||
state = .initial
|
||||
}
|
||||
|
||||
deinit {
|
||||
stopScanning(destroy: true)
|
||||
}
|
||||
|
||||
// MARK: Private
|
||||
}
|
||||
|
||||
// MARK: - ZXCaptureDelegate
|
||||
|
||||
extension QRLoginService: ZXCaptureDelegate {
|
||||
func captureCameraIsReady(_ capture: ZXCapture!) {
|
||||
isCameraReady = true
|
||||
}
|
||||
|
||||
func captureResult(_ capture: ZXCapture!, result: ZXResult!) {
|
||||
guard isCameraReady,
|
||||
let result = result,
|
||||
result.barcodeFormat == kBarcodeFormatQRCode else {
|
||||
return
|
||||
}
|
||||
|
||||
stopScanning(destroy: false)
|
||||
|
||||
if let bytes = result.resultMetadata.object(forKey: kResultMetadataTypeByteSegments.rawValue) as? NSArray,
|
||||
let byteArray = bytes.firstObject as? ZXByteArray {
|
||||
let data = Data(bytes: UnsafeRawPointer(byteArray.array), count: Int(byteArray.length))
|
||||
|
||||
callbacks.send(.didScanQR(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ViewWrapper
|
||||
|
||||
private struct ViewWrapper: UIViewRepresentable {
|
||||
var view: UIView
|
||||
|
||||
func makeUIView(context: Context) -> some UIView {
|
||||
view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIViewType, context: Context) { }
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
import SwiftUI
|
||||
|
||||
class MockQRLoginService: QRLoginServiceProtocol {
|
||||
init(withState state: QRLoginServiceState = .initial,
|
||||
mode: QRLoginServiceMode = .notAuthenticated) {
|
||||
self.state = state
|
||||
self.mode = mode
|
||||
}
|
||||
|
||||
// MARK: - QRLoginServiceProtocol
|
||||
|
||||
let mode: QRLoginServiceMode
|
||||
|
||||
var state: QRLoginServiceState {
|
||||
didSet {
|
||||
if state != oldValue {
|
||||
callbacks.send(.didUpdateState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let callbacks = PassthroughSubject<QRLoginServiceCallback, Never>()
|
||||
|
||||
func isServiceAvailable() async throws -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func generateQRCode() async throws -> QRLoginCode {
|
||||
let transport = QRLoginRendezvousTransportDetails(type: "http.v1",
|
||||
uri: "https://matrix.org")
|
||||
let rendezvous = QRLoginRendezvous(transport: transport,
|
||||
algorithm: "m.rendezvous.v1.curve25519-aes-sha256",
|
||||
key: "")
|
||||
return QRLoginCode(user: "@mock:matrix.org",
|
||||
initiator: .new,
|
||||
rendezvous: rendezvous)
|
||||
}
|
||||
|
||||
func scannerView() -> AnyView {
|
||||
AnyView(Color.red)
|
||||
}
|
||||
|
||||
func startScanning() { }
|
||||
|
||||
func stopScanning(destroy: Bool) { }
|
||||
|
||||
func processScannedQR(_ data: Data) {
|
||||
state = .connectingToDevice
|
||||
state = .waitingForConfirmation("28E-1B9-D0F-896")
|
||||
}
|
||||
|
||||
func confirmCode() {
|
||||
state = .waitingForRemoteSignIn
|
||||
}
|
||||
|
||||
func restart() {
|
||||
state = .initial
|
||||
}
|
||||
|
||||
func reset() {
|
||||
state = .initial
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - QRLoginServiceMode
|
||||
|
||||
enum QRLoginServiceMode {
|
||||
case authenticated
|
||||
case notAuthenticated
|
||||
}
|
||||
|
||||
// MARK: - QRLoginServiceError
|
||||
|
||||
enum QRLoginServiceError: Error, Equatable {
|
||||
case noCameraAccess
|
||||
case noCameraAvailable
|
||||
case invalidQR
|
||||
case requestDenied
|
||||
case requestTimedOut
|
||||
}
|
||||
|
||||
// MARK: - QRLoginServiceState
|
||||
|
||||
enum QRLoginServiceState: Equatable {
|
||||
case initial
|
||||
case scanningQR
|
||||
case connectingToDevice
|
||||
case waitingForConfirmation(_ code: String)
|
||||
case waitingForRemoteSignIn
|
||||
case failed(error: QRLoginServiceError)
|
||||
case completed
|
||||
|
||||
static func == (lhs: QRLoginServiceState, rhs: QRLoginServiceState) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.initial, .initial):
|
||||
return true
|
||||
case (.scanningQR, .scanningQR):
|
||||
return true
|
||||
case (.connectingToDevice, .connectingToDevice):
|
||||
return true
|
||||
case (let .waitingForConfirmation(code1), let .waitingForConfirmation(code2)):
|
||||
return code1 == code2
|
||||
case (.waitingForRemoteSignIn, .waitingForRemoteSignIn):
|
||||
return true
|
||||
case (let .failed(error1), let .failed(error2)):
|
||||
return error1 == error2
|
||||
case (.completed, .completed):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - QRLoginServiceCallback
|
||||
|
||||
enum QRLoginServiceCallback {
|
||||
case didScanQR(Data)
|
||||
case didUpdateState
|
||||
}
|
||||
|
||||
// MARK: - QRLoginServiceProtocol
|
||||
|
||||
protocol QRLoginServiceProtocol {
|
||||
var mode: QRLoginServiceMode { get }
|
||||
var state: QRLoginServiceState { get }
|
||||
var callbacks: PassthroughSubject<QRLoginServiceCallback, Never> { get }
|
||||
func isServiceAvailable() async throws -> Bool
|
||||
func generateQRCode() async throws -> QRLoginCode
|
||||
|
||||
// MARK: QR Scanner
|
||||
|
||||
func scannerView() -> AnyView
|
||||
func startScanning()
|
||||
func stopScanning(destroy: Bool)
|
||||
func processScannedQR(_ data: Data)
|
||||
|
||||
func confirmCode()
|
||||
func restart()
|
||||
func reset()
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//
|
||||
// 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
|
||||
import SwiftUI
|
||||
|
||||
struct LabelledDivider: View {
|
||||
@Environment(\.theme) private var theme
|
||||
|
||||
let label: String
|
||||
let font: Font? // theme.fonts.subheadline by default
|
||||
let labelColor: Color? // theme.colors.primaryContent by default
|
||||
let lineColor: Color? // theme.colors.quinaryContent by default
|
||||
|
||||
init(label: String,
|
||||
font: Font? = nil,
|
||||
labelColor: Color? = nil,
|
||||
lineColor: Color? = nil) {
|
||||
self.label = label
|
||||
self.font = font
|
||||
self.labelColor = labelColor
|
||||
self.lineColor = lineColor
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
line
|
||||
Text(label)
|
||||
.foregroundColor(labelColor ?? theme.colors.primaryContent)
|
||||
.font(font ?? theme.fonts.subheadline)
|
||||
.fixedSize()
|
||||
line
|
||||
}
|
||||
}
|
||||
|
||||
var line: some View {
|
||||
VStack { Divider().background(lineColor ?? theme.colors.quinaryContent) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct LabelledDivider_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
LabelledDivider(label: "Label")
|
||||
.theme(.light).preferredColorScheme(.light)
|
||||
LabelledDivider(label: "Label")
|
||||
.theme(.dark).preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
//
|
||||
// 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: - Coordinator
|
||||
|
||||
// MARK: View model
|
||||
|
||||
enum AuthenticationQRLoginConfirmViewModelResult {
|
||||
case confirm
|
||||
case cancel
|
||||
}
|
||||
|
||||
// MARK: View
|
||||
|
||||
struct AuthenticationQRLoginConfirmViewState: BindableState {
|
||||
var confirmationCode: String?
|
||||
}
|
||||
|
||||
enum AuthenticationQRLoginConfirmViewAction {
|
||||
case confirm
|
||||
case cancel
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
//
|
||||
// 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 AuthenticationQRLoginConfirmViewModelType = StateStoreViewModel<AuthenticationQRLoginConfirmViewState, AuthenticationQRLoginConfirmViewAction>
|
||||
|
||||
class AuthenticationQRLoginConfirmViewModel: AuthenticationQRLoginConfirmViewModelType, AuthenticationQRLoginConfirmViewModelProtocol {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private let qrLoginService: QRLoginServiceProtocol
|
||||
|
||||
// MARK: Public
|
||||
|
||||
var callback: ((AuthenticationQRLoginConfirmViewModelResult) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(qrLoginService: QRLoginServiceProtocol) {
|
||||
self.qrLoginService = qrLoginService
|
||||
super.init(initialViewState: AuthenticationQRLoginConfirmViewState())
|
||||
|
||||
switch qrLoginService.state {
|
||||
case .waitingForConfirmation(let code):
|
||||
state.confirmationCode = code
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: AuthenticationQRLoginConfirmViewAction) {
|
||||
switch viewAction {
|
||||
case .confirm:
|
||||
callback?(.confirm)
|
||||
case .cancel:
|
||||
callback?(.cancel)
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// 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 AuthenticationQRLoginConfirmViewModelProtocol {
|
||||
var callback: ((AuthenticationQRLoginConfirmViewModelResult) -> Void)? { get set }
|
||||
var context: AuthenticationQRLoginConfirmViewModelType.Context { get }
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
//
|
||||
// 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 CommonKit
|
||||
import SwiftUI
|
||||
|
||||
struct AuthenticationQRLoginConfirmCoordinatorParameters {
|
||||
let navigationRouter: NavigationRouterType
|
||||
let qrLoginService: QRLoginServiceProtocol
|
||||
}
|
||||
|
||||
enum AuthenticationQRLoginConfirmCoordinatorResult {
|
||||
/// Login with QR done
|
||||
case done
|
||||
}
|
||||
|
||||
final class AuthenticationQRLoginConfirmCoordinator: Coordinator, Presentable {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private let parameters: AuthenticationQRLoginConfirmCoordinatorParameters
|
||||
private let onboardingQRLoginConfirmHostingController: VectorHostingController
|
||||
private var onboardingQRLoginConfirmViewModel: AuthenticationQRLoginConfirmViewModelProtocol
|
||||
|
||||
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
|
||||
private var loadingIndicator: UserIndicator?
|
||||
|
||||
private var navigationRouter: NavigationRouterType { parameters.navigationRouter }
|
||||
|
||||
// MARK: Public
|
||||
|
||||
// Must be used only internally
|
||||
var childCoordinators: [Coordinator] = []
|
||||
var callback: ((AuthenticationQRLoginConfirmCoordinatorResult) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(parameters: AuthenticationQRLoginConfirmCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
let viewModel = AuthenticationQRLoginConfirmViewModel(qrLoginService: parameters.qrLoginService)
|
||||
let view = AuthenticationQRLoginConfirmScreen(context: viewModel.context)
|
||||
onboardingQRLoginConfirmViewModel = viewModel
|
||||
|
||||
onboardingQRLoginConfirmHostingController = VectorHostingController(rootView: view)
|
||||
onboardingQRLoginConfirmHostingController.vc_removeBackTitle()
|
||||
onboardingQRLoginConfirmHostingController.enableNavigationBarScrollEdgeAppearance = true
|
||||
|
||||
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingQRLoginConfirmHostingController)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func start() {
|
||||
MXLog.debug("[AuthenticationQRLoginConfirmCoordinator] did start.")
|
||||
onboardingQRLoginConfirmViewModel.callback = { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
MXLog.debug("[AuthenticationQRLoginConfirmCoordinator] AuthenticationQRLoginConfirmViewModel did complete with result: \(result).")
|
||||
|
||||
switch result {
|
||||
case .confirm:
|
||||
self.parameters.qrLoginService.confirmCode()
|
||||
case .cancel:
|
||||
self.parameters.qrLoginService.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
onboardingQRLoginConfirmHostingController
|
||||
}
|
||||
|
||||
/// Stops any ongoing activities in the coordinator.
|
||||
func stop() {
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Show an activity indicator whilst loading.
|
||||
private func startLoading() {
|
||||
loadingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true))
|
||||
}
|
||||
|
||||
/// Hide the currently displayed activity indicator.
|
||||
private func stopLoading() {
|
||||
loadingIndicator = nil
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
//
|
||||
// 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.
|
||||
enum MockAuthenticationQRLoginConfirmScreenState: MockScreenState, CaseIterable {
|
||||
// A case for each state you want to represent
|
||||
// with specific, minimal associated data that will allow you
|
||||
// mock that screen.
|
||||
case `default`
|
||||
|
||||
/// The associated screen
|
||||
var screenType: Any.Type {
|
||||
AuthenticationQRLoginConfirmScreen.self
|
||||
}
|
||||
|
||||
/// A list of screen state definitions
|
||||
static var allCases: [MockAuthenticationQRLoginConfirmScreenState] {
|
||||
// Each of the presence statuses
|
||||
[.default]
|
||||
}
|
||||
|
||||
/// Generate the view struct for the screen state.
|
||||
var screenView: ([Any], AnyView) {
|
||||
let viewModel = AuthenticationQRLoginConfirmViewModel(qrLoginService: MockQRLoginService(withState: .waitingForConfirmation("28E-1B9-D0F-896")))
|
||||
|
||||
// can simulate service and viewModel actions here if needs be.
|
||||
|
||||
return (
|
||||
[self, viewModel],
|
||||
AnyView(AuthenticationQRLoginConfirmScreen(context: viewModel.context))
|
||||
)
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
//
|
||||
// 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 RiotSwiftUI
|
||||
import XCTest
|
||||
|
||||
class AuthenticationQRLoginConfirmUITests: MockScreenTestCase {
|
||||
func testDefault() {
|
||||
app.goToScreenWithIdentifier(MockAuthenticationQRLoginConfirmScreenState.default.title)
|
||||
|
||||
XCTAssertTrue(app.staticTexts["titleLabel"].exists)
|
||||
XCTAssertTrue(app.staticTexts["subtitleLabel"].exists)
|
||||
XCTAssertTrue(app.staticTexts["confirmationCodeLabel"].exists)
|
||||
XCTAssertTrue(app.staticTexts["alertText"].exists)
|
||||
|
||||
let confirmButton = app.buttons["confirmButton"]
|
||||
XCTAssertTrue(confirmButton.exists)
|
||||
XCTAssertTrue(confirmButton.isEnabled)
|
||||
|
||||
let cancelButton = app.buttons["cancelButton"]
|
||||
XCTAssertTrue(cancelButton.exists)
|
||||
XCTAssertTrue(cancelButton.isEnabled)
|
||||
}
|
||||
}
|
||||
+53
@@ -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
|
||||
|
||||
@testable import RiotSwiftUI
|
||||
|
||||
class AuthenticationQRLoginConfirmViewModelTests: XCTestCase {
|
||||
var viewModel: AuthenticationQRLoginConfirmViewModelProtocol!
|
||||
var context: AuthenticationQRLoginConfirmViewModelType.Context!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
viewModel = AuthenticationQRLoginConfirmViewModel(qrLoginService: MockQRLoginService(withState: .waitingForConfirmation("28E-1B9-D0F-896")))
|
||||
context = viewModel.context
|
||||
}
|
||||
|
||||
func testConfirm() {
|
||||
var result: AuthenticationQRLoginConfirmViewModelResult?
|
||||
|
||||
viewModel.callback = { callbackResult in
|
||||
result = callbackResult
|
||||
}
|
||||
|
||||
context.send(viewAction: .confirm)
|
||||
|
||||
XCTAssertEqual(result, .confirm)
|
||||
}
|
||||
|
||||
func testCancel() {
|
||||
var result: AuthenticationQRLoginConfirmViewModelResult?
|
||||
|
||||
viewModel.callback = { callbackResult in
|
||||
result = callbackResult
|
||||
}
|
||||
|
||||
context.send(viewAction: .cancel)
|
||||
|
||||
XCTAssertEqual(result, .cancel)
|
||||
}
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
/// The screen shown to a new user to select their use case for the app.
|
||||
struct AuthenticationQRLoginConfirmScreen: View {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
@Environment(\.theme) private var theme
|
||||
@ScaledMetric private var iconSize = 70.0
|
||||
|
||||
// MARK: Public
|
||||
|
||||
@ObservedObject var context: AuthenticationQRLoginConfirmViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ScrollView {
|
||||
titleContent
|
||||
.padding(.top, OnboardingMetrics.topPaddingToNavigationBar)
|
||||
codeView
|
||||
}
|
||||
.readableFrame()
|
||||
|
||||
footerContent
|
||||
.padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
.background(theme.colors.background.ignoresSafeArea())
|
||||
}
|
||||
|
||||
/// The screen's title and instructions.
|
||||
var titleContent: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(Asset.Images.authenticationQrloginConfirmIcon.name)
|
||||
.frame(width: iconSize, height: iconSize)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
Text(VectorL10n.authenticationQrLoginConfirmTitle)
|
||||
.font(theme.fonts.title3SB)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(theme.colors.primaryContent)
|
||||
.accessibilityIdentifier("titleLabel")
|
||||
|
||||
Text(VectorL10n.authenticationQrLoginConfirmSubtitle)
|
||||
.font(theme.fonts.body)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(theme.colors.secondaryContent)
|
||||
.padding(.bottom, 24)
|
||||
.accessibilityIdentifier("subtitleLabel")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var codeView: some View {
|
||||
if let code = context.viewState.confirmationCode {
|
||||
Text(code)
|
||||
.multilineTextAlignment(.center)
|
||||
.font(theme.fonts.title1)
|
||||
.foregroundColor(theme.colors.primaryContent)
|
||||
.padding(.top, 80)
|
||||
.accessibilityIdentifier("confirmationCodeLabel")
|
||||
}
|
||||
}
|
||||
|
||||
/// The screen's footer.
|
||||
var footerContent: some View {
|
||||
VStack(spacing: 16) {
|
||||
Text(VectorL10n.authenticationQrLoginConfirmAlert)
|
||||
.padding(10)
|
||||
.multilineTextAlignment(.center)
|
||||
.font(theme.fonts.body)
|
||||
.foregroundColor(theme.colors.alert)
|
||||
.shapedBorder(color: theme.colors.alert, borderWidth: 1, shape: RoundedRectangle(cornerRadius: 8))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.bottom, 12)
|
||||
.accessibilityIdentifier("alertText")
|
||||
|
||||
Button(action: confirm) {
|
||||
Text(VectorL10n.confirm)
|
||||
}
|
||||
.buttonStyle(PrimaryActionButtonStyle(font: theme.fonts.bodySB))
|
||||
.accessibilityIdentifier("confirmButton")
|
||||
|
||||
Button(action: cancel) {
|
||||
Text(VectorL10n.cancel)
|
||||
}
|
||||
.buttonStyle(SecondaryActionButtonStyle(font: theme.fonts.bodySB))
|
||||
.accessibilityIdentifier("cancelButton")
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends the `confirm` view action.
|
||||
func confirm() {
|
||||
context.send(viewAction: .confirm)
|
||||
}
|
||||
|
||||
/// Sends the `cancel` view action.
|
||||
func cancel() {
|
||||
context.send(viewAction: .cancel)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct AuthenticationQRLoginConfirm_Previews: PreviewProvider {
|
||||
static let stateRenderer = MockAuthenticationQRLoginConfirmScreenState.stateRenderer
|
||||
|
||||
static var previews: some View {
|
||||
stateRenderer.screenGroup(addNavigation: true)
|
||||
.theme(.light).preferredColorScheme(.light)
|
||||
.navigationViewStyle(.stack)
|
||||
stateRenderer.screenGroup(addNavigation: true)
|
||||
.theme(.dark).preferredColorScheme(.dark)
|
||||
.navigationViewStyle(.stack)
|
||||
}
|
||||
}
|
||||
+36
@@ -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
|
||||
import UIKit
|
||||
|
||||
// MARK: - Coordinator
|
||||
|
||||
// MARK: View model
|
||||
|
||||
enum AuthenticationQRLoginDisplayViewModelResult {
|
||||
case cancel
|
||||
}
|
||||
|
||||
// MARK: View
|
||||
|
||||
struct AuthenticationQRLoginDisplayViewState: BindableState {
|
||||
var qrImage: UIImage?
|
||||
}
|
||||
|
||||
enum AuthenticationQRLoginDisplayViewAction {
|
||||
case cancel
|
||||
}
|
||||
+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 SwiftUI
|
||||
|
||||
typealias AuthenticationQRLoginDisplayViewModelType = StateStoreViewModel<AuthenticationQRLoginDisplayViewState, AuthenticationQRLoginDisplayViewAction>
|
||||
|
||||
class AuthenticationQRLoginDisplayViewModel: AuthenticationQRLoginDisplayViewModelType, AuthenticationQRLoginDisplayViewModelProtocol {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private let qrLoginService: QRLoginServiceProtocol
|
||||
|
||||
// MARK: Public
|
||||
|
||||
var callback: ((AuthenticationQRLoginDisplayViewModelResult) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(qrLoginService: QRLoginServiceProtocol) {
|
||||
self.qrLoginService = qrLoginService
|
||||
super.init(initialViewState: AuthenticationQRLoginDisplayViewState())
|
||||
|
||||
Task { @MainActor in
|
||||
let generator = QRCodeGenerator()
|
||||
let qrData = try await qrLoginService.generateQRCode()
|
||||
guard let jsonString = qrData.jsonString,
|
||||
let data = jsonString.data(using: .isoLatin1) else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
state.qrImage = try generator.generateCode(from: data,
|
||||
with: CGSize(width: 240, height: 240),
|
||||
offColor: .clear)
|
||||
} catch {
|
||||
// MXLog.error("[AuthenticationQRLoginDisplayViewModel] failed to generate QR", context: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: AuthenticationQRLoginDisplayViewAction) {
|
||||
switch viewAction {
|
||||
case .cancel:
|
||||
callback?(.cancel)
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// 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 AuthenticationQRLoginDisplayViewModelProtocol {
|
||||
var callback: ((AuthenticationQRLoginDisplayViewModelResult) -> Void)? { get set }
|
||||
var context: AuthenticationQRLoginDisplayViewModelType.Context { get }
|
||||
}
|
||||
+103
@@ -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 CommonKit
|
||||
import SwiftUI
|
||||
|
||||
struct AuthenticationQRLoginDisplayCoordinatorParameters {
|
||||
let navigationRouter: NavigationRouterType
|
||||
let qrLoginService: QRLoginServiceProtocol
|
||||
}
|
||||
|
||||
enum AuthenticationQRLoginDisplayCoordinatorResult {
|
||||
/// Login with QR done
|
||||
case done
|
||||
}
|
||||
|
||||
final class AuthenticationQRLoginDisplayCoordinator: Coordinator, Presentable {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private let parameters: AuthenticationQRLoginDisplayCoordinatorParameters
|
||||
private let onboardingQRLoginDisplayHostingController: VectorHostingController
|
||||
private var onboardingQRLoginDisplayViewModel: AuthenticationQRLoginDisplayViewModelProtocol
|
||||
|
||||
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
|
||||
private var loadingIndicator: UserIndicator?
|
||||
private var navigationRouter: NavigationRouterType { parameters.navigationRouter }
|
||||
|
||||
// MARK: Public
|
||||
|
||||
// Must be used only internally
|
||||
var childCoordinators: [Coordinator] = []
|
||||
var callback: ((AuthenticationQRLoginDisplayCoordinatorResult) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(parameters: AuthenticationQRLoginDisplayCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
let viewModel = AuthenticationQRLoginDisplayViewModel(qrLoginService: parameters.qrLoginService)
|
||||
let view = AuthenticationQRLoginDisplayScreen(context: viewModel.context)
|
||||
onboardingQRLoginDisplayViewModel = viewModel
|
||||
|
||||
onboardingQRLoginDisplayHostingController = VectorHostingController(rootView: view)
|
||||
onboardingQRLoginDisplayHostingController.vc_removeBackTitle()
|
||||
onboardingQRLoginDisplayHostingController.enableNavigationBarScrollEdgeAppearance = true
|
||||
|
||||
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingQRLoginDisplayHostingController)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func start() {
|
||||
MXLog.debug("[AuthenticationQRLoginDisplayCoordinator] did start.")
|
||||
onboardingQRLoginDisplayViewModel.callback = { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
MXLog.debug("[AuthenticationQRLoginDisplayCoordinator] AuthenticationQRLoginDisplayViewModel did complete with result: \(result).")
|
||||
|
||||
switch result {
|
||||
case .cancel:
|
||||
self.navigationRouter.popModule(animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
onboardingQRLoginDisplayHostingController
|
||||
}
|
||||
|
||||
/// Stops any ongoing activities in the coordinator.
|
||||
func stop() {
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func showScanQRScreen() { }
|
||||
|
||||
private func showDisplayQRScreen() { }
|
||||
|
||||
/// Show an activity indicator whilst loading.
|
||||
private func startLoading() {
|
||||
loadingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true))
|
||||
}
|
||||
|
||||
/// Hide the currently displayed activity indicator.
|
||||
private func stopLoading() {
|
||||
loadingIndicator = nil
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
//
|
||||
// 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.
|
||||
enum MockAuthenticationQRLoginDisplayScreenState: MockScreenState, CaseIterable {
|
||||
// A case for each state you want to represent
|
||||
// with specific, minimal associated data that will allow you
|
||||
// mock that screen.
|
||||
case `default`
|
||||
|
||||
/// The associated screen
|
||||
var screenType: Any.Type {
|
||||
AuthenticationQRLoginDisplayScreen.self
|
||||
}
|
||||
|
||||
/// A list of screen state definitions
|
||||
static var allCases: [MockAuthenticationQRLoginDisplayScreenState] {
|
||||
// Each of the presence statuses
|
||||
[.default]
|
||||
}
|
||||
|
||||
/// Generate the view struct for the screen state.
|
||||
var screenView: ([Any], AnyView) {
|
||||
let viewModel = AuthenticationQRLoginDisplayViewModel(qrLoginService: MockQRLoginService())
|
||||
|
||||
// can simulate service and viewModel actions here if needs be.
|
||||
|
||||
return (
|
||||
[self, viewModel],
|
||||
AnyView(AuthenticationQRLoginDisplayScreen(context: viewModel.context))
|
||||
)
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
//
|
||||
// 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 RiotSwiftUI
|
||||
import XCTest
|
||||
|
||||
class AuthenticationQRLoginDisplayUITests: MockScreenTestCase {
|
||||
func testDefault() {
|
||||
app.goToScreenWithIdentifier(MockAuthenticationQRLoginDisplayScreenState.default.title)
|
||||
|
||||
XCTAssertTrue(app.staticTexts["titleLabel"].exists)
|
||||
XCTAssertTrue(app.staticTexts["subtitleLabel"].exists)
|
||||
XCTAssertTrue(app.images["qrImageView"].exists)
|
||||
|
||||
let displayQRButton = app.buttons["cancelButton"]
|
||||
XCTAssertTrue(displayQRButton.exists)
|
||||
XCTAssertTrue(displayQRButton.isEnabled)
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
@testable import RiotSwiftUI
|
||||
|
||||
class AuthenticationQRLoginDisplayViewModelTests: XCTestCase {
|
||||
var viewModel: AuthenticationQRLoginDisplayViewModelProtocol!
|
||||
var context: AuthenticationQRLoginDisplayViewModelType.Context!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
viewModel = AuthenticationQRLoginDisplayViewModel(qrLoginService: MockQRLoginService())
|
||||
context = viewModel.context
|
||||
}
|
||||
|
||||
func testCancel() {
|
||||
var result: AuthenticationQRLoginDisplayViewModelResult?
|
||||
|
||||
viewModel.callback = { callbackResult in
|
||||
result = callbackResult
|
||||
}
|
||||
|
||||
context.send(viewAction: .cancel)
|
||||
|
||||
XCTAssertEqual(result, .cancel)
|
||||
}
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
/// The screen shown to a new user to select their use case for the app.
|
||||
struct AuthenticationQRLoginDisplayScreen: View {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
@Environment(\.theme) private var theme
|
||||
|
||||
// MARK: Public
|
||||
|
||||
@ObservedObject var context: AuthenticationQRLoginDisplayViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ScrollView {
|
||||
titleContent
|
||||
.padding(.top, OnboardingMetrics.topPaddingToNavigationBar)
|
||||
stepsView
|
||||
qrView
|
||||
}
|
||||
.readableFrame()
|
||||
|
||||
footerContent
|
||||
.padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
.background(theme.colors.background.ignoresSafeArea())
|
||||
}
|
||||
|
||||
/// The screen's title and instructions.
|
||||
var titleContent: some View {
|
||||
VStack(spacing: 24) {
|
||||
Text(VectorL10n.authenticationQrLoginDisplayTitle)
|
||||
.font(theme.fonts.title2B)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(theme.colors.primaryContent)
|
||||
.accessibilityIdentifier("titleLabel")
|
||||
|
||||
Text(VectorL10n.authenticationQrLoginDisplaySubtitle)
|
||||
.font(theme.fonts.body)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(theme.colors.secondaryContent)
|
||||
.padding(.bottom, 24)
|
||||
.accessibilityIdentifier("subtitleLabel")
|
||||
}
|
||||
}
|
||||
|
||||
/// The screen's footer.
|
||||
var footerContent: some View {
|
||||
VStack(spacing: 8) {
|
||||
Button(action: cancel) {
|
||||
Text(VectorL10n.cancel)
|
||||
}
|
||||
.buttonStyle(SecondaryActionButtonStyle(font: theme.fonts.bodySB))
|
||||
.accessibilityIdentifier("cancelButton")
|
||||
}
|
||||
}
|
||||
|
||||
/// The buttons used to select a use case for the app.
|
||||
var stepsView: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ForEach(steps) { step in
|
||||
HStack {
|
||||
Text(String(step.id))
|
||||
.font(theme.fonts.caption2SB)
|
||||
.foregroundColor(theme.colors.accent)
|
||||
.padding(6)
|
||||
.shapedBorder(color: theme.colors.accent, borderWidth: 1, shape: Circle())
|
||||
.offset(x: 1, y: 0)
|
||||
Text(step.description)
|
||||
.foregroundColor(theme.colors.primaryContent)
|
||||
.font(theme.fonts.subheadline)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var qrView: some View {
|
||||
if let qrImage = context.viewState.qrImage {
|
||||
VStack {
|
||||
Image(uiImage: qrImage)
|
||||
.resizable()
|
||||
.renderingMode(.template)
|
||||
.foregroundColor(theme.colors.primaryContent)
|
||||
.scaledToFit()
|
||||
.accessibilityIdentifier("qrImageView")
|
||||
}
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.shapedBorder(color: theme.colors.quinaryContent,
|
||||
borderWidth: 1,
|
||||
shape: RoundedRectangle(cornerRadius: 8))
|
||||
.padding(1)
|
||||
.padding(.top, 16)
|
||||
}
|
||||
}
|
||||
|
||||
private let steps = [
|
||||
QRLoginDisplayStep(id: 1, description: VectorL10n.authenticationQrLoginDisplayStep1),
|
||||
QRLoginDisplayStep(id: 2, description: VectorL10n.authenticationQrLoginDisplayStep2)
|
||||
]
|
||||
|
||||
/// Sends the `cancel` view action.
|
||||
func cancel() {
|
||||
context.send(viewAction: .cancel)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct AuthenticationQRLoginDisplay_Previews: PreviewProvider {
|
||||
static let stateRenderer = MockAuthenticationQRLoginDisplayScreenState.stateRenderer
|
||||
|
||||
static var previews: some View {
|
||||
stateRenderer.screenGroup(addNavigation: true)
|
||||
.theme(.light).preferredColorScheme(.light)
|
||||
.navigationViewStyle(.stack)
|
||||
stateRenderer.screenGroup(addNavigation: true)
|
||||
.theme(.dark).preferredColorScheme(.dark)
|
||||
.navigationViewStyle(.stack)
|
||||
}
|
||||
}
|
||||
|
||||
private struct QRLoginDisplayStep: Identifiable {
|
||||
let id: Int
|
||||
let description: String
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
//
|
||||
// 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: - Coordinator
|
||||
|
||||
// MARK: View model
|
||||
|
||||
enum AuthenticationQRLoginFailureViewModelResult {
|
||||
case retry
|
||||
case cancel
|
||||
}
|
||||
|
||||
// MARK: View
|
||||
|
||||
struct AuthenticationQRLoginFailureViewState: BindableState {
|
||||
var retryButtonVisible: Bool
|
||||
var failureText: String?
|
||||
}
|
||||
|
||||
enum AuthenticationQRLoginFailureViewAction {
|
||||
case retry
|
||||
case cancel
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
//
|
||||
// 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 AuthenticationQRLoginFailureViewModelType = StateStoreViewModel<AuthenticationQRLoginFailureViewState, AuthenticationQRLoginFailureViewAction>
|
||||
|
||||
class AuthenticationQRLoginFailureViewModel: AuthenticationQRLoginFailureViewModelType, AuthenticationQRLoginFailureViewModelProtocol {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private let qrLoginService: QRLoginServiceProtocol
|
||||
|
||||
// MARK: Public
|
||||
|
||||
var callback: ((AuthenticationQRLoginFailureViewModelResult) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(qrLoginService: QRLoginServiceProtocol) {
|
||||
self.qrLoginService = qrLoginService
|
||||
super.init(initialViewState: AuthenticationQRLoginFailureViewState(retryButtonVisible: false))
|
||||
|
||||
updateFailureText(for: qrLoginService.state)
|
||||
qrLoginService.callbacks.sink { [weak self] callback in
|
||||
guard let self = self else { return }
|
||||
switch callback {
|
||||
case .didUpdateState:
|
||||
self.updateFailureText(for: qrLoginService.state)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func updateFailureText(for state: QRLoginServiceState) {
|
||||
switch state {
|
||||
case .failed(let error):
|
||||
switch error {
|
||||
case .invalidQR:
|
||||
self.state.failureText = VectorL10n.authenticationQrLoginFailureInvalidQr
|
||||
self.state.retryButtonVisible = true
|
||||
case .requestDenied:
|
||||
self.state.failureText = VectorL10n.authenticationQrLoginFailureRequestDenied
|
||||
self.state.retryButtonVisible = false
|
||||
case .requestTimedOut:
|
||||
self.state.failureText = VectorL10n.authenticationQrLoginFailureRequestTimedOut
|
||||
self.state.retryButtonVisible = true
|
||||
default:
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: AuthenticationQRLoginFailureViewAction) {
|
||||
switch viewAction {
|
||||
case .retry:
|
||||
callback?(.retry)
|
||||
case .cancel:
|
||||
callback?(.cancel)
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// 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 AuthenticationQRLoginFailureViewModelProtocol {
|
||||
var callback: ((AuthenticationQRLoginFailureViewModelResult) -> Void)? { get set }
|
||||
var context: AuthenticationQRLoginFailureViewModelType.Context { get }
|
||||
}
|
||||
+103
@@ -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 CommonKit
|
||||
import SwiftUI
|
||||
|
||||
struct AuthenticationQRLoginFailureCoordinatorParameters {
|
||||
let navigationRouter: NavigationRouterType
|
||||
let qrLoginService: QRLoginServiceProtocol
|
||||
}
|
||||
|
||||
enum AuthenticationQRLoginFailureCoordinatorResult {
|
||||
/// Login with QR done
|
||||
case done
|
||||
}
|
||||
|
||||
final class AuthenticationQRLoginFailureCoordinator: Coordinator, Presentable {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private let parameters: AuthenticationQRLoginFailureCoordinatorParameters
|
||||
private let onboardingQRLoginFailureHostingController: VectorHostingController
|
||||
private var onboardingQRLoginFailureViewModel: AuthenticationQRLoginFailureViewModelProtocol
|
||||
|
||||
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
|
||||
private var loadingIndicator: UserIndicator?
|
||||
|
||||
private var navigationRouter: NavigationRouterType { parameters.navigationRouter }
|
||||
private var qrLoginService: QRLoginServiceProtocol { parameters.qrLoginService }
|
||||
|
||||
// MARK: Public
|
||||
|
||||
// Must be used only internally
|
||||
var childCoordinators: [Coordinator] = []
|
||||
var callback: ((AuthenticationQRLoginFailureCoordinatorResult) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(parameters: AuthenticationQRLoginFailureCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
let viewModel = AuthenticationQRLoginFailureViewModel(qrLoginService: parameters.qrLoginService)
|
||||
let view = AuthenticationQRLoginFailureScreen(context: viewModel.context)
|
||||
onboardingQRLoginFailureViewModel = viewModel
|
||||
|
||||
onboardingQRLoginFailureHostingController = VectorHostingController(rootView: view)
|
||||
onboardingQRLoginFailureHostingController.vc_removeBackTitle()
|
||||
onboardingQRLoginFailureHostingController.enableNavigationBarScrollEdgeAppearance = true
|
||||
|
||||
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingQRLoginFailureHostingController)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func start() {
|
||||
MXLog.debug("[AuthenticationQRLoginFailureCoordinator] did start.")
|
||||
onboardingQRLoginFailureViewModel.callback = { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
MXLog.debug("[AuthenticationQRLoginFailureCoordinator] AuthenticationQRLoginFailureViewModel did complete with result: \(result).")
|
||||
|
||||
switch result {
|
||||
case .retry:
|
||||
self.qrLoginService.restart()
|
||||
case .cancel:
|
||||
self.qrLoginService.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
onboardingQRLoginFailureHostingController
|
||||
}
|
||||
|
||||
/// Stops any ongoing activities in the coordinator.
|
||||
func stop() {
|
||||
stopFailure()
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Show an activity indicator whilst loading.
|
||||
private func startFailure() {
|
||||
loadingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true))
|
||||
}
|
||||
|
||||
/// Hide the currently displayed activity indicator.
|
||||
private func stopFailure() {
|
||||
loadingIndicator = nil
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
//
|
||||
// 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.
|
||||
enum MockAuthenticationQRLoginFailureScreenState: MockScreenState, CaseIterable {
|
||||
// A case for each state you want to represent
|
||||
// with specific, minimal associated data that will allow you
|
||||
// mock that screen.
|
||||
case invalidQR
|
||||
case requestDenied
|
||||
case requestTimedOut
|
||||
|
||||
/// The associated screen
|
||||
var screenType: Any.Type {
|
||||
AuthenticationQRLoginFailureScreen.self
|
||||
}
|
||||
|
||||
/// A list of screen state definitions
|
||||
static var allCases: [MockAuthenticationQRLoginFailureScreenState] {
|
||||
// Each of the presence statuses
|
||||
[.invalidQR, .requestDenied, .requestTimedOut]
|
||||
}
|
||||
|
||||
/// Generate the view struct for the screen state.
|
||||
var screenView: ([Any], AnyView) {
|
||||
let viewModel: AuthenticationQRLoginFailureViewModel
|
||||
|
||||
switch self {
|
||||
case .invalidQR:
|
||||
viewModel = .init(qrLoginService: MockQRLoginService(withState: .failed(error: .invalidQR)))
|
||||
case .requestDenied:
|
||||
viewModel = .init(qrLoginService: MockQRLoginService(withState: .failed(error: .requestDenied)))
|
||||
case .requestTimedOut:
|
||||
viewModel = .init(qrLoginService: MockQRLoginService(withState: .failed(error: .requestTimedOut)))
|
||||
}
|
||||
|
||||
// can simulate service and viewModel actions here if needs be.
|
||||
|
||||
return (
|
||||
[self, viewModel],
|
||||
AnyView(AuthenticationQRLoginFailureScreen(context: viewModel.context))
|
||||
)
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
//
|
||||
// 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 RiotSwiftUI
|
||||
import XCTest
|
||||
|
||||
class AuthenticationQRLoginFailureUITests: MockScreenTestCase {
|
||||
func testInvalidQR() {
|
||||
app.goToScreenWithIdentifier(MockAuthenticationQRLoginFailureScreenState.invalidQR.title)
|
||||
|
||||
XCTAssertTrue(app.staticTexts["failureLabel"].exists)
|
||||
|
||||
let retryButton = app.buttons["retryButton"]
|
||||
XCTAssertTrue(retryButton.exists)
|
||||
XCTAssertTrue(retryButton.isEnabled)
|
||||
|
||||
let cancelButton = app.buttons["cancelButton"]
|
||||
XCTAssertTrue(cancelButton.exists)
|
||||
XCTAssertTrue(cancelButton.isEnabled)
|
||||
}
|
||||
|
||||
func testRequestDenied() {
|
||||
app.goToScreenWithIdentifier(MockAuthenticationQRLoginFailureScreenState.requestDenied.title)
|
||||
|
||||
XCTAssertTrue(app.staticTexts["failureLabel"].exists)
|
||||
|
||||
let retryButton = app.buttons["retryButton"]
|
||||
XCTAssertFalse(retryButton.exists)
|
||||
|
||||
let cancelButton = app.buttons["cancelButton"]
|
||||
XCTAssertTrue(cancelButton.exists)
|
||||
XCTAssertTrue(cancelButton.isEnabled)
|
||||
}
|
||||
|
||||
func testRequestTimedOut() {
|
||||
app.goToScreenWithIdentifier(MockAuthenticationQRLoginFailureScreenState.requestTimedOut.title)
|
||||
|
||||
XCTAssertTrue(app.staticTexts["failureLabel"].exists)
|
||||
|
||||
let retryButton = app.buttons["retryButton"]
|
||||
XCTAssertTrue(retryButton.exists)
|
||||
XCTAssertTrue(retryButton.isEnabled)
|
||||
|
||||
let cancelButton = app.buttons["cancelButton"]
|
||||
XCTAssertTrue(cancelButton.exists)
|
||||
XCTAssertTrue(cancelButton.isEnabled)
|
||||
}
|
||||
}
|
||||
+53
@@ -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
|
||||
|
||||
@testable import RiotSwiftUI
|
||||
|
||||
class AuthenticationQRLoginFailureViewModelTests: XCTestCase {
|
||||
var viewModel: AuthenticationQRLoginFailureViewModelProtocol!
|
||||
var context: AuthenticationQRLoginFailureViewModelType.Context!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
viewModel = AuthenticationQRLoginFailureViewModel(qrLoginService: MockQRLoginService(withState: .failed(error: .requestTimedOut)))
|
||||
context = viewModel.context
|
||||
}
|
||||
|
||||
func testRetry() {
|
||||
var result: AuthenticationQRLoginFailureViewModelResult?
|
||||
|
||||
viewModel.callback = { callbackResult in
|
||||
result = callbackResult
|
||||
}
|
||||
|
||||
context.send(viewAction: .retry)
|
||||
|
||||
XCTAssertEqual(result, .retry)
|
||||
}
|
||||
|
||||
func testCancel() {
|
||||
var result: AuthenticationQRLoginFailureViewModelResult?
|
||||
|
||||
viewModel.callback = { callbackResult in
|
||||
result = callbackResult
|
||||
}
|
||||
|
||||
context.send(viewAction: .cancel)
|
||||
|
||||
XCTAssertEqual(result, .cancel)
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
/// The screen shown to a new user to select their use case for the app.
|
||||
struct AuthenticationQRLoginFailureScreen: View {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
@Environment(\.theme) private var theme
|
||||
@ScaledMetric private var iconSize = 70.0
|
||||
|
||||
// MARK: Public
|
||||
|
||||
@ObservedObject var context: AuthenticationQRLoginFailureViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ScrollView {
|
||||
titleContent
|
||||
.padding(.top, OnboardingMetrics.topPaddingToNavigationBar)
|
||||
}
|
||||
.readableFrame()
|
||||
|
||||
footerContent
|
||||
.padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
.background(theme.colors.background.ignoresSafeArea())
|
||||
}
|
||||
|
||||
/// The screen's title and instructions.
|
||||
var titleContent: some View {
|
||||
VStack(spacing: 16) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(theme.colors.alert)
|
||||
Image(Asset.Images.exclamationCircle.name)
|
||||
.resizable()
|
||||
.renderingMode(.template)
|
||||
.foregroundColor(.white)
|
||||
.aspectRatio(1.0, contentMode: .fit)
|
||||
.padding(15)
|
||||
}
|
||||
.frame(width: iconSize, height: iconSize)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
Text(VectorL10n.authenticationQrLoginFailureTitle)
|
||||
.font(theme.fonts.title3SB)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(theme.colors.primaryContent)
|
||||
.accessibilityIdentifier("titleLabel")
|
||||
|
||||
if let failureText = context.viewState.failureText {
|
||||
Text(failureText)
|
||||
.font(theme.fonts.body)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(theme.colors.secondaryContent)
|
||||
.accessibilityIdentifier("failureLabel")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The screen's footer.
|
||||
var footerContent: some View {
|
||||
VStack(spacing: 16) {
|
||||
if context.viewState.retryButtonVisible {
|
||||
Button(action: retry) {
|
||||
Text(VectorL10n.authenticationQrLoginFailureRetry)
|
||||
}
|
||||
.buttonStyle(PrimaryActionButtonStyle(font: theme.fonts.bodySB))
|
||||
.accessibilityIdentifier("retryButton")
|
||||
}
|
||||
|
||||
Button(action: cancel) {
|
||||
Text(VectorL10n.cancel)
|
||||
}
|
||||
.buttonStyle(SecondaryActionButtonStyle(font: theme.fonts.bodySB))
|
||||
.accessibilityIdentifier("cancelButton")
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends the `retry` view action.
|
||||
func retry() {
|
||||
context.send(viewAction: .retry)
|
||||
}
|
||||
|
||||
/// Sends the `cancel` view action.
|
||||
func cancel() {
|
||||
context.send(viewAction: .cancel)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct AuthenticationQRLoginFailure_Previews: PreviewProvider {
|
||||
static let stateRenderer = MockAuthenticationQRLoginFailureScreenState.stateRenderer
|
||||
|
||||
static var previews: some View {
|
||||
stateRenderer.screenGroup(addNavigation: true)
|
||||
.theme(.light).preferredColorScheme(.light)
|
||||
.navigationViewStyle(.stack)
|
||||
stateRenderer.screenGroup(addNavigation: true)
|
||||
.theme(.dark).preferredColorScheme(.dark)
|
||||
.navigationViewStyle(.stack)
|
||||
}
|
||||
}
|
||||
+35
@@ -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
|
||||
|
||||
// MARK: - Coordinator
|
||||
|
||||
// MARK: View model
|
||||
|
||||
enum AuthenticationQRLoginLoadingViewModelResult {
|
||||
case cancel
|
||||
}
|
||||
|
||||
// MARK: View
|
||||
|
||||
struct AuthenticationQRLoginLoadingViewState: BindableState {
|
||||
var loadingText: String?
|
||||
}
|
||||
|
||||
enum AuthenticationQRLoginLoadingViewAction {
|
||||
case cancel
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
//
|
||||
// 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 AuthenticationQRLoginLoadingViewModelType = StateStoreViewModel<AuthenticationQRLoginLoadingViewState, AuthenticationQRLoginLoadingViewAction>
|
||||
|
||||
class AuthenticationQRLoginLoadingViewModel: AuthenticationQRLoginLoadingViewModelType, AuthenticationQRLoginLoadingViewModelProtocol {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private let qrLoginService: QRLoginServiceProtocol
|
||||
|
||||
// MARK: Public
|
||||
|
||||
var callback: ((AuthenticationQRLoginLoadingViewModelResult) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(qrLoginService: QRLoginServiceProtocol) {
|
||||
self.qrLoginService = qrLoginService
|
||||
super.init(initialViewState: AuthenticationQRLoginLoadingViewState())
|
||||
|
||||
updateLoadingText(for: qrLoginService.state)
|
||||
qrLoginService.callbacks.sink { [weak self] callback in
|
||||
guard let self = self else { return }
|
||||
switch callback {
|
||||
case .didUpdateState:
|
||||
self.updateLoadingText(for: qrLoginService.state)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func updateLoadingText(for state: QRLoginServiceState) {
|
||||
switch state {
|
||||
case .connectingToDevice:
|
||||
self.state.loadingText = VectorL10n.authenticationQrLoginLoadingConnectingDevice
|
||||
case .waitingForRemoteSignIn:
|
||||
self.state.loadingText = VectorL10n.authenticationQrLoginLoadingWaitingSignin
|
||||
case .completed:
|
||||
self.state.loadingText = VectorL10n.authenticationQrLoginLoadingSignedIn
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: AuthenticationQRLoginLoadingViewAction) {
|
||||
switch viewAction {
|
||||
case .cancel:
|
||||
callback?(.cancel)
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// 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 AuthenticationQRLoginLoadingViewModelProtocol {
|
||||
var callback: ((AuthenticationQRLoginLoadingViewModelResult) -> Void)? { get set }
|
||||
var context: AuthenticationQRLoginLoadingViewModelType.Context { get }
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
//
|
||||
// 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 CommonKit
|
||||
import SwiftUI
|
||||
|
||||
struct AuthenticationQRLoginLoadingCoordinatorParameters {
|
||||
let navigationRouter: NavigationRouterType
|
||||
let qrLoginService: QRLoginServiceProtocol
|
||||
}
|
||||
|
||||
enum AuthenticationQRLoginLoadingCoordinatorResult {
|
||||
/// Login with QR done
|
||||
case done
|
||||
}
|
||||
|
||||
final class AuthenticationQRLoginLoadingCoordinator: Coordinator, Presentable {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private let parameters: AuthenticationQRLoginLoadingCoordinatorParameters
|
||||
private let onboardingQRLoginLoadingHostingController: VectorHostingController
|
||||
private var onboardingQRLoginLoadingViewModel: AuthenticationQRLoginLoadingViewModelProtocol
|
||||
|
||||
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
|
||||
private var loadingIndicator: UserIndicator?
|
||||
|
||||
private var navigationRouter: NavigationRouterType { parameters.navigationRouter }
|
||||
private var qrLoginService: QRLoginServiceProtocol { parameters.qrLoginService }
|
||||
|
||||
// MARK: Public
|
||||
|
||||
// Must be used only internally
|
||||
var childCoordinators: [Coordinator] = []
|
||||
var callback: ((AuthenticationQRLoginLoadingCoordinatorResult) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(parameters: AuthenticationQRLoginLoadingCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
let viewModel = AuthenticationQRLoginLoadingViewModel(qrLoginService: parameters.qrLoginService)
|
||||
let view = AuthenticationQRLoginLoadingScreen(context: viewModel.context)
|
||||
onboardingQRLoginLoadingViewModel = viewModel
|
||||
|
||||
onboardingQRLoginLoadingHostingController = VectorHostingController(rootView: view)
|
||||
onboardingQRLoginLoadingHostingController.vc_removeBackTitle()
|
||||
onboardingQRLoginLoadingHostingController.enableNavigationBarScrollEdgeAppearance = true
|
||||
|
||||
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingQRLoginLoadingHostingController)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func start() {
|
||||
MXLog.debug("[AuthenticationQRLoginLoadingCoordinator] did start.")
|
||||
onboardingQRLoginLoadingViewModel.callback = { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
MXLog.debug("[AuthenticationQRLoginLoadingCoordinator] AuthenticationQRLoginLoadingViewModel did complete with result: \(result).")
|
||||
|
||||
switch result {
|
||||
case .cancel:
|
||||
self.qrLoginService.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
onboardingQRLoginLoadingHostingController
|
||||
}
|
||||
|
||||
/// Stops any ongoing activities in the coordinator.
|
||||
func stop() {
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Show an activity indicator whilst loading.
|
||||
private func startLoading() {
|
||||
loadingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true))
|
||||
}
|
||||
|
||||
/// Hide the currently displayed activity indicator.
|
||||
private func stopLoading() {
|
||||
loadingIndicator = nil
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
//
|
||||
// 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.
|
||||
enum MockAuthenticationQRLoginLoadingScreenState: MockScreenState, CaseIterable {
|
||||
// A case for each state you want to represent
|
||||
// with specific, minimal associated data that will allow you
|
||||
// mock that screen.
|
||||
case connectingToDevice
|
||||
case waitingForRemoteSignIn
|
||||
case completed
|
||||
|
||||
/// The associated screen
|
||||
var screenType: Any.Type {
|
||||
AuthenticationQRLoginLoadingScreen.self
|
||||
}
|
||||
|
||||
/// A list of screen state definitions
|
||||
static var allCases: [MockAuthenticationQRLoginLoadingScreenState] {
|
||||
// Each of the presence statuses
|
||||
[.connectingToDevice, .waitingForRemoteSignIn, .completed]
|
||||
}
|
||||
|
||||
/// Generate the view struct for the screen state.
|
||||
var screenView: ([Any], AnyView) {
|
||||
let viewModel: AuthenticationQRLoginLoadingViewModel
|
||||
|
||||
switch self {
|
||||
case .connectingToDevice:
|
||||
viewModel = .init(qrLoginService: MockQRLoginService(withState: .connectingToDevice))
|
||||
case .waitingForRemoteSignIn:
|
||||
viewModel = .init(qrLoginService: MockQRLoginService(withState: .waitingForRemoteSignIn))
|
||||
case .completed:
|
||||
viewModel = .init(qrLoginService: MockQRLoginService(withState: .completed))
|
||||
}
|
||||
|
||||
// can simulate service and viewModel actions here if needs be.
|
||||
|
||||
return (
|
||||
[self, viewModel],
|
||||
AnyView(AuthenticationQRLoginLoadingScreen(context: viewModel.context))
|
||||
)
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
//
|
||||
// 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 RiotSwiftUI
|
||||
import XCTest
|
||||
|
||||
class AuthenticationQRLoginLoadingUITests: MockScreenTestCase {
|
||||
func testCommon() {
|
||||
app.goToScreenWithIdentifier(MockAuthenticationQRLoginLoadingScreenState.connectingToDevice.title)
|
||||
|
||||
XCTAssertTrue(app.staticTexts["loadingLabel"].exists)
|
||||
|
||||
let cancelButton = app.buttons["cancelButton"]
|
||||
XCTAssertTrue(cancelButton.exists)
|
||||
XCTAssertTrue(cancelButton.isEnabled)
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
@testable import RiotSwiftUI
|
||||
|
||||
class AuthenticationQRLoginLoadingViewModelTests: XCTestCase {
|
||||
var viewModel: AuthenticationQRLoginLoadingViewModelProtocol!
|
||||
var context: AuthenticationQRLoginLoadingViewModelType.Context!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
viewModel = AuthenticationQRLoginLoadingViewModel(qrLoginService: MockQRLoginService(withState: .connectingToDevice))
|
||||
context = viewModel.context
|
||||
}
|
||||
|
||||
func testCancel() {
|
||||
var result: AuthenticationQRLoginLoadingViewModelResult?
|
||||
|
||||
viewModel.callback = { callbackResult in
|
||||
result = callbackResult
|
||||
}
|
||||
|
||||
context.send(viewAction: .cancel)
|
||||
|
||||
XCTAssertEqual(result, .cancel)
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
/// The screen shown to a new user to select their use case for the app.
|
||||
struct AuthenticationQRLoginLoadingScreen: View {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
@Environment(\.theme) private var theme
|
||||
|
||||
// MARK: Public
|
||||
|
||||
@ObservedObject var context: AuthenticationQRLoginLoadingViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ScrollView {
|
||||
loadingText
|
||||
.padding(.top, 60)
|
||||
loader
|
||||
}
|
||||
.readableFrame()
|
||||
|
||||
footerContent
|
||||
.padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
.background(theme.colors.background.ignoresSafeArea())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var loadingText: some View {
|
||||
if let code = context.viewState.loadingText {
|
||||
Text(code)
|
||||
.multilineTextAlignment(.center)
|
||||
.font(theme.fonts.body)
|
||||
.foregroundColor(theme.colors.primaryContent)
|
||||
.accessibilityIdentifier("loadingLabel")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var loader: some View {
|
||||
ProgressView()
|
||||
.padding(.top, 64)
|
||||
.accessibilityIdentifier("loader")
|
||||
}
|
||||
|
||||
/// The screen's footer.
|
||||
var footerContent: some View {
|
||||
VStack(spacing: 8) {
|
||||
Button(action: cancel) {
|
||||
Text(VectorL10n.cancel)
|
||||
}
|
||||
.buttonStyle(SecondaryActionButtonStyle(font: theme.fonts.bodySB))
|
||||
.accessibilityIdentifier("cancelButton")
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends the `cancel` view action.
|
||||
func cancel() {
|
||||
context.send(viewAction: .cancel)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct AuthenticationQRLoginLoading_Previews: PreviewProvider {
|
||||
static let stateRenderer = MockAuthenticationQRLoginLoadingScreenState.stateRenderer
|
||||
|
||||
static var previews: some View {
|
||||
stateRenderer.screenGroup(addNavigation: true)
|
||||
.theme(.light).preferredColorScheme(.light)
|
||||
.navigationViewStyle(.stack)
|
||||
stateRenderer.screenGroup(addNavigation: true)
|
||||
.theme(.dark).preferredColorScheme(.dark)
|
||||
.navigationViewStyle(.stack)
|
||||
}
|
||||
}
|
||||
@@ -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 Foundation
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Coordinator
|
||||
|
||||
// MARK: View model
|
||||
|
||||
enum AuthenticationQRLoginScanViewModelResult: Equatable {
|
||||
case goToSettings
|
||||
case displayQR
|
||||
case qrScanned(Data)
|
||||
|
||||
static func == (lhs: AuthenticationQRLoginScanViewModelResult, rhs: AuthenticationQRLoginScanViewModelResult) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.goToSettings, .goToSettings):
|
||||
return true
|
||||
case (.displayQR, .displayQR):
|
||||
return true
|
||||
case (let .qrScanned(data1), let .qrScanned(data2)):
|
||||
return data1 == data2
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: View
|
||||
|
||||
struct AuthenticationQRLoginScanViewState: BindableState {
|
||||
var serviceState: QRLoginServiceState
|
||||
var scannerView: AnyView?
|
||||
}
|
||||
|
||||
enum AuthenticationQRLoginScanViewAction {
|
||||
case goToSettings
|
||||
case displayQR
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
//
|
||||
// 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 Combine
|
||||
import SwiftUI
|
||||
|
||||
typealias AuthenticationQRLoginScanViewModelType = StateStoreViewModel<AuthenticationQRLoginScanViewState, AuthenticationQRLoginScanViewAction>
|
||||
|
||||
class AuthenticationQRLoginScanViewModel: AuthenticationQRLoginScanViewModelType, AuthenticationQRLoginScanViewModelProtocol {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private let qrLoginService: QRLoginServiceProtocol
|
||||
|
||||
// MARK: Public
|
||||
|
||||
var callback: ((AuthenticationQRLoginScanViewModelResult) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(qrLoginService: QRLoginServiceProtocol) {
|
||||
self.qrLoginService = qrLoginService
|
||||
super.init(initialViewState: AuthenticationQRLoginScanViewState(serviceState: .initial))
|
||||
|
||||
qrLoginService.callbacks.sink { callback in
|
||||
switch callback {
|
||||
case .didUpdateState:
|
||||
self.processServiceState(qrLoginService.state)
|
||||
case .didScanQR(let data):
|
||||
self.callback?(.qrScanned(data))
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
processServiceState(qrLoginService.state)
|
||||
qrLoginService.startScanning()
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: AuthenticationQRLoginScanViewAction) {
|
||||
switch viewAction {
|
||||
case .goToSettings:
|
||||
callback?(.goToSettings)
|
||||
case .displayQR:
|
||||
callback?(.displayQR)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func processServiceState(_ state: QRLoginServiceState) {
|
||||
switch state {
|
||||
case .scanningQR:
|
||||
self.state.scannerView = qrLoginService.scannerView()
|
||||
default:
|
||||
break
|
||||
}
|
||||
self.state.serviceState = state
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// 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 AuthenticationQRLoginScanViewModelProtocol {
|
||||
var callback: ((AuthenticationQRLoginScanViewModelResult) -> Void)? { get set }
|
||||
var context: AuthenticationQRLoginScanViewModelType.Context { get }
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
//
|
||||
// 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 CommonKit
|
||||
import SwiftUI
|
||||
|
||||
struct AuthenticationQRLoginScanCoordinatorParameters {
|
||||
let navigationRouter: NavigationRouterType
|
||||
let qrLoginService: QRLoginServiceProtocol
|
||||
}
|
||||
|
||||
enum AuthenticationQRLoginScanCoordinatorResult {
|
||||
/// Login with QR done
|
||||
case done
|
||||
}
|
||||
|
||||
final class AuthenticationQRLoginScanCoordinator: Coordinator, Presentable {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private let parameters: AuthenticationQRLoginScanCoordinatorParameters
|
||||
private let onboardingQRLoginScanHostingController: VectorHostingController
|
||||
private var onboardingQRLoginScanViewModel: AuthenticationQRLoginScanViewModelProtocol
|
||||
|
||||
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
|
||||
private var loadingIndicator: UserIndicator?
|
||||
|
||||
private var navigationRouter: NavigationRouterType { parameters.navigationRouter }
|
||||
private var qrLoginService: QRLoginServiceProtocol { parameters.qrLoginService }
|
||||
|
||||
// MARK: Public
|
||||
|
||||
// Must be used only internally
|
||||
var childCoordinators: [Coordinator] = []
|
||||
var callback: ((AuthenticationQRLoginScanCoordinatorResult) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(parameters: AuthenticationQRLoginScanCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
let viewModel = AuthenticationQRLoginScanViewModel(qrLoginService: parameters.qrLoginService)
|
||||
let view = AuthenticationQRLoginScanScreen(context: viewModel.context)
|
||||
onboardingQRLoginScanViewModel = viewModel
|
||||
|
||||
onboardingQRLoginScanHostingController = VectorHostingController(rootView: view)
|
||||
onboardingQRLoginScanHostingController.vc_removeBackTitle()
|
||||
onboardingQRLoginScanHostingController.enableNavigationBarScrollEdgeAppearance = true
|
||||
|
||||
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingQRLoginScanHostingController)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func start() {
|
||||
MXLog.debug("[AuthenticationQRLoginScanCoordinator] did start.")
|
||||
onboardingQRLoginScanViewModel.callback = { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
MXLog.debug("[AuthenticationQRLoginScanCoordinator] AuthenticationQRLoginScanViewModel did complete with result: \(result).")
|
||||
|
||||
switch result {
|
||||
case .goToSettings:
|
||||
self.goToSettings()
|
||||
case .displayQR:
|
||||
self.showDisplayQRScreen()
|
||||
case .qrScanned(let data):
|
||||
self.qrLoginService.stopScanning(destroy: false)
|
||||
self.qrLoginService.processScannedQR(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
onboardingQRLoginScanHostingController
|
||||
}
|
||||
|
||||
/// Stops any ongoing activities in the coordinator.
|
||||
func stop() {
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func goToSettings() {
|
||||
UIApplication.shared.vc_openSettings()
|
||||
}
|
||||
|
||||
/// Shows the display QR screen.
|
||||
private func showDisplayQRScreen() {
|
||||
MXLog.debug("[AuthenticationQRLoginScanCoordinator] showDisplayQRScreen")
|
||||
|
||||
let parameters = AuthenticationQRLoginDisplayCoordinatorParameters(navigationRouter: navigationRouter,
|
||||
qrLoginService: qrLoginService)
|
||||
let coordinator = AuthenticationQRLoginDisplayCoordinator(parameters: parameters)
|
||||
coordinator.callback = { [weak self, weak coordinator] _ in
|
||||
guard let self = self, let coordinator = coordinator else { return }
|
||||
self.remove(childCoordinator: coordinator)
|
||||
}
|
||||
|
||||
coordinator.start()
|
||||
add(childCoordinator: coordinator)
|
||||
|
||||
navigationRouter.push(coordinator, animated: true) { [weak self] in
|
||||
self?.remove(childCoordinator: coordinator)
|
||||
}
|
||||
}
|
||||
|
||||
/// Show an activity indicator whilst loading.
|
||||
private func startLoading() {
|
||||
loadingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true))
|
||||
}
|
||||
|
||||
/// Hide the currently displayed activity indicator.
|
||||
private func stopLoading() {
|
||||
loadingIndicator = nil
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
//
|
||||
// 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.
|
||||
enum MockAuthenticationQRLoginScanScreenState: MockScreenState, CaseIterable {
|
||||
// A case for each state you want to represent
|
||||
// with specific, minimal associated data that will allow you
|
||||
// mock that screen.
|
||||
case scanning
|
||||
case noCameraAvailable
|
||||
case noCameraAccess
|
||||
|
||||
/// The associated screen
|
||||
var screenType: Any.Type {
|
||||
AuthenticationQRLoginScanScreen.self
|
||||
}
|
||||
|
||||
/// A list of screen state definitions
|
||||
static var allCases: [MockAuthenticationQRLoginScanScreenState] {
|
||||
// Each of the presence statuses
|
||||
[.scanning, .noCameraAvailable, .noCameraAccess]
|
||||
}
|
||||
|
||||
/// Generate the view struct for the screen state.
|
||||
var screenView: ([Any], AnyView) {
|
||||
let viewModel: AuthenticationQRLoginScanViewModel
|
||||
|
||||
switch self {
|
||||
case .scanning:
|
||||
viewModel = .init(qrLoginService: MockQRLoginService(withState: .scanningQR))
|
||||
case .noCameraAvailable:
|
||||
viewModel = .init(qrLoginService: MockQRLoginService(withState: .failed(error: .noCameraAvailable)))
|
||||
case .noCameraAccess:
|
||||
viewModel = .init(qrLoginService: MockQRLoginService(withState: .failed(error: .noCameraAccess)))
|
||||
}
|
||||
|
||||
// can simulate service and viewModel actions here if needs be.
|
||||
|
||||
return (
|
||||
[self, viewModel],
|
||||
AnyView(AuthenticationQRLoginScanScreen(context: viewModel.context))
|
||||
)
|
||||
}
|
||||
}
|
||||
+53
@@ -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 RiotSwiftUI
|
||||
import XCTest
|
||||
|
||||
class AuthenticationQRLoginScanUITests: MockScreenTestCase {
|
||||
func testScanning() {
|
||||
app.goToScreenWithIdentifier(MockAuthenticationQRLoginScanScreenState.scanning.title)
|
||||
|
||||
XCTAssertTrue(app.staticTexts["titleLabel"].exists)
|
||||
XCTAssertTrue(app.staticTexts["subtitleLabel"].exists)
|
||||
}
|
||||
|
||||
func testNoCameraAvailable() {
|
||||
app.goToScreenWithIdentifier(MockAuthenticationQRLoginScanScreenState.noCameraAvailable.title)
|
||||
|
||||
XCTAssertTrue(app.staticTexts["titleLabel"].exists)
|
||||
XCTAssertTrue(app.staticTexts["subtitleLabel"].exists)
|
||||
|
||||
let displayQRButton = app.buttons["displayQRButton"]
|
||||
XCTAssertTrue(displayQRButton.exists)
|
||||
XCTAssertTrue(displayQRButton.isEnabled)
|
||||
}
|
||||
|
||||
func testNoCameraAccess() {
|
||||
app.goToScreenWithIdentifier(MockAuthenticationQRLoginScanScreenState.noCameraAccess.title)
|
||||
|
||||
XCTAssertTrue(app.staticTexts["titleLabel"].exists)
|
||||
XCTAssertTrue(app.staticTexts["subtitleLabel"].exists)
|
||||
|
||||
let openSettingsButton = app.buttons["openSettingsButton"]
|
||||
XCTAssertTrue(openSettingsButton.exists)
|
||||
XCTAssertTrue(openSettingsButton.isEnabled)
|
||||
|
||||
let displayQRButton = app.buttons["displayQRButton"]
|
||||
XCTAssertTrue(displayQRButton.exists)
|
||||
XCTAssertTrue(displayQRButton.isEnabled)
|
||||
}
|
||||
}
|
||||
+53
@@ -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
|
||||
|
||||
@testable import RiotSwiftUI
|
||||
|
||||
class AuthenticationQRLoginScanViewModelTests: XCTestCase {
|
||||
var viewModel: AuthenticationQRLoginScanViewModelProtocol!
|
||||
var context: AuthenticationQRLoginScanViewModelType.Context!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
viewModel = AuthenticationQRLoginScanViewModel(qrLoginService: MockQRLoginService())
|
||||
context = viewModel.context
|
||||
}
|
||||
|
||||
func testGoToSettings() {
|
||||
var result: AuthenticationQRLoginScanViewModelResult?
|
||||
|
||||
viewModel.callback = { callbackResult in
|
||||
result = callbackResult
|
||||
}
|
||||
|
||||
context.send(viewAction: .goToSettings)
|
||||
|
||||
XCTAssertEqual(result, .goToSettings)
|
||||
}
|
||||
|
||||
func testDisplayQR() {
|
||||
var result: AuthenticationQRLoginScanViewModelResult?
|
||||
|
||||
viewModel.callback = { callbackResult in
|
||||
result = callbackResult
|
||||
}
|
||||
|
||||
context.send(viewAction: .displayQR)
|
||||
|
||||
XCTAssertEqual(result, .displayQR)
|
||||
}
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
/// The screen shown to a new user to select their use case for the app.
|
||||
struct AuthenticationQRLoginScanScreen: View {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
@Environment(\.theme) private var theme
|
||||
@ScaledMetric private var iconSize = 70.0
|
||||
private let overlayBgColor = Color.black.opacity(0.4)
|
||||
|
||||
// MARK: Public
|
||||
|
||||
@ObservedObject var context: AuthenticationQRLoginScanViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
switch context.viewState.serviceState {
|
||||
case .scanningQR:
|
||||
scanningBody
|
||||
case .failed(let error):
|
||||
switch error {
|
||||
case .noCameraAvailable, .noCameraAccess:
|
||||
errorBody(for: error)
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
var scanningBody: some View {
|
||||
ZStack {
|
||||
if let scannerView = context.viewState.scannerView {
|
||||
scannerView
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.black)
|
||||
}
|
||||
overlayView
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
|
||||
var overlayView: some View {
|
||||
GeometryReader { geometry in
|
||||
VStack(spacing: 0) {
|
||||
VStack {
|
||||
Spacer()
|
||||
scanningTitleContent
|
||||
.padding(.horizontal, 40)
|
||||
Spacer()
|
||||
.frame(height: 16)
|
||||
}
|
||||
.frame(height: additionalViewHeight(in: geometry))
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(overlayBgColor)
|
||||
|
||||
HStack(spacing: 0) {
|
||||
overlayBgColor
|
||||
.frame(width: 40)
|
||||
Spacer()
|
||||
overlayBgColor
|
||||
.frame(width: 40)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
overlayBgColor
|
||||
.frame(height: additionalViewHeight(in: geometry))
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
|
||||
/// The screen's title and instructions.
|
||||
var scanningTitleContent: some View {
|
||||
VStack(spacing: 24) {
|
||||
Text(VectorL10n.authenticationQrLoginScanTitle)
|
||||
.font(theme.fonts.title1B)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.white)
|
||||
.accessibilityIdentifier("titleLabel")
|
||||
|
||||
Text(VectorL10n.authenticationQrLoginScanSubtitle)
|
||||
.font(theme.fonts.bodySB)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.white)
|
||||
.padding(.bottom, 24)
|
||||
.accessibilityIdentifier("subtitleLabel")
|
||||
}
|
||||
}
|
||||
|
||||
func errorBody(for error: QRLoginServiceError) -> some View {
|
||||
GeometryReader { geometry in
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ScrollView {
|
||||
errorTitleContent(for: error)
|
||||
.padding(.top, OnboardingMetrics.topPaddingToNavigationBar)
|
||||
}
|
||||
.readableFrame()
|
||||
|
||||
errorFooterContent(for: error)
|
||||
.padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
.background(theme.colors.background.ignoresSafeArea())
|
||||
}
|
||||
|
||||
/// The screen's title and instructions on error.
|
||||
func errorTitleContent(for error: QRLoginServiceError) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(theme.colors.accent)
|
||||
Image(Asset.Images.camera.name)
|
||||
.resizable()
|
||||
.renderingMode(.template)
|
||||
.foregroundColor(.white)
|
||||
.aspectRatio(1.0, contentMode: .fit)
|
||||
.padding(14)
|
||||
}
|
||||
.frame(width: iconSize, height: iconSize)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
Text(VectorL10n.authenticationQrLoginStartTitle)
|
||||
.font(theme.fonts.title2B)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(theme.colors.primaryContent)
|
||||
.accessibilityIdentifier("titleLabel")
|
||||
|
||||
Text(error == .noCameraAccess ? VectorL10n.cameraAccessNotGranted(AppInfo.current.displayName) : VectorL10n.cameraUnavailable)
|
||||
.font(theme.fonts.body)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(theme.colors.secondaryContent)
|
||||
.padding(.bottom, 24)
|
||||
.accessibilityIdentifier("subtitleLabel")
|
||||
}
|
||||
}
|
||||
|
||||
/// The screen's footer on error.
|
||||
func errorFooterContent(for error: QRLoginServiceError) -> some View {
|
||||
VStack(spacing: 12) {
|
||||
if error == .noCameraAccess {
|
||||
Button(action: goToSettings) {
|
||||
Text(VectorL10n.settings)
|
||||
}
|
||||
.buttonStyle(PrimaryActionButtonStyle(font: theme.fonts.bodySB))
|
||||
.padding(.bottom, 8)
|
||||
.accessibilityIdentifier("openSettingsButton")
|
||||
}
|
||||
|
||||
LabelledDivider(label: VectorL10n.authenticationQrLoginStartNeedAlternative)
|
||||
|
||||
Button(action: displayQR) {
|
||||
Text(VectorL10n.authenticationQrLoginStartDisplayQr)
|
||||
}
|
||||
.buttonStyle(SecondaryActionButtonStyle(font: theme.fonts.bodySB))
|
||||
.accessibilityIdentifier("displayQRButton")
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends the `goToSettings` view action.
|
||||
func goToSettings() {
|
||||
context.send(viewAction: .goToSettings)
|
||||
}
|
||||
|
||||
/// Sends the `displayQR` view action.
|
||||
func displayQR() {
|
||||
context.send(viewAction: .displayQR)
|
||||
}
|
||||
|
||||
func squareSize(in geometry: GeometryProxy) -> CGFloat {
|
||||
geometry.size.width - 80
|
||||
}
|
||||
|
||||
func additionalViewHeight(in geometry: GeometryProxy) -> CGFloat {
|
||||
(geometry.size.height - squareSize(in: geometry)) / 2
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct AuthenticationQRLoginScan_Previews: PreviewProvider {
|
||||
static let stateRenderer = MockAuthenticationQRLoginScanScreenState.stateRenderer
|
||||
|
||||
static var previews: some View {
|
||||
stateRenderer.screenGroup(addNavigation: true)
|
||||
.theme(.light).preferredColorScheme(.light)
|
||||
.navigationViewStyle(.stack)
|
||||
stateRenderer.screenGroup(addNavigation: true)
|
||||
.theme(.dark).preferredColorScheme(.dark)
|
||||
.navigationViewStyle(.stack)
|
||||
}
|
||||
}
|
||||
+35
@@ -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
|
||||
|
||||
// MARK: - Coordinator
|
||||
|
||||
// MARK: View model
|
||||
|
||||
enum AuthenticationQRLoginStartViewModelResult {
|
||||
case scanQR
|
||||
case displayQR
|
||||
}
|
||||
|
||||
// MARK: View
|
||||
|
||||
struct AuthenticationQRLoginStartViewState: BindableState { }
|
||||
|
||||
enum AuthenticationQRLoginStartViewAction {
|
||||
case scanQR
|
||||
case displayQR
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
//
|
||||
// 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 AuthenticationQRLoginStartViewModelType = StateStoreViewModel<AuthenticationQRLoginStartViewState, AuthenticationQRLoginStartViewAction>
|
||||
|
||||
class AuthenticationQRLoginStartViewModel: AuthenticationQRLoginStartViewModelType, AuthenticationQRLoginStartViewModelProtocol {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private let qrLoginService: QRLoginServiceProtocol
|
||||
|
||||
// MARK: Public
|
||||
|
||||
var callback: ((AuthenticationQRLoginStartViewModelResult) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(qrLoginService: QRLoginServiceProtocol) {
|
||||
self.qrLoginService = qrLoginService
|
||||
super.init(initialViewState: AuthenticationQRLoginStartViewState())
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: AuthenticationQRLoginStartViewAction) {
|
||||
switch viewAction {
|
||||
case .scanQR:
|
||||
callback?(.scanQR)
|
||||
case .displayQR:
|
||||
callback?(.displayQR)
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// 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 AuthenticationQRLoginStartViewModelProtocol {
|
||||
var callback: ((AuthenticationQRLoginStartViewModelResult) -> Void)? { get set }
|
||||
var context: AuthenticationQRLoginStartViewModelType.Context { get }
|
||||
}
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
//
|
||||
// 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 Combine
|
||||
import CommonKit
|
||||
import SwiftUI
|
||||
|
||||
struct AuthenticationQRLoginStartCoordinatorParameters {
|
||||
let navigationRouter: NavigationRouterType
|
||||
let qrLoginService: QRLoginServiceProtocol
|
||||
}
|
||||
|
||||
enum AuthenticationQRLoginStartCoordinatorResult {
|
||||
/// Login with QR done
|
||||
case done
|
||||
}
|
||||
|
||||
final class AuthenticationQRLoginStartCoordinator: Coordinator, Presentable {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private let parameters: AuthenticationQRLoginStartCoordinatorParameters
|
||||
private let onboardingQRLoginStartHostingController: VectorHostingController
|
||||
private var onboardingQRLoginStartViewModel: AuthenticationQRLoginStartViewModelProtocol
|
||||
|
||||
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
|
||||
private var loadingIndicator: UserIndicator?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private var navigationRouter: NavigationRouterType { parameters.navigationRouter }
|
||||
private var qrLoginService: QRLoginServiceProtocol { parameters.qrLoginService }
|
||||
|
||||
// MARK: Public
|
||||
|
||||
// Must be used only internally
|
||||
var childCoordinators: [Coordinator] = []
|
||||
var callback: ((AuthenticationQRLoginStartCoordinatorResult) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(parameters: AuthenticationQRLoginStartCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
let viewModel = AuthenticationQRLoginStartViewModel(qrLoginService: parameters.qrLoginService)
|
||||
let view = AuthenticationQRLoginStartScreen(context: viewModel.context)
|
||||
onboardingQRLoginStartViewModel = viewModel
|
||||
|
||||
onboardingQRLoginStartHostingController = VectorHostingController(rootView: view)
|
||||
onboardingQRLoginStartHostingController.vc_removeBackTitle()
|
||||
onboardingQRLoginStartHostingController.enableNavigationBarScrollEdgeAppearance = true
|
||||
|
||||
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingQRLoginStartHostingController)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func start() {
|
||||
MXLog.debug("[AuthenticationQRLoginStartCoordinator] did start.")
|
||||
onboardingQRLoginStartViewModel.callback = { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
MXLog.debug("[AuthenticationQRLoginStartCoordinator] AuthenticationQRLoginStartViewModel did complete with result: \(result).")
|
||||
|
||||
switch result {
|
||||
case .scanQR:
|
||||
self.showScanQRScreen()
|
||||
case .displayQR:
|
||||
self.showDisplayQRScreen()
|
||||
}
|
||||
}
|
||||
|
||||
qrLoginService.callbacks.sink { [weak self] callback in
|
||||
guard let self = self else { return }
|
||||
switch callback {
|
||||
case .didUpdateState:
|
||||
self.processServiceState(self.qrLoginService.state)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
onboardingQRLoginStartHostingController
|
||||
}
|
||||
|
||||
/// Stops any ongoing activities in the coordinator.
|
||||
func stop() {
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func processServiceState(_ state: QRLoginServiceState) {
|
||||
switch state {
|
||||
case .initial:
|
||||
removeAllChildren()
|
||||
case .connectingToDevice, .waitingForRemoteSignIn, .completed:
|
||||
showLoadingScreenIfNeeded()
|
||||
case .waitingForConfirmation:
|
||||
showConfirmationScreenIfNeeded()
|
||||
case .failed(let error):
|
||||
switch error {
|
||||
case .noCameraAccess, .noCameraAvailable:
|
||||
// handled in scanning screen
|
||||
break
|
||||
default:
|
||||
showFailureScreenIfNeeded()
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func removeAllChildren(animated: Bool = true) {
|
||||
MXLog.debug("[AuthenticationQRLoginStartCoordinator] removeAllChildren")
|
||||
|
||||
guard !childCoordinators.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
for coordinator in childCoordinators.reversed() {
|
||||
remove(childCoordinator: coordinator)
|
||||
}
|
||||
|
||||
navigationRouter.popToModule(self, animated: animated)
|
||||
}
|
||||
|
||||
/// Shows the scan QR screen.
|
||||
private func showScanQRScreen() {
|
||||
MXLog.debug("[AuthenticationQRLoginStartCoordinator] showScanQRScreen")
|
||||
|
||||
let parameters = AuthenticationQRLoginScanCoordinatorParameters(navigationRouter: navigationRouter,
|
||||
qrLoginService: qrLoginService)
|
||||
let coordinator = AuthenticationQRLoginScanCoordinator(parameters: parameters)
|
||||
coordinator.callback = { [weak self, weak coordinator] _ in
|
||||
guard let self = self, let coordinator = coordinator else { return }
|
||||
self.remove(childCoordinator: coordinator)
|
||||
}
|
||||
|
||||
coordinator.start()
|
||||
add(childCoordinator: coordinator)
|
||||
|
||||
navigationRouter.push(coordinator, animated: true) { [weak self] in
|
||||
self?.remove(childCoordinator: coordinator)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows the display QR screen.
|
||||
private func showDisplayQRScreen() {
|
||||
MXLog.debug("[AuthenticationQRLoginStartCoordinator] showDisplayQRScreen")
|
||||
|
||||
let parameters = AuthenticationQRLoginDisplayCoordinatorParameters(navigationRouter: navigationRouter,
|
||||
qrLoginService: qrLoginService)
|
||||
let coordinator = AuthenticationQRLoginDisplayCoordinator(parameters: parameters)
|
||||
coordinator.callback = { [weak self, weak coordinator] _ in
|
||||
guard let self = self, let coordinator = coordinator else { return }
|
||||
self.remove(childCoordinator: coordinator)
|
||||
}
|
||||
|
||||
coordinator.start()
|
||||
add(childCoordinator: coordinator)
|
||||
|
||||
navigationRouter.push(coordinator, animated: true) { [weak self] in
|
||||
self?.remove(childCoordinator: coordinator)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows the loading screen.
|
||||
private func showLoadingScreenIfNeeded() {
|
||||
MXLog.debug("[AuthenticationQRLoginStartCoordinator] showLoadingScreenIfNeeded")
|
||||
|
||||
if let lastCoordinator = childCoordinators.last,
|
||||
lastCoordinator is AuthenticationQRLoginLoadingCoordinator {
|
||||
// if the last screen is loading, do nothing. It'll be updated by the service state.
|
||||
return
|
||||
}
|
||||
|
||||
let parameters = AuthenticationQRLoginLoadingCoordinatorParameters(navigationRouter: navigationRouter,
|
||||
qrLoginService: qrLoginService)
|
||||
let coordinator = AuthenticationQRLoginLoadingCoordinator(parameters: parameters)
|
||||
coordinator.callback = { [weak self, weak coordinator] _ in
|
||||
guard let self = self, let coordinator = coordinator else { return }
|
||||
self.remove(childCoordinator: coordinator)
|
||||
}
|
||||
|
||||
coordinator.start()
|
||||
add(childCoordinator: coordinator)
|
||||
|
||||
navigationRouter.push(coordinator, animated: true) { [weak self] in
|
||||
self?.remove(childCoordinator: coordinator)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows the confirmation screen.
|
||||
private func showConfirmationScreenIfNeeded() {
|
||||
MXLog.debug("[AuthenticationQRLoginStartCoordinator] showConfirmationScreenIfNeeded")
|
||||
|
||||
if let lastCoordinator = childCoordinators.last,
|
||||
lastCoordinator is AuthenticationQRLoginConfirmCoordinator {
|
||||
// if the last screen is confirmation, do nothing. It'll be updated by the service state.
|
||||
return
|
||||
}
|
||||
|
||||
let parameters = AuthenticationQRLoginConfirmCoordinatorParameters(navigationRouter: navigationRouter,
|
||||
qrLoginService: qrLoginService)
|
||||
let coordinator = AuthenticationQRLoginConfirmCoordinator(parameters: parameters)
|
||||
coordinator.callback = { [weak self, weak coordinator] _ in
|
||||
guard let self = self, let coordinator = coordinator else { return }
|
||||
self.remove(childCoordinator: coordinator)
|
||||
}
|
||||
|
||||
coordinator.start()
|
||||
add(childCoordinator: coordinator)
|
||||
|
||||
navigationRouter.push(coordinator, animated: true) { [weak self] in
|
||||
self?.remove(childCoordinator: coordinator)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows the failure screen.
|
||||
private func showFailureScreenIfNeeded() {
|
||||
MXLog.debug("[AuthenticationQRLoginStartCoordinator] showFailureScreenIfNeeded")
|
||||
|
||||
if let lastCoordinator = childCoordinators.last,
|
||||
lastCoordinator is AuthenticationQRLoginFailureCoordinator {
|
||||
// if the last screen is failure, do nothing. It'll be updated by the service state.
|
||||
return
|
||||
}
|
||||
|
||||
let parameters = AuthenticationQRLoginFailureCoordinatorParameters(navigationRouter: navigationRouter,
|
||||
qrLoginService: qrLoginService)
|
||||
let coordinator = AuthenticationQRLoginFailureCoordinator(parameters: parameters)
|
||||
coordinator.callback = { [weak self, weak coordinator] _ in
|
||||
guard let self = self, let coordinator = coordinator else { return }
|
||||
self.remove(childCoordinator: coordinator)
|
||||
}
|
||||
|
||||
coordinator.start()
|
||||
add(childCoordinator: coordinator)
|
||||
|
||||
navigationRouter.push(coordinator, animated: true) { [weak self] in
|
||||
self?.remove(childCoordinator: coordinator)
|
||||
}
|
||||
}
|
||||
|
||||
/// Show an activity indicator whilst loading.
|
||||
private func startLoading() {
|
||||
loadingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true))
|
||||
}
|
||||
|
||||
/// Hide the currently displayed activity indicator.
|
||||
private func stopLoading() {
|
||||
loadingIndicator = nil
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
//
|
||||
// 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.
|
||||
enum MockAuthenticationQRLoginStartScreenState: MockScreenState, CaseIterable {
|
||||
// A case for each state you want to represent
|
||||
// with specific, minimal associated data that will allow you
|
||||
// mock that screen.
|
||||
case `default`
|
||||
|
||||
/// The associated screen
|
||||
var screenType: Any.Type {
|
||||
AuthenticationQRLoginStartScreen.self
|
||||
}
|
||||
|
||||
/// A list of screen state definitions
|
||||
static var allCases: [MockAuthenticationQRLoginStartScreenState] {
|
||||
// Each of the presence statuses
|
||||
[.default]
|
||||
}
|
||||
|
||||
/// Generate the view struct for the screen state.
|
||||
var screenView: ([Any], AnyView) {
|
||||
let viewModel = AuthenticationQRLoginStartViewModel(qrLoginService: MockQRLoginService())
|
||||
|
||||
// can simulate service and viewModel actions here if needs be.
|
||||
|
||||
return (
|
||||
[self, viewModel],
|
||||
AnyView(AuthenticationQRLoginStartScreen(context: viewModel.context))
|
||||
)
|
||||
}
|
||||
}
|
||||
+35
@@ -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 RiotSwiftUI
|
||||
import XCTest
|
||||
|
||||
class AuthenticationQRLoginStartUITests: MockScreenTestCase {
|
||||
func testDefault() {
|
||||
app.goToScreenWithIdentifier(MockAuthenticationQRLoginStartScreenState.default.title)
|
||||
|
||||
XCTAssertTrue(app.staticTexts["titleLabel"].exists)
|
||||
XCTAssertTrue(app.staticTexts["subtitleLabel"].exists)
|
||||
|
||||
let scanQRButton = app.buttons["scanQRButton"]
|
||||
XCTAssertTrue(scanQRButton.exists)
|
||||
XCTAssertTrue(scanQRButton.isEnabled)
|
||||
|
||||
let displayQRButton = app.buttons["displayQRButton"]
|
||||
XCTAssertTrue(displayQRButton.exists)
|
||||
XCTAssertTrue(displayQRButton.isEnabled)
|
||||
}
|
||||
}
|
||||
+53
@@ -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
|
||||
|
||||
@testable import RiotSwiftUI
|
||||
|
||||
class AuthenticationQRLoginStartViewModelTests: XCTestCase {
|
||||
var viewModel: AuthenticationQRLoginStartViewModelProtocol!
|
||||
var context: AuthenticationQRLoginStartViewModelType.Context!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
viewModel = AuthenticationQRLoginStartViewModel(qrLoginService: MockQRLoginService())
|
||||
context = viewModel.context
|
||||
}
|
||||
|
||||
func testScanQR() {
|
||||
var result: AuthenticationQRLoginStartViewModelResult?
|
||||
|
||||
viewModel.callback = { callbackResult in
|
||||
result = callbackResult
|
||||
}
|
||||
|
||||
context.send(viewAction: .scanQR)
|
||||
|
||||
XCTAssertEqual(result, .scanQR)
|
||||
}
|
||||
|
||||
func testDisplayQR() {
|
||||
var result: AuthenticationQRLoginStartViewModelResult?
|
||||
|
||||
viewModel.callback = { callbackResult in
|
||||
result = callbackResult
|
||||
}
|
||||
|
||||
context.send(viewAction: .displayQR)
|
||||
|
||||
XCTAssertEqual(result, .displayQR)
|
||||
}
|
||||
}
|
||||
+157
@@ -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
|
||||
|
||||
/// The screen shown to a new user to select their use case for the app.
|
||||
struct AuthenticationQRLoginStartScreen: View {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
@Environment(\.theme) private var theme
|
||||
@ScaledMetric private var iconSize = 70.0
|
||||
|
||||
// MARK: Public
|
||||
|
||||
@ObservedObject var context: AuthenticationQRLoginStartViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ScrollView {
|
||||
titleContent
|
||||
.padding(.top, OnboardingMetrics.topPaddingToNavigationBar)
|
||||
stepsView
|
||||
}
|
||||
.readableFrame()
|
||||
|
||||
footerContent
|
||||
.padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
.background(theme.colors.background.ignoresSafeArea())
|
||||
}
|
||||
|
||||
/// The screen's title and instructions.
|
||||
var titleContent: some View {
|
||||
VStack(spacing: 16) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(theme.colors.accent)
|
||||
Image(Asset.Images.camera.name)
|
||||
.resizable()
|
||||
.renderingMode(.template)
|
||||
.foregroundColor(.white)
|
||||
.aspectRatio(1.0, contentMode: .fit)
|
||||
.padding(14)
|
||||
}
|
||||
.frame(width: iconSize, height: iconSize)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
Text(VectorL10n.authenticationQrLoginStartTitle)
|
||||
.font(theme.fonts.title2B)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(theme.colors.primaryContent)
|
||||
.accessibilityIdentifier("titleLabel")
|
||||
|
||||
Text(VectorL10n.authenticationQrLoginStartSubtitle)
|
||||
.font(theme.fonts.body)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(theme.colors.secondaryContent)
|
||||
.padding(.bottom, 24)
|
||||
.accessibilityIdentifier("subtitleLabel")
|
||||
}
|
||||
}
|
||||
|
||||
/// The screen's footer.
|
||||
var footerContent: some View {
|
||||
VStack(spacing: 12) {
|
||||
Button(action: scanQR) {
|
||||
Text(VectorL10n.authenticationQrLoginStartTitle)
|
||||
}
|
||||
.buttonStyle(PrimaryActionButtonStyle(font: theme.fonts.bodySB))
|
||||
.padding(.bottom, 8)
|
||||
.accessibilityIdentifier("scanQRButton")
|
||||
|
||||
LabelledDivider(label: VectorL10n.authenticationQrLoginStartNeedAlternative)
|
||||
|
||||
Button(action: displayQR) {
|
||||
Text(VectorL10n.authenticationQrLoginStartDisplayQr)
|
||||
}
|
||||
.buttonStyle(SecondaryActionButtonStyle(font: theme.fonts.bodySB))
|
||||
.accessibilityIdentifier("displayQRButton")
|
||||
}
|
||||
}
|
||||
|
||||
/// The buttons used to select a use case for the app.
|
||||
var stepsView: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ForEach(steps) { step in
|
||||
HStack {
|
||||
Text(String(step.id))
|
||||
.font(theme.fonts.caption2SB)
|
||||
.foregroundColor(theme.colors.accent)
|
||||
.padding(6)
|
||||
.shapedBorder(color: theme.colors.accent, borderWidth: 1, shape: Circle())
|
||||
.offset(x: 1, y: 0)
|
||||
Text(step.description)
|
||||
.foregroundColor(theme.colors.primaryContent)
|
||||
.font(theme.fonts.subheadline)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let steps = [
|
||||
QRLoginStartStep(id: 1, description: VectorL10n.authenticationQrLoginStartStep1),
|
||||
QRLoginStartStep(id: 2, description: VectorL10n.authenticationQrLoginStartStep2),
|
||||
QRLoginStartStep(id: 3, description: VectorL10n.authenticationQrLoginStartStep3),
|
||||
QRLoginStartStep(id: 4, description: VectorL10n.authenticationQrLoginStartStep4)
|
||||
]
|
||||
|
||||
/// Sends the `scanQR` view action.
|
||||
func scanQR() {
|
||||
context.send(viewAction: .scanQR)
|
||||
}
|
||||
|
||||
/// Sends the `displayQR` view action.
|
||||
func displayQR() {
|
||||
context.send(viewAction: .displayQR)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct AuthenticationQRLoginStart_Previews: PreviewProvider {
|
||||
static let stateRenderer = MockAuthenticationQRLoginStartScreenState.stateRenderer
|
||||
|
||||
static var previews: some View {
|
||||
stateRenderer.screenGroup(addNavigation: true)
|
||||
.theme(.light).preferredColorScheme(.light)
|
||||
.navigationViewStyle(.stack)
|
||||
stateRenderer.screenGroup(addNavigation: true)
|
||||
.theme(.dark).preferredColorScheme(.dark)
|
||||
.navigationViewStyle(.stack)
|
||||
}
|
||||
}
|
||||
|
||||
private struct QRLoginStartStep: Identifiable {
|
||||
let id: Int
|
||||
let description: String
|
||||
}
|
||||
@@ -35,6 +35,12 @@ enum MockAppScreens {
|
||||
MockAuthenticationForgotPasswordScreenState.self,
|
||||
MockAuthenticationChoosePasswordScreenState.self,
|
||||
MockAuthenticationSoftLogoutScreenState.self,
|
||||
MockAuthenticationQRLoginStartScreenState.self,
|
||||
MockAuthenticationQRLoginDisplayScreenState.self,
|
||||
MockAuthenticationQRLoginScanScreenState.self,
|
||||
MockAuthenticationQRLoginConfirmScreenState.self,
|
||||
MockAuthenticationQRLoginLoadingScreenState.self,
|
||||
MockAuthenticationQRLoginFailureScreenState.self,
|
||||
MockOnboardingCelebrationScreenState.self,
|
||||
MockOnboardingAvatarScreenState.self,
|
||||
MockOnboardingDisplayNameScreenState.self,
|
||||
|
||||
@@ -19,8 +19,11 @@ import SwiftUI
|
||||
struct PrimaryActionButtonStyle: ButtonStyle {
|
||||
@Environment(\.theme) private var theme
|
||||
@Environment(\.isEnabled) private var isEnabled
|
||||
|
||||
|
||||
/// `theme.colors.accent` by default
|
||||
var customColor: Color?
|
||||
/// `theme.colors.body` by default
|
||||
var font: Font?
|
||||
|
||||
private var fontColor: Color {
|
||||
// Always white unless disabled with a dark theme.
|
||||
@@ -36,7 +39,7 @@ struct PrimaryActionButtonStyle: ButtonStyle {
|
||||
.padding(12.0)
|
||||
.frame(maxWidth: .infinity)
|
||||
.foregroundColor(fontColor)
|
||||
.font(theme.fonts.body)
|
||||
.font(font ?? theme.fonts.body)
|
||||
.background(backgroundColor.opacity(backgroundOpacity(when: configuration.isPressed)))
|
||||
.cornerRadius(8.0)
|
||||
}
|
||||
|
||||
@@ -19,15 +19,18 @@ import SwiftUI
|
||||
struct SecondaryActionButtonStyle: ButtonStyle {
|
||||
@Environment(\.theme) private var theme
|
||||
@Environment(\.isEnabled) private var isEnabled
|
||||
|
||||
|
||||
/// `theme.colors.accent` by default
|
||||
var customColor: Color?
|
||||
/// `theme.fonts.body` by default
|
||||
var font: Font?
|
||||
|
||||
func makeBody(configuration: Self.Configuration) -> some View {
|
||||
configuration.label
|
||||
.padding(12.0)
|
||||
.frame(maxWidth: .infinity)
|
||||
.foregroundColor(customColor ?? theme.colors.accent)
|
||||
.font(theme.fonts.body)
|
||||
.font(font ?? theme.fonts.body)
|
||||
.background(RoundedRectangle(cornerRadius: 8)
|
||||
.strokeBorder()
|
||||
.foregroundColor(customColor ?? theme.colors.accent))
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
//
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
@@ -139,21 +139,21 @@ struct UserSessionCardViewPreview: View {
|
||||
|
||||
init(isCurrent: Bool = false) {
|
||||
let sessionInfo = UserSessionInfo(id: "alice",
|
||||
name: "iOS",
|
||||
deviceType: .mobile,
|
||||
isVerified: false,
|
||||
lastSeenIP: "10.0.0.10",
|
||||
lastSeenTimestamp: nil,
|
||||
applicationName: "Element iOS",
|
||||
applicationVersion: "1.0.0",
|
||||
applicationURL: nil,
|
||||
deviceModel: nil,
|
||||
deviceOS: "iOS 15.5",
|
||||
lastSeenIPLocation: nil,
|
||||
clientName: "Element",
|
||||
clientVersion: "1.0.0",
|
||||
isActive: true,
|
||||
isCurrent: isCurrent)
|
||||
name: "iOS",
|
||||
deviceType: .mobile,
|
||||
isVerified: false,
|
||||
lastSeenIP: "10.0.0.10",
|
||||
lastSeenTimestamp: nil,
|
||||
applicationName: "Element iOS",
|
||||
applicationVersion: "1.0.0",
|
||||
applicationURL: nil,
|
||||
deviceModel: nil,
|
||||
deviceOS: "iOS 15.5",
|
||||
lastSeenIPLocation: nil,
|
||||
clientName: "Element",
|
||||
clientVersion: "1.0.0",
|
||||
isActive: true,
|
||||
isCurrent: isCurrent)
|
||||
viewData = UserSessionCardViewData(sessionInfo: sessionInfo)
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable {
|
||||
init(parameters: UserSessionsFlowCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
self.navigationRouter = parameters.router
|
||||
navigationRouter = parameters.router
|
||||
errorPresenter = MXKErrorAlertPresentation()
|
||||
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: parameters.router.toPresentable())
|
||||
}
|
||||
@@ -71,10 +71,12 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable {
|
||||
self.showLogoutConfirmation(for: sessionInfo)
|
||||
case let .openSessionOverview(sessionInfo: sessionInfo):
|
||||
self.openSessionOverview(sessionInfo: sessionInfo)
|
||||
case let .openOtherSessions(sessionsInfo: sessionsInfo, filter: filter):
|
||||
self.openOtherSessions(sessionsInfo: sessionsInfo,
|
||||
case let .openOtherSessions(sessionInfos: sessionInfos, filter: filter):
|
||||
self.openOtherSessions(sessionInfos: sessionInfos,
|
||||
filterBy: filter,
|
||||
title: VectorL10n.userOtherSessionSecurityRecommendationTitle)
|
||||
case .linkDevice:
|
||||
self.openQRLoginScreen()
|
||||
}
|
||||
}
|
||||
return coordinator
|
||||
@@ -105,6 +107,21 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable {
|
||||
}
|
||||
pushScreen(with: coordinator)
|
||||
}
|
||||
|
||||
/// Shows the QR login screen.
|
||||
private func openQRLoginScreen() {
|
||||
let service = QRLoginService(client: parameters.session.matrixRestClient,
|
||||
mode: .authenticated)
|
||||
let parameters = AuthenticationQRLoginStartCoordinatorParameters(navigationRouter: navigationRouter,
|
||||
qrLoginService: service)
|
||||
let coordinator = AuthenticationQRLoginStartCoordinator(parameters: parameters)
|
||||
coordinator.callback = { [weak self, weak coordinator] _ in
|
||||
guard let self = self, let coordinator = coordinator else { return }
|
||||
self.remove(childCoordinator: coordinator)
|
||||
}
|
||||
|
||||
pushScreen(with: coordinator)
|
||||
}
|
||||
|
||||
private func createUserSessionOverviewCoordinator(sessionInfo: UserSessionInfo) -> UserSessionOverviewCoordinator {
|
||||
let parameters = UserSessionOverviewCoordinatorParameters(session: parameters.session,
|
||||
@@ -112,8 +129,8 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable {
|
||||
return UserSessionOverviewCoordinator(parameters: parameters)
|
||||
}
|
||||
|
||||
private func openOtherSessions(sessionsInfo: [UserSessionInfo], filterBy filter: OtherUserSessionsFilter, title: String) {
|
||||
let coordinator = createOtherSessionsCoordinator(sessionsInfo: sessionsInfo,
|
||||
private func openOtherSessions(sessionInfos: [UserSessionInfo], filterBy filter: OtherUserSessionsFilter, title: String) {
|
||||
let coordinator = createOtherSessionsCoordinator(sessionInfos: sessionInfos,
|
||||
filterBy: filter,
|
||||
title: title)
|
||||
coordinator.completion = { [weak self] result in
|
||||
@@ -126,16 +143,15 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable {
|
||||
pushScreen(with: coordinator)
|
||||
}
|
||||
|
||||
private func createOtherSessionsCoordinator(sessionsInfo: [UserSessionInfo],
|
||||
private func createOtherSessionsCoordinator(sessionInfos: [UserSessionInfo],
|
||||
filterBy filter: OtherUserSessionsFilter,
|
||||
title: String) -> UserOtherSessionsCoordinator {
|
||||
let parameters = UserOtherSessionsCoordinatorParameters(sessionsInfo: sessionsInfo,
|
||||
let parameters = UserOtherSessionsCoordinatorParameters(sessionInfos: sessionInfos,
|
||||
filter: filter,
|
||||
title: title)
|
||||
return UserOtherSessionsCoordinator(parameters: parameters)
|
||||
}
|
||||
|
||||
|
||||
/// Shows a confirmation dialog to the user to sign out of a session.
|
||||
private func showLogoutConfirmation(for sessionInfo: UserSessionInfo) {
|
||||
// Use a UIAlertController as we don't have confirmationDialog in SwiftUI on iOS 14.
|
||||
|
||||
+2
-3
@@ -18,13 +18,12 @@ import CommonKit
|
||||
import SwiftUI
|
||||
|
||||
struct UserOtherSessionsCoordinatorParameters {
|
||||
let sessionsInfo: [UserSessionInfo]
|
||||
let sessionInfos: [UserSessionInfo]
|
||||
let filter: OtherUserSessionsFilter
|
||||
let title: String
|
||||
}
|
||||
|
||||
final class UserOtherSessionsCoordinator: Coordinator, Presentable {
|
||||
|
||||
private let parameters: UserOtherSessionsCoordinatorParameters
|
||||
private let userOtherSessionsHostingController: UIViewController
|
||||
private var userOtherSessionsViewModel: UserOtherSessionsViewModelProtocol
|
||||
@@ -38,7 +37,7 @@ final class UserOtherSessionsCoordinator: Coordinator, Presentable {
|
||||
init(parameters: UserOtherSessionsCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
let viewModel = UserOtherSessionsViewModel(sessionsInfo: parameters.sessionsInfo,
|
||||
let viewModel = UserOtherSessionsViewModel(sessionInfos: parameters.sessionInfos,
|
||||
filter: parameters.filter,
|
||||
title: parameters.title)
|
||||
let view = UserOtherSessions(viewModel: viewModel.context)
|
||||
|
||||
+49
-6
@@ -25,6 +25,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
|
||||
// mock that screen.
|
||||
|
||||
case inactiveSessions
|
||||
case unverifiedSessions
|
||||
|
||||
/// The associated screen
|
||||
var screenType: Any.Type {
|
||||
@@ -34,15 +35,22 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
|
||||
/// A list of screen state definitions
|
||||
static var allCases: [MockUserOtherSessionsScreenState] {
|
||||
// Each of the presence statuses
|
||||
[.inactiveSessions]
|
||||
[.inactiveSessions, .unverifiedSessions]
|
||||
}
|
||||
|
||||
/// Generate the view struct for the screen state.
|
||||
var screenView: ([Any], AnyView) {
|
||||
|
||||
let viewModel = UserOtherSessionsViewModel(sessionsInfo: inactiveSessions(),
|
||||
let viewModel: UserOtherSessionsViewModel
|
||||
switch self {
|
||||
case .inactiveSessions:
|
||||
viewModel = UserOtherSessionsViewModel(sessionInfos: inactiveSessions(),
|
||||
filter: .inactive,
|
||||
title: VectorL10n.userOtherSessionSecurityRecommendationTitle)
|
||||
case .unverifiedSessions:
|
||||
viewModel = UserOtherSessionsViewModel(sessionInfos: unverifiedSessions(),
|
||||
filter: .unverified,
|
||||
title: VectorL10n.userOtherSessionSecurityRecommendationTitle)
|
||||
}
|
||||
|
||||
// can simulate service and viewModel actions here if needs be.
|
||||
|
||||
@@ -74,7 +82,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
|
||||
deviceType: .desktop,
|
||||
isVerified: true,
|
||||
lastSeenIP: "1.0.0.1",
|
||||
lastSeenTimestamp: Date().timeIntervalSince1970 - 8000000,
|
||||
lastSeenTimestamp: Date().timeIntervalSince1970 - 8_000_000,
|
||||
applicationName: nil,
|
||||
applicationVersion: nil,
|
||||
applicationURL: nil,
|
||||
@@ -90,7 +98,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
|
||||
deviceType: .web,
|
||||
isVerified: true,
|
||||
lastSeenIP: "2.0.0.2",
|
||||
lastSeenTimestamp: Date().timeIntervalSince1970 - 9000000,
|
||||
lastSeenTimestamp: Date().timeIntervalSince1970 - 9_000_000,
|
||||
applicationName: nil,
|
||||
applicationVersion: nil,
|
||||
applicationURL: nil,
|
||||
@@ -106,7 +114,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
|
||||
deviceType: .mobile,
|
||||
isVerified: false,
|
||||
lastSeenIP: "3.0.0.3",
|
||||
lastSeenTimestamp: Date().timeIntervalSince1970 - 10000000,
|
||||
lastSeenTimestamp: Date().timeIntervalSince1970 - 10_000_000,
|
||||
applicationName: nil,
|
||||
applicationVersion: nil,
|
||||
applicationURL: nil,
|
||||
@@ -118,4 +126,39 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
|
||||
isActive: false,
|
||||
isCurrent: false)]
|
||||
}
|
||||
|
||||
private func unverifiedSessions() -> [UserSessionInfo] {
|
||||
[UserSessionInfo(id: "0",
|
||||
name: "iOS",
|
||||
deviceType: .mobile,
|
||||
isVerified: false,
|
||||
lastSeenIP: "10.0.0.10",
|
||||
lastSeenTimestamp: nil,
|
||||
applicationName: nil,
|
||||
applicationVersion: nil,
|
||||
applicationURL: nil,
|
||||
deviceModel: nil,
|
||||
deviceOS: nil,
|
||||
lastSeenIPLocation: nil,
|
||||
clientName: nil,
|
||||
clientVersion: nil,
|
||||
isActive: true,
|
||||
isCurrent: true),
|
||||
UserSessionInfo(id: "1",
|
||||
name: "macOS",
|
||||
deviceType: .desktop,
|
||||
isVerified: false,
|
||||
lastSeenIP: "1.0.0.1",
|
||||
lastSeenTimestamp: Date().timeIntervalSince1970 - 8_000_000,
|
||||
applicationName: nil,
|
||||
applicationVersion: nil,
|
||||
applicationURL: nil,
|
||||
deviceModel: nil,
|
||||
deviceOS: nil,
|
||||
lastSeenIPLocation: nil,
|
||||
clientName: nil,
|
||||
clientVersion: nil,
|
||||
isActive: true,
|
||||
isCurrent: false)]
|
||||
}
|
||||
}
|
||||
|
||||
+14
-2
@@ -18,12 +18,11 @@ import RiotSwiftUI
|
||||
import XCTest
|
||||
|
||||
class UserOtherSessionsUITests: MockScreenTestCase {
|
||||
|
||||
func test_whenOtherSessionsWithInactiveSessionFilterPresented_correctHeaderDisplayed() {
|
||||
app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.inactiveSessions.title)
|
||||
|
||||
XCTAssertTrue(app.staticTexts[VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveTitle].exists)
|
||||
XCTAssertTrue(app.staticTexts[VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo].exists)
|
||||
XCTAssertTrue(app.staticTexts[VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo].exists)
|
||||
}
|
||||
|
||||
func test_whenOtherSessionsWithInactiveSessionFilterPresented_correctItemsDisplayed() {
|
||||
@@ -31,4 +30,17 @@ class UserOtherSessionsUITests: MockScreenTestCase {
|
||||
|
||||
XCTAssertTrue(app.buttons["RiotSwiftUI Mobile: iOS, Inactive for 90+ days"].exists)
|
||||
}
|
||||
|
||||
func test_whenOtherSessionsWithUnverifiedSessionFilterPresented_correctHeaderDisplayed() {
|
||||
app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.unverifiedSessions.title)
|
||||
|
||||
XCTAssertTrue(app.staticTexts[VectorL10n.userSessionsOverviewSecurityRecommendationsUnverifiedTitle].exists)
|
||||
XCTAssertTrue(app.staticTexts[VectorL10n.userOtherSessionUnverifiedSessionsHeaderSubtitle].exists)
|
||||
}
|
||||
|
||||
func test_whenOtherSessionsWithUnverifiedSessionFilterPresented_correctItemsDisplayed() {
|
||||
app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.unverifiedSessions.title)
|
||||
|
||||
XCTAssertTrue(app.buttons["RiotSwiftUI Mobile: iOS, Unverified · Your current session"].exists)
|
||||
}
|
||||
}
|
||||
|
||||
+9
-12
@@ -19,14 +19,12 @@ import XCTest
|
||||
@testable import RiotSwiftUI
|
||||
|
||||
class UserOtherSessionsViewModelTests: XCTestCase {
|
||||
|
||||
|
||||
func test_whenUserOtherSessionSelectedProcessed_completionWithShowUserSessionOverviewCalled() {
|
||||
let expectedUserSessionInfo = createUserSessionInfo(sessionId: "session 2")
|
||||
let sut = UserOtherSessionsViewModel(sessionsInfo: [createUserSessionInfo(sessionId: "session 1"),
|
||||
expectedUserSessionInfo],
|
||||
filter: .inactive,
|
||||
title: "Title")
|
||||
let sut = UserOtherSessionsViewModel(sessionInfos: [createUserSessionInfo(sessionId: "session 1"),
|
||||
expectedUserSessionInfo],
|
||||
filter: .inactive,
|
||||
title: "Title")
|
||||
|
||||
var modelResult: UserOtherSessionsViewModelResult?
|
||||
sut.completion = { result in
|
||||
@@ -37,21 +35,20 @@ class UserOtherSessionsViewModelTests: XCTestCase {
|
||||
}
|
||||
|
||||
func test_whenModelCreated_withInactiveFilter_viewStateIsCorrect() {
|
||||
let sessionsInfo = [createUserSessionInfo(sessionId: "session 1"), createUserSessionInfo(sessionId: "session 2")]
|
||||
let sut = UserOtherSessionsViewModel(sessionsInfo: sessionsInfo,
|
||||
filter: .inactive,
|
||||
title: "Title")
|
||||
let sessionInfos = [createUserSessionInfo(sessionId: "session 1"), createUserSessionInfo(sessionId: "session 2")]
|
||||
let sut = UserOtherSessionsViewModel(sessionInfos: sessionInfos,
|
||||
filter: .inactive,
|
||||
title: "Title")
|
||||
|
||||
let expectedHeader = UserOtherSessionsHeaderViewData(title: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveTitle,
|
||||
subtitle: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo,
|
||||
iconName: Asset.Images.userOtherSessionsInactive.name)
|
||||
let expectedItems = sessionsInfo.filter { !$0.isActive }.asViewData()
|
||||
let expectedItems = sessionInfos.filter { !$0.isActive }.asViewData()
|
||||
let expectedState = UserOtherSessionsViewState(title: "Title",
|
||||
sections: [.sessionItems(header: expectedHeader, items: expectedItems)])
|
||||
XCTAssertEqual(sut.state, expectedState)
|
||||
}
|
||||
|
||||
|
||||
private func createUserSessionInfo(sessionId: String) -> UserSessionInfo {
|
||||
UserSessionInfo(id: sessionId,
|
||||
name: "iOS",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Coordinator
|
||||
|
||||
enum UserOtherSessionsCoordinatorResult {
|
||||
case openSessionDetails(sessionInfo: UserSessionInfo)
|
||||
}
|
||||
@@ -38,6 +39,7 @@ enum UserOtherSessionsSection: Hashable, Identifiable {
|
||||
var id: Self {
|
||||
self
|
||||
}
|
||||
|
||||
case sessionItems(header: UserOtherSessionsHeaderViewData, items: [UserSessionListItemViewData])
|
||||
}
|
||||
|
||||
|
||||
+22
-17
@@ -25,16 +25,15 @@ enum OtherUserSessionsFilter {
|
||||
}
|
||||
|
||||
class UserOtherSessionsViewModel: UserOtherSessionsViewModelType, UserOtherSessionsViewModelProtocol {
|
||||
|
||||
var completion: ((UserOtherSessionsViewModelResult) -> Void)?
|
||||
private let sessionsInfo: [UserSessionInfo]
|
||||
private let sessionInfos: [UserSessionInfo]
|
||||
|
||||
init(sessionsInfo: [UserSessionInfo],
|
||||
init(sessionInfos: [UserSessionInfo],
|
||||
filter: OtherUserSessionsFilter,
|
||||
title: String) {
|
||||
self.sessionsInfo = sessionsInfo
|
||||
self.sessionInfos = sessionInfos
|
||||
super.init(initialViewState: UserOtherSessionsViewState(title: title, sections: []))
|
||||
updateViewState(sessionsInfo: sessionsInfo, filter: filter)
|
||||
updateViewState(sessionInfos: sessionInfos, filter: filter)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
@@ -42,7 +41,7 @@ class UserOtherSessionsViewModel: UserOtherSessionsViewModelType, UserOtherSessi
|
||||
override func process(viewAction: UserOtherSessionsViewAction) {
|
||||
switch viewAction {
|
||||
case let .userOtherSessionSelected(sessionId: sessionId):
|
||||
guard let session = sessionsInfo.first(where: {$0.id == sessionId}) else {
|
||||
guard let session = sessionInfos.first(where: { $0.id == sessionId }) else {
|
||||
assertionFailure("Session should exist in the array.")
|
||||
return
|
||||
}
|
||||
@@ -52,20 +51,28 @@ class UserOtherSessionsViewModel: UserOtherSessionsViewModelType, UserOtherSessi
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func updateViewState(sessionsInfo: [UserSessionInfo], filter: OtherUserSessionsFilter) {
|
||||
let sectionItems = filterSessions(sessionsInfo: sessionsInfo, by: filter).asViewData()
|
||||
private func updateViewState(sessionInfos: [UserSessionInfo], filter: OtherUserSessionsFilter) {
|
||||
let sectionItems = createSectionItems(sessionInfos: sessionInfos, filter: filter)
|
||||
let sectionHeader = createHeaderData(filter: filter)
|
||||
state.sections = [.sessionItems(header: sectionHeader, items: sectionItems)]
|
||||
}
|
||||
|
||||
private func filterSessions(sessionsInfo: [UserSessionInfo], by filter: OtherUserSessionsFilter) -> [UserSessionInfo] {
|
||||
private func createSectionItems(sessionInfos: [UserSessionInfo], filter: OtherUserSessionsFilter) -> [UserSessionListItemViewData] {
|
||||
filterSessions(sessionInfos: sessionInfos, by: filter)
|
||||
.map {
|
||||
UserSessionListItemViewDataFactory().create(from: $0,
|
||||
highlightSessionDetails: filter == .unverified && $0.isCurrent)
|
||||
}
|
||||
}
|
||||
|
||||
private func filterSessions(sessionInfos: [UserSessionInfo], by filter: OtherUserSessionsFilter) -> [UserSessionInfo] {
|
||||
switch filter {
|
||||
case .all:
|
||||
return sessionsInfo.filter { !$0.isCurrent }
|
||||
return sessionInfos.filter { !$0.isCurrent }
|
||||
case .inactive:
|
||||
return sessionsInfo.filter { !$0.isActive }
|
||||
return sessionInfos.filter { !$0.isActive }
|
||||
case .unverified:
|
||||
return sessionsInfo.filter { !$0.isVerified }
|
||||
return sessionInfos.filter { !$0.isVerified }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,11 +88,9 @@ class UserOtherSessionsViewModel: UserOtherSessionsViewModelType, UserOtherSessi
|
||||
subtitle: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo,
|
||||
iconName: Asset.Images.userOtherSessionsInactive.name)
|
||||
case .unverified:
|
||||
// TODO:
|
||||
return UserOtherSessionsHeaderViewData(title: nil,
|
||||
subtitle: "",
|
||||
iconName: nil)
|
||||
return UserOtherSessionsHeaderViewData(title: VectorL10n.userSessionsOverviewSecurityRecommendationsUnverifiedTitle,
|
||||
subtitle: VectorL10n.userOtherSessionUnverifiedSessionsHeaderSubtitle,
|
||||
iconName: Asset.Images.userOtherSessionsUnverified.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
import SwiftUI
|
||||
|
||||
struct UserOtherSessions: View {
|
||||
|
||||
@Environment(\.theme) private var theme
|
||||
|
||||
@ObservedObject var viewModel: UserOtherSessionsViewModel.Context
|
||||
@@ -57,7 +56,6 @@ struct UserOtherSessions: View {
|
||||
// MARK: - Previews
|
||||
|
||||
struct UserOtherSessions_Previews: PreviewProvider {
|
||||
|
||||
static let stateRenderer = MockUserOtherSessionsScreenState.stateRenderer
|
||||
|
||||
static var previews: some View {
|
||||
|
||||
+2
-6
@@ -18,12 +18,11 @@ import SwiftUI
|
||||
|
||||
struct UserOtherSessionsHeaderViewData: Hashable {
|
||||
var title: String?
|
||||
var subtitle: String
|
||||
let subtitle: String
|
||||
var iconName: String?
|
||||
}
|
||||
|
||||
struct UserOtherSessionsHeaderView: View {
|
||||
|
||||
private var backgroundShape: RoundedRectangle {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
}
|
||||
@@ -33,10 +32,9 @@ struct UserOtherSessionsHeaderView: View {
|
||||
let viewData: UserOtherSessionsHeaderViewData
|
||||
|
||||
var body: some View {
|
||||
HStack (alignment: .top, spacing: 0) {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
if let iconName = viewData.iconName {
|
||||
Image(iconName)
|
||||
.foregroundColor(.red)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(theme.colors.background)
|
||||
.clipShape(backgroundShape)
|
||||
@@ -64,12 +62,10 @@ struct UserOtherSessionsHeaderView: View {
|
||||
// MARK: - Previews
|
||||
|
||||
struct UserOtherSessionsHeaderView_Previews: PreviewProvider {
|
||||
|
||||
private static let inactiveSessionViewData = UserOtherSessionsHeaderViewData(title: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveTitle,
|
||||
subtitle: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo,
|
||||
iconName: Asset.Images.userOtherSessionsInactive.name)
|
||||
|
||||
|
||||
static var previews: some View {
|
||||
Group {
|
||||
UserOtherSessionsHeaderView(viewData: self.inactiveSessionViewData)
|
||||
|
||||
+30
-30
@@ -42,38 +42,38 @@ enum MockUserSessionDetailsScreenState: MockScreenState, CaseIterable {
|
||||
switch self {
|
||||
case .allSections:
|
||||
sessionInfo = UserSessionInfo(id: "alice",
|
||||
name: "iOS",
|
||||
deviceType: .mobile,
|
||||
isVerified: false,
|
||||
lastSeenIP: "10.0.0.10",
|
||||
lastSeenTimestamp: nil,
|
||||
applicationName: "Element iOS",
|
||||
applicationVersion: "1.0.0",
|
||||
applicationURL: nil,
|
||||
deviceModel: nil,
|
||||
deviceOS: "iOS 15.5",
|
||||
lastSeenIPLocation: nil,
|
||||
clientName: "Element",
|
||||
clientVersion: "1.0.0",
|
||||
isActive: true,
|
||||
isCurrent: true)
|
||||
name: "iOS",
|
||||
deviceType: .mobile,
|
||||
isVerified: false,
|
||||
lastSeenIP: "10.0.0.10",
|
||||
lastSeenTimestamp: nil,
|
||||
applicationName: "Element iOS",
|
||||
applicationVersion: "1.0.0",
|
||||
applicationURL: nil,
|
||||
deviceModel: nil,
|
||||
deviceOS: "iOS 15.5",
|
||||
lastSeenIPLocation: nil,
|
||||
clientName: "Element",
|
||||
clientVersion: "1.0.0",
|
||||
isActive: true,
|
||||
isCurrent: true)
|
||||
case .sessionSectionOnly:
|
||||
sessionInfo = UserSessionInfo(id: "3",
|
||||
name: "Android",
|
||||
deviceType: .mobile,
|
||||
isVerified: false,
|
||||
lastSeenIP: "3.0.0.3",
|
||||
lastSeenTimestamp: Date().timeIntervalSince1970 - 10,
|
||||
applicationName: "Element Android",
|
||||
applicationVersion: "1.0.0",
|
||||
applicationURL: nil,
|
||||
deviceModel: nil,
|
||||
deviceOS: "Android 4.0",
|
||||
lastSeenIPLocation: nil,
|
||||
clientName: "Element",
|
||||
clientVersion: "1.0.0",
|
||||
isActive: true,
|
||||
isCurrent: false)
|
||||
name: "Android",
|
||||
deviceType: .mobile,
|
||||
isVerified: false,
|
||||
lastSeenIP: "3.0.0.3",
|
||||
lastSeenTimestamp: Date().timeIntervalSince1970 - 10,
|
||||
applicationName: "Element Android",
|
||||
applicationVersion: "1.0.0",
|
||||
applicationURL: nil,
|
||||
deviceModel: nil,
|
||||
deviceOS: "Android 4.0",
|
||||
lastSeenIPLocation: nil,
|
||||
clientName: "Element",
|
||||
clientVersion: "1.0.0",
|
||||
isActive: true,
|
||||
isCurrent: false)
|
||||
}
|
||||
let viewModel = UserSessionDetailsViewModel(sessionInfo: sessionInfo)
|
||||
|
||||
|
||||
+18
-15
@@ -18,7 +18,6 @@ import Combine
|
||||
import MatrixSDK
|
||||
|
||||
class UserSessionOverviewService: UserSessionOverviewServiceProtocol {
|
||||
|
||||
// MARK: - Members
|
||||
|
||||
private(set) var pusherEnabledSubject: CurrentValueSubject<Bool?, Never>
|
||||
@@ -36,10 +35,10 @@ class UserSessionOverviewService: UserSessionOverviewServiceProtocol {
|
||||
init(session: MXSession, sessionInfo: UserSessionInfo) {
|
||||
self.session = session
|
||||
self.sessionInfo = sessionInfo
|
||||
self.pusherEnabledSubject = CurrentValueSubject(nil)
|
||||
self.remotelyTogglingPushersAvailableSubject = CurrentValueSubject(false)
|
||||
pusherEnabledSubject = CurrentValueSubject(nil)
|
||||
remotelyTogglingPushersAvailableSubject = CurrentValueSubject(false)
|
||||
|
||||
self.localNotificationSettings = session.accountData.localNotificationSettingsForDevice(withId: sessionInfo.id)
|
||||
localNotificationSettings = session.accountData.localNotificationSettingsForDevice(withId: sessionInfo.id)
|
||||
|
||||
if let localNotificationSettings = localNotificationSettings, let isSilenced = localNotificationSettings[kMXAccountDataIsSilencedKey] as? Bool {
|
||||
remotelyTogglingPushersAvailableSubject.send(true)
|
||||
@@ -69,7 +68,7 @@ class UserSessionOverviewService: UserSessionOverviewServiceProtocol {
|
||||
// MARK: - Private
|
||||
|
||||
private func toggle(_ pusher: MXPusher, enabled: Bool) {
|
||||
guard self.remotelyTogglingPushersAvailableSubject.value else {
|
||||
guard remotelyTogglingPushersAvailableSubject.value else {
|
||||
MXLog.warning("[UserSessionOverviewService] toggle pusher canceled: remotely toggling pushers not available")
|
||||
return
|
||||
}
|
||||
@@ -77,20 +76,24 @@ class UserSessionOverviewService: UserSessionOverviewServiceProtocol {
|
||||
MXLog.debug("[UserSessionOverviewService] remotely toggling pusher")
|
||||
let data = pusher.data.jsonDictionary() as? [String: Any] ?? [:]
|
||||
|
||||
self.session.matrixRestClient.setPusher(pushKey: pusher.pushkey,
|
||||
kind: MXPusherKind(value: pusher.kind),
|
||||
appId: pusher.appId,
|
||||
appDisplayName:pusher.appDisplayName,
|
||||
deviceDisplayName: pusher.deviceDisplayName,
|
||||
profileTag: pusher.profileTag ?? "",
|
||||
lang: pusher.lang,
|
||||
data: data,
|
||||
append: false,
|
||||
enabled: enabled) { [weak self] response in
|
||||
session.matrixRestClient.setPusher(pushKey: pusher.pushkey,
|
||||
kind: MXPusherKind(value: pusher.kind),
|
||||
appId: pusher.appId,
|
||||
appDisplayName: pusher.appDisplayName,
|
||||
deviceDisplayName: pusher.deviceDisplayName,
|
||||
profileTag: pusher.profileTag ?? "",
|
||||
lang: pusher.lang,
|
||||
data: data,
|
||||
append: false,
|
||||
enabled: enabled) { [weak self] response in
|
||||
guard let self = self else { return }
|
||||
|
||||
switch response {
|
||||
case .success:
|
||||
if let account = MXKAccountManager.shared().activeAccounts.first, account.device?.deviceId == pusher.deviceId {
|
||||
account.loadCurrentPusher(nil)
|
||||
}
|
||||
|
||||
self.checkPusher()
|
||||
case .failure(let error):
|
||||
MXLog.warning("[UserSessionOverviewService] togglePusher failed due to error: \(error)")
|
||||
|
||||
+3
-5
@@ -18,18 +18,16 @@ import Combine
|
||||
import Foundation
|
||||
|
||||
class MockUserSessionOverviewService: UserSessionOverviewServiceProtocol {
|
||||
|
||||
|
||||
var pusherEnabledSubject: CurrentValueSubject<Bool?, Never>
|
||||
var remotelyTogglingPushersAvailableSubject: CurrentValueSubject<Bool, Never>
|
||||
|
||||
init(pusherEnabled: Bool? = nil, remotelyTogglingPushersAvailable: Bool = true) {
|
||||
self.pusherEnabledSubject = CurrentValueSubject(pusherEnabled)
|
||||
self.remotelyTogglingPushersAvailableSubject = CurrentValueSubject(remotelyTogglingPushersAvailable)
|
||||
pusherEnabledSubject = CurrentValueSubject(pusherEnabled)
|
||||
remotelyTogglingPushersAvailableSubject = CurrentValueSubject(remotelyTogglingPushersAvailable)
|
||||
}
|
||||
|
||||
func togglePushNotifications() {
|
||||
guard let enabled = pusherEnabledSubject.value, self.remotelyTogglingPushersAvailableSubject.value else {
|
||||
guard let enabled = pusherEnabledSubject.value, remotelyTogglingPushersAvailableSubject.value else {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -20,7 +20,6 @@ import XCTest
|
||||
@testable import RiotSwiftUI
|
||||
|
||||
class UserSessionOverviewViewModelTests: XCTestCase {
|
||||
|
||||
func test_whenVerifyCurrentSessionProcessed_completionWithVerifyCurrentSessionCalled() {
|
||||
let sut = UserSessionOverviewViewModel(sessionInfo: createUserSessionInfo(), service: MockUserSessionOverviewService())
|
||||
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@ class UserSessionOverviewViewModel: UserSessionOverviewViewModelType, UserSessio
|
||||
case .viewSessionDetails:
|
||||
completion?(.showSessionDetails(sessionInfo: sessionInfo))
|
||||
case .togglePushNotifications:
|
||||
self.state.showLoadingIndicator = true
|
||||
state.showLoadingIndicator = true
|
||||
service.togglePushNotifications()
|
||||
case .renameSession:
|
||||
completion?(.renameSession(sessionInfo))
|
||||
|
||||
+6
-5
@@ -57,8 +57,8 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable {
|
||||
MXLog.debug("[UserSessionsOverviewCoordinator] UserSessionsOverviewViewModel did complete with result: \(result).")
|
||||
|
||||
switch result {
|
||||
case let .showOtherSessions(sessionsInfo: sessionsInfo, filter: filter):
|
||||
self.showOtherSessions(sessionsInfo: sessionsInfo, filterBy: filter)
|
||||
case let .showOtherSessions(sessionInfos: sessionInfos, filter: filter):
|
||||
self.showOtherSessions(sessionInfos: sessionInfos, filterBy: filter)
|
||||
case .verifyCurrentSession:
|
||||
self.startVerifyCurrentSession()
|
||||
case .renameSession(let sessionInfo):
|
||||
@@ -69,6 +69,8 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable {
|
||||
self.showCurrentSessionOverview(sessionInfo: sessionInfo)
|
||||
case let .showUserSessionOverview(sessionInfo):
|
||||
self.showUserSessionOverview(sessionInfo: sessionInfo)
|
||||
case .linkDevice:
|
||||
self.completion?(.linkDevice)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,8 +94,8 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable {
|
||||
loadingIndicator = nil
|
||||
}
|
||||
|
||||
private func showOtherSessions(sessionsInfo: [UserSessionInfo], filterBy filter: OtherUserSessionsFilter) {
|
||||
completion?(.openOtherSessions(sessionsInfo: sessionsInfo, filter: filter))
|
||||
private func showOtherSessions(sessionInfos: [UserSessionInfo], filterBy filter: OtherUserSessionsFilter) {
|
||||
completion?(.openOtherSessions(sessionInfos: sessionInfos, filter: filter))
|
||||
}
|
||||
|
||||
private func startVerifyCurrentSession() {
|
||||
@@ -107,5 +109,4 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable {
|
||||
private func showUserSessionOverview(sessionInfo: UserSessionInfo) {
|
||||
completion?(.openSessionOverview(sessionInfo: sessionInfo))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+6
@@ -47,4 +47,10 @@ class UserSessionsDataProvider: UserSessionsDataProviderProtocol {
|
||||
func accountData(for eventType: String) -> [AnyHashable: Any]? {
|
||||
session.accountData.accountData(forEventType: eventType)
|
||||
}
|
||||
|
||||
func qrLoginAvailable() async throws -> Bool {
|
||||
let service = QRLoginService(client: session.matrixRestClient,
|
||||
mode: .authenticated)
|
||||
return try await service.isServiceAvailable()
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -29,4 +29,6 @@ protocol UserSessionsDataProviderProtocol {
|
||||
func device(withDeviceId deviceId: String, ofUser userId: String) -> MXDeviceInfo?
|
||||
|
||||
func accountData(for eventType: String) -> [AnyHashable: Any]?
|
||||
|
||||
func qrLoginAvailable() async throws -> Bool
|
||||
}
|
||||
|
||||
+26
-14
@@ -24,6 +24,7 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
|
||||
private let dataProvider: UserSessionsDataProviderProtocol
|
||||
|
||||
private(set) var overviewData: UserSessionsOverviewData
|
||||
private(set) var sessionInfos: [UserSessionInfo]
|
||||
|
||||
init(dataProvider: UserSessionsDataProviderProtocol) {
|
||||
self.dataProvider = dataProvider
|
||||
@@ -31,8 +32,9 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
|
||||
overviewData = UserSessionsOverviewData(currentSession: nil,
|
||||
unverifiedSessions: [],
|
||||
inactiveSessions: [],
|
||||
otherSessions: [])
|
||||
|
||||
otherSessions: [],
|
||||
linkDeviceEnabled: false)
|
||||
sessionInfos = []
|
||||
setupInitialOverviewData()
|
||||
}
|
||||
|
||||
@@ -42,8 +44,13 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
|
||||
dataProvider.devices { response in
|
||||
switch response {
|
||||
case .success(let devices):
|
||||
self.overviewData = self.sessionsOverviewData(from: devices)
|
||||
completion(.success(self.overviewData))
|
||||
self.sessionInfos = self.sortedSessionInfos(from: devices)
|
||||
Task { @MainActor in
|
||||
let linkDeviceEnabled = try? await self.dataProvider.qrLoginAvailable()
|
||||
self.overviewData = self.sessionsOverviewData(from: self.sessionInfos,
|
||||
linkDeviceEnabled: linkDeviceEnabled ?? false)
|
||||
completion(.success(self.overviewData))
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
@@ -57,7 +64,7 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
|
||||
|
||||
return overviewData.otherSessions.first(where: { $0.id == sessionId })
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func setupInitialOverviewData() {
|
||||
@@ -68,7 +75,8 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
|
||||
overviewData = UserSessionsOverviewData(currentSession: currentSessionInfo,
|
||||
unverifiedSessions: currentSessionInfo.isVerified ? [] : [currentSessionInfo],
|
||||
inactiveSessions: currentSessionInfo.isActive ? [] : [currentSessionInfo],
|
||||
otherSessions: [])
|
||||
otherSessions: [],
|
||||
linkDeviceEnabled: false)
|
||||
}
|
||||
|
||||
private func getCurrentSessionInfo() -> UserSessionInfo? {
|
||||
@@ -78,16 +86,20 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
|
||||
}
|
||||
return sessionInfo(from: device, isCurrentSession: true)
|
||||
}
|
||||
|
||||
private func sessionsOverviewData(from devices: [MXDevice]) -> UserSessionsOverviewData {
|
||||
let allSessions = devices
|
||||
|
||||
private func sortedSessionInfos(from devices: [MXDevice]) -> [UserSessionInfo] {
|
||||
devices
|
||||
.sorted { $0.lastSeenTs > $1.lastSeenTs }
|
||||
.map { sessionInfo(from: $0, isCurrentSession: $0.deviceId == dataProvider.myDeviceId) }
|
||||
|
||||
return UserSessionsOverviewData(currentSession: allSessions.filter(\.isCurrent).first,
|
||||
unverifiedSessions: allSessions.filter { !$0.isVerified },
|
||||
inactiveSessions: allSessions.filter { !$0.isActive },
|
||||
otherSessions: allSessions.filter { !$0.isCurrent })
|
||||
}
|
||||
|
||||
private func sessionsOverviewData(from allSessions: [UserSessionInfo],
|
||||
linkDeviceEnabled: Bool) -> UserSessionsOverviewData {
|
||||
UserSessionsOverviewData(currentSession: allSessions.filter(\.isCurrent).first,
|
||||
unverifiedSessions: allSessions.filter { !$0.isVerified },
|
||||
inactiveSessions: allSessions.filter { !$0.isActive },
|
||||
otherSessions: allSessions.filter { !$0.isCurrent },
|
||||
linkDeviceEnabled: linkDeviceEnabled)
|
||||
}
|
||||
|
||||
private func sessionInfo(from device: MXDevice, isCurrentSession: Bool) -> UserSessionInfo {
|
||||
|
||||
+13
-7
@@ -28,6 +28,7 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
|
||||
private let mode: Mode
|
||||
|
||||
var overviewData: UserSessionsOverviewData
|
||||
var sessionInfos = [UserSessionInfo]()
|
||||
|
||||
init(mode: Mode = .currentSessionUnverified) {
|
||||
self.mode = mode
|
||||
@@ -35,7 +36,8 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
|
||||
overviewData = UserSessionsOverviewData(currentSession: nil,
|
||||
unverifiedSessions: [],
|
||||
inactiveSessions: [],
|
||||
otherSessions: [])
|
||||
otherSessions: [],
|
||||
linkDeviceEnabled: false)
|
||||
}
|
||||
|
||||
func updateOverviewData(completion: @escaping (Result<UserSessionsOverviewData, Error>) -> Void) {
|
||||
@@ -47,24 +49,28 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
|
||||
overviewData = UserSessionsOverviewData(currentSession: currentSession,
|
||||
unverifiedSessions: [],
|
||||
inactiveSessions: [],
|
||||
otherSessions: [])
|
||||
otherSessions: [],
|
||||
linkDeviceEnabled: false)
|
||||
case .onlyUnverifiedSessions:
|
||||
overviewData = UserSessionsOverviewData(currentSession: currentSession,
|
||||
unverifiedSessions: unverifiedSessions + [currentSession],
|
||||
inactiveSessions: [],
|
||||
otherSessions: unverifiedSessions)
|
||||
otherSessions: unverifiedSessions,
|
||||
linkDeviceEnabled: false)
|
||||
case .onlyInactiveSessions:
|
||||
overviewData = UserSessionsOverviewData(currentSession: currentSession,
|
||||
unverifiedSessions: [],
|
||||
inactiveSessions: inactiveSessions,
|
||||
otherSessions: inactiveSessions)
|
||||
otherSessions: inactiveSessions,
|
||||
linkDeviceEnabled: false)
|
||||
default:
|
||||
let otherSessions = unverifiedSessions + inactiveSessions + buildSessions(verified: true, active: true)
|
||||
|
||||
overviewData = UserSessionsOverviewData(currentSession: currentSession,
|
||||
unverifiedSessions: unverifiedSessions,
|
||||
inactiveSessions: inactiveSessions,
|
||||
otherSessions: otherSessions)
|
||||
otherSessions: otherSessions,
|
||||
linkDeviceEnabled: true)
|
||||
}
|
||||
|
||||
completion(.success(overviewData))
|
||||
@@ -73,7 +79,7 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
|
||||
func sessionForIdentifier(_ sessionId: String) -> UserSessionInfo? {
|
||||
overviewData.otherSessions.first { $0.id == sessionId }
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private var currentSession: UserSessionInfo {
|
||||
@@ -101,7 +107,7 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
|
||||
deviceType: .desktop,
|
||||
isVerified: verified,
|
||||
lastSeenIP: "1.0.0.1",
|
||||
lastSeenTimestamp: Date().timeIntervalSince1970 - 8000000,
|
||||
lastSeenTimestamp: Date().timeIntervalSince1970 - 8_000_000,
|
||||
applicationName: "Element MacOS",
|
||||
applicationVersion: "1.0.0",
|
||||
applicationURL: nil,
|
||||
|
||||
+2
@@ -21,10 +21,12 @@ struct UserSessionsOverviewData {
|
||||
let unverifiedSessions: [UserSessionInfo]
|
||||
let inactiveSessions: [UserSessionInfo]
|
||||
let otherSessions: [UserSessionInfo]
|
||||
let linkDeviceEnabled: Bool
|
||||
}
|
||||
|
||||
protocol UserSessionsOverviewServiceProtocol {
|
||||
var overviewData: UserSessionsOverviewData { get }
|
||||
var sessionInfos: [UserSessionInfo] { get }
|
||||
|
||||
func updateOverviewData(completion: @escaping (Result<UserSessionsOverviewData, Error>) -> Void) -> Void
|
||||
|
||||
|
||||
+21
@@ -23,6 +23,8 @@ class UserSessionsOverviewUITests: MockScreenTestCase {
|
||||
|
||||
XCTAssertTrue(app.buttons["userSessionCardVerifyButton"].exists)
|
||||
XCTAssertTrue(app.staticTexts["userSessionCardViewDetails"].exists)
|
||||
|
||||
verifyLinkDeviceButtonStatus(true)
|
||||
}
|
||||
|
||||
func testCurrentSessionVerified() {
|
||||
@@ -30,6 +32,8 @@ class UserSessionsOverviewUITests: MockScreenTestCase {
|
||||
|
||||
XCTAssertFalse(app.buttons["userSessionCardVerifyButton"].exists)
|
||||
XCTAssertTrue(app.staticTexts["userSessionCardViewDetails"].exists)
|
||||
|
||||
verifyLinkDeviceButtonStatus(true)
|
||||
}
|
||||
|
||||
func testOnlyUnverifiedSessions() {
|
||||
@@ -37,6 +41,8 @@ class UserSessionsOverviewUITests: MockScreenTestCase {
|
||||
|
||||
XCTAssertTrue(app.staticTexts["userSessionsOverviewSecurityRecommendationsSection"].exists)
|
||||
XCTAssertTrue(app.staticTexts["userSessionsOverviewOtherSection"].exists)
|
||||
|
||||
verifyLinkDeviceButtonStatus(false)
|
||||
}
|
||||
|
||||
func testOnlyInactiveSessions() {
|
||||
@@ -44,6 +50,8 @@ class UserSessionsOverviewUITests: MockScreenTestCase {
|
||||
|
||||
XCTAssertTrue(app.staticTexts["userSessionsOverviewSecurityRecommendationsSection"].exists)
|
||||
XCTAssertTrue(app.staticTexts["userSessionsOverviewOtherSection"].exists)
|
||||
|
||||
verifyLinkDeviceButtonStatus(false)
|
||||
}
|
||||
|
||||
func testNoOtherSessions() {
|
||||
@@ -51,5 +59,18 @@ class UserSessionsOverviewUITests: MockScreenTestCase {
|
||||
|
||||
XCTAssertFalse(app.staticTexts["userSessionsOverviewSecurityRecommendationsSection"].exists)
|
||||
XCTAssertFalse(app.staticTexts["userSessionsOverviewOtherSection"].exists)
|
||||
|
||||
verifyLinkDeviceButtonStatus(false)
|
||||
}
|
||||
|
||||
func verifyLinkDeviceButtonStatus(_ enabled: Bool) {
|
||||
if enabled {
|
||||
let linkDeviceButton = app.buttons["linkDeviceButton"]
|
||||
XCTAssertTrue(linkDeviceButton.exists)
|
||||
XCTAssertTrue(linkDeviceButton.isEnabled)
|
||||
} else {
|
||||
let linkDeviceButton = app.buttons["linkDeviceButton"]
|
||||
XCTAssertFalse(linkDeviceButton.exists)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -27,6 +27,7 @@ class UserSessionsOverviewViewModelTests: XCTestCase {
|
||||
XCTAssertTrue(viewModel.state.unverifiedSessionsViewData.isEmpty)
|
||||
XCTAssertTrue(viewModel.state.inactiveSessionsViewData.isEmpty)
|
||||
XCTAssertTrue(viewModel.state.otherSessionsViewData.isEmpty)
|
||||
XCTAssertFalse(viewModel.state.linkDeviceButtonVisible)
|
||||
}
|
||||
|
||||
func testLoadOnDidAppear() {
|
||||
@@ -37,6 +38,7 @@ class UserSessionsOverviewViewModelTests: XCTestCase {
|
||||
XCTAssertFalse(viewModel.state.unverifiedSessionsViewData.isEmpty)
|
||||
XCTAssertFalse(viewModel.state.inactiveSessionsViewData.isEmpty)
|
||||
XCTAssertFalse(viewModel.state.otherSessionsViewData.isEmpty)
|
||||
XCTAssertTrue(viewModel.state.linkDeviceButtonVisible)
|
||||
}
|
||||
|
||||
func testSimpleActionProcessing() {
|
||||
@@ -51,7 +53,10 @@ class UserSessionsOverviewViewModelTests: XCTestCase {
|
||||
XCTAssertEqual(result, .verifyCurrentSession)
|
||||
|
||||
viewModel.process(viewAction: .viewAllInactiveSessions)
|
||||
XCTAssertEqual(result, .showOtherSessions(sessionsInfo: [], filter: .inactive))
|
||||
XCTAssertEqual(result, .showOtherSessions(sessionInfos: [], filter: .inactive))
|
||||
|
||||
viewModel.process(viewAction: .linkDevice)
|
||||
XCTAssertEqual(result, .linkDevice)
|
||||
}
|
||||
|
||||
func testShowSessionDetails() {
|
||||
|
||||
+7
-2
@@ -22,18 +22,20 @@ enum UserSessionsOverviewCoordinatorResult {
|
||||
case renameSession(UserSessionInfo)
|
||||
case logoutOfSession(UserSessionInfo)
|
||||
case openSessionOverview(sessionInfo: UserSessionInfo)
|
||||
case openOtherSessions(sessionsInfo: [UserSessionInfo], filter: OtherUserSessionsFilter)
|
||||
case openOtherSessions(sessionInfos: [UserSessionInfo], filter: OtherUserSessionsFilter)
|
||||
case linkDevice
|
||||
}
|
||||
|
||||
// MARK: View model
|
||||
|
||||
enum UserSessionsOverviewViewModelResult: Equatable {
|
||||
case showOtherSessions(sessionsInfo: [UserSessionInfo], filter: OtherUserSessionsFilter)
|
||||
case showOtherSessions(sessionInfos: [UserSessionInfo], filter: OtherUserSessionsFilter)
|
||||
case verifyCurrentSession
|
||||
case renameSession(UserSessionInfo)
|
||||
case logoutOfSession(UserSessionInfo)
|
||||
case showCurrentSessionOverview(sessionInfo: UserSessionInfo)
|
||||
case showUserSessionOverview(sessionInfo: UserSessionInfo)
|
||||
case linkDevice
|
||||
}
|
||||
|
||||
// MARK: View
|
||||
@@ -48,6 +50,8 @@ struct UserSessionsOverviewViewState: BindableState {
|
||||
var otherSessionsViewData = [UserSessionListItemViewData]()
|
||||
|
||||
var showLoadingIndicator = false
|
||||
|
||||
var linkDeviceButtonVisible = false
|
||||
}
|
||||
|
||||
enum UserSessionsOverviewViewAction {
|
||||
@@ -60,4 +64,5 @@ enum UserSessionsOverviewViewAction {
|
||||
case viewAllInactiveSessions
|
||||
case viewAllOtherSessions
|
||||
case tapUserSession(_ sessionId: String)
|
||||
case linkDevice
|
||||
}
|
||||
|
||||
+6
-4
@@ -58,8 +58,7 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess
|
||||
}
|
||||
completion?(.showCurrentSessionOverview(sessionInfo: currentSessionInfo))
|
||||
case .viewAllUnverifiedSessions:
|
||||
// TODO: showSessions(filteredBy: .unverified)
|
||||
break
|
||||
showSessions(filteredBy: .unverified)
|
||||
case .viewAllInactiveSessions:
|
||||
showSessions(filteredBy: .inactive)
|
||||
case .viewAllOtherSessions:
|
||||
@@ -71,6 +70,8 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess
|
||||
return
|
||||
}
|
||||
completion?(.showUserSessionOverview(sessionInfo: session))
|
||||
case .linkDevice:
|
||||
completion?(.linkDevice)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +85,7 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess
|
||||
if let currentSessionInfo = userSessionsViewData.currentSession {
|
||||
state.currentSessionViewData = UserSessionCardViewData(sessionInfo: currentSessionInfo)
|
||||
}
|
||||
state.linkDeviceButtonVisible = userSessionsViewData.linkDeviceEnabled
|
||||
}
|
||||
|
||||
private func loadData() {
|
||||
@@ -107,13 +109,13 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess
|
||||
}
|
||||
|
||||
private func showSessions(filteredBy filter: OtherUserSessionsFilter) {
|
||||
completion?(.showOtherSessions(sessionsInfo: userSessionsOverviewService.overviewData.otherSessions,
|
||||
completion?(.showOtherSessions(sessionInfos: userSessionsOverviewService.sessionInfos,
|
||||
filter: filter))
|
||||
}
|
||||
}
|
||||
|
||||
extension Collection where Element == UserSessionInfo {
|
||||
func asViewData() -> [UserSessionListItemViewData] {
|
||||
map { UserSessionListItemViewDataFactory().create(from: $0)}
|
||||
map { UserSessionListItemViewDataFactory().create(from: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ struct UserSessionListItem: View {
|
||||
}
|
||||
Text(viewData.sessionDetails)
|
||||
.font(theme.fonts.caption1)
|
||||
.foregroundColor(theme.colors.secondaryContent)
|
||||
.foregroundColor(viewData.highlightSessionDetails ? theme.colors.alert : theme.colors.secondaryContent)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -18,7 +18,6 @@ import Foundation
|
||||
|
||||
/// View data for UserSessionListItem
|
||||
struct UserSessionListItemViewData: Identifiable, Hashable {
|
||||
|
||||
var id: String {
|
||||
sessionId
|
||||
}
|
||||
@@ -29,6 +28,8 @@ struct UserSessionListItemViewData: Identifiable, Hashable {
|
||||
|
||||
let sessionDetails: String
|
||||
|
||||
let highlightSessionDetails: Bool
|
||||
|
||||
let deviceAvatarViewData: DeviceAvatarViewData
|
||||
|
||||
let sessionDetailsIcon: String?
|
||||
|
||||
+21
-21
@@ -17,50 +17,50 @@
|
||||
import Foundation
|
||||
|
||||
struct UserSessionListItemViewDataFactory {
|
||||
|
||||
func create(from session: UserSessionInfo) -> UserSessionListItemViewData {
|
||||
let sessionName = UserSessionNameFormatter.sessionName(deviceType: session.deviceType,
|
||||
sessionDisplayName: session.name)
|
||||
let sessionDetails = buildSessionDetails(isVerified: session.isVerified,
|
||||
lastActivityDate: session.lastSeenTimestamp,
|
||||
isActive: session.isActive)
|
||||
let deviceAvatarViewData = DeviceAvatarViewData(deviceType: session.deviceType,
|
||||
isVerified: session.isVerified)
|
||||
return UserSessionListItemViewData(sessionId: session.id,
|
||||
func create(from sessionInfo: UserSessionInfo, highlightSessionDetails: Bool = false) -> UserSessionListItemViewData {
|
||||
let sessionName = UserSessionNameFormatter.sessionName(deviceType: sessionInfo.deviceType,
|
||||
sessionDisplayName: sessionInfo.name)
|
||||
let sessionDetails = buildSessionDetails(sessionInfo: sessionInfo)
|
||||
let deviceAvatarViewData = DeviceAvatarViewData(deviceType: sessionInfo.deviceType,
|
||||
isVerified: sessionInfo.isVerified)
|
||||
return UserSessionListItemViewData(sessionId: sessionInfo.id,
|
||||
sessionName: sessionName,
|
||||
sessionDetails: sessionDetails,
|
||||
highlightSessionDetails: highlightSessionDetails,
|
||||
deviceAvatarViewData: deviceAvatarViewData,
|
||||
sessionDetailsIcon: getSessionDetailsIcon(isActive: session.isActive))
|
||||
sessionDetailsIcon: getSessionDetailsIcon(isActive: sessionInfo.isActive))
|
||||
}
|
||||
|
||||
private func buildSessionDetails(isVerified: Bool, lastActivityDate: TimeInterval?, isActive: Bool) -> String {
|
||||
if isActive {
|
||||
return activeSessionDetails(isVerified: isVerified, lastActivityDate: lastActivityDate)
|
||||
private func buildSessionDetails(sessionInfo: UserSessionInfo) -> String {
|
||||
if sessionInfo.isActive {
|
||||
return activeSessionDetails(sessionInfo: sessionInfo)
|
||||
} else {
|
||||
return inactiveSessionDetails(lastActivityDate: lastActivityDate)
|
||||
return inactiveSessionDetails(sessionInfo: sessionInfo)
|
||||
}
|
||||
}
|
||||
|
||||
private func inactiveSessionDetails(lastActivityDate: TimeInterval?) -> String {
|
||||
if let lastActivityDate = lastActivityDate {
|
||||
private func inactiveSessionDetails(sessionInfo: UserSessionInfo) -> String {
|
||||
if let lastActivityDate = sessionInfo.lastSeenTimestamp {
|
||||
let lastActivityDateString = InactiveUserSessionLastActivityFormatter.lastActivityDateString(from: lastActivityDate)
|
||||
return VectorL10n.userInactiveSessionItemWithDate(lastActivityDateString)
|
||||
}
|
||||
return VectorL10n.userInactiveSessionItem
|
||||
}
|
||||
|
||||
private func activeSessionDetails(isVerified: Bool, lastActivityDate: TimeInterval?) -> String {
|
||||
private func activeSessionDetails(sessionInfo: UserSessionInfo) -> String {
|
||||
let sessionDetailsString: String
|
||||
|
||||
let sessionStatusText = isVerified ? VectorL10n.userSessionVerifiedShort : VectorL10n.userSessionUnverifiedShort
|
||||
let sessionStatusText = sessionInfo.isVerified ? VectorL10n.userSessionVerifiedShort : VectorL10n.userSessionUnverifiedShort
|
||||
|
||||
var lastActivityDateString: String?
|
||||
|
||||
if let lastActivityDate = lastActivityDate {
|
||||
if let lastActivityDate = sessionInfo.lastSeenTimestamp {
|
||||
lastActivityDateString = UserSessionLastActivityFormatter.lastActivityDateString(from: lastActivityDate)
|
||||
}
|
||||
|
||||
if let lastActivityDateString = lastActivityDateString, lastActivityDateString.isEmpty == false {
|
||||
if sessionInfo.isCurrent {
|
||||
sessionDetailsString = VectorL10n.userOtherSessionUnverifiedCurrentSessionDetails(sessionStatusText)
|
||||
} else if let lastActivityDateString = lastActivityDateString, lastActivityDateString.isEmpty == false {
|
||||
sessionDetailsString = VectorL10n.userSessionItemDetails(sessionStatusText, lastActivityDateString)
|
||||
} else {
|
||||
sessionDetailsString = sessionStatusText
|
||||
|
||||
+36
-9
@@ -22,15 +22,25 @@ struct UserSessionsOverview: View {
|
||||
@ObservedObject var viewModel: UserSessionsOverviewViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
if hasSecurityRecommendations {
|
||||
securityRecommendationsSection
|
||||
}
|
||||
|
||||
currentSessionsSection
|
||||
|
||||
if !viewModel.viewState.otherSessionsViewData.isEmpty {
|
||||
otherSessionsSection
|
||||
GeometryReader { geometry in
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ScrollView {
|
||||
if hasSecurityRecommendations {
|
||||
securityRecommendationsSection
|
||||
}
|
||||
|
||||
currentSessionsSection
|
||||
|
||||
if !viewModel.viewState.otherSessionsViewData.isEmpty {
|
||||
otherSessionsSection
|
||||
}
|
||||
}
|
||||
.readableFrame()
|
||||
|
||||
if viewModel.viewState.linkDeviceButtonVisible {
|
||||
linkDeviceView
|
||||
.padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(theme.colors.system.ignoresSafeArea())
|
||||
@@ -158,6 +168,23 @@ struct UserSessionsOverview: View {
|
||||
}
|
||||
.accessibilityIdentifier("userSessionsOverviewOtherSection")
|
||||
}
|
||||
|
||||
/// The footer view containing link device button.
|
||||
var linkDeviceView: some View {
|
||||
VStack {
|
||||
Button {
|
||||
viewModel.send(viewAction: .linkDevice)
|
||||
} label: {
|
||||
Text(VectorL10n.userSessionsOverviewLinkDevice)
|
||||
}
|
||||
.buttonStyle(PrimaryActionButtonStyle(font: theme.fonts.bodySB))
|
||||
.padding(.top, 28)
|
||||
.padding(.bottom, 12)
|
||||
.padding(.horizontal, 16)
|
||||
.accessibilityIdentifier("linkDeviceButton")
|
||||
}
|
||||
.background(theme.colors.system.ignoresSafeArea())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
@@ -57,9 +57,11 @@ targets:
|
||||
- path: ../Riot/Categories/UISearchBar.swift
|
||||
- path: ../Riot/Categories/UIView.swift
|
||||
- path: ../Riot/Categories/UIApplication.swift
|
||||
- path: ../Riot/Categories/Codable.swift
|
||||
- path: ../Riot/Assets/en.lproj/Vector.strings
|
||||
- path: ../Riot/Modules/Analytics/AnalyticsScreen.swift
|
||||
- path: ../Riot/Modules/LocationSharing/LocationAuthorizationStatus.swift
|
||||
- path: ../Riot/Modules/QRCode/QRCodeGenerator.swift
|
||||
- path: ../Riot/Assets/en.lproj/Untranslated.strings
|
||||
buildPhase: resources
|
||||
- path: ../Riot/Assets/Images.xcassets
|
||||
|
||||
@@ -66,9 +66,11 @@ targets:
|
||||
- path: ../Riot/Categories/UISearchBar.swift
|
||||
- path: ../Riot/Categories/UIView.swift
|
||||
- path: ../Riot/Categories/UIApplication.swift
|
||||
- path: ../Riot/Categories/Codable.swift
|
||||
- path: ../Riot/Assets/en.lproj/Vector.strings
|
||||
- path: ../Riot/Modules/Analytics/AnalyticsScreen.swift
|
||||
- path: ../Riot/Modules/LocationSharing/LocationAuthorizationStatus.swift
|
||||
- path: ../Riot/Modules/QRCode/QRCodeGenerator.swift
|
||||
- path: ../Riot/Assets/en.lproj/Untranslated.strings
|
||||
buildPhase: resources
|
||||
- path: ../Riot/Assets/Images.xcassets
|
||||
|
||||
Reference in New Issue
Block a user