QR Code Login Initial view state (#2667)

This commit is contained in:
Mauro
2024-04-09 12:21:14 +02:00
committed by GitHub
parent bc75fe1cf8
commit c61135f88c
27 changed files with 623 additions and 93 deletions
@@ -90,8 +90,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
case .loginManually:
Task { await self.startAuthentication() }
case .loginWithQR:
// TODO: Implement QR code login navigation
break
startQRCodeLogin()
case .reportProblem:
showReportProblemScreen()
}
@@ -103,6 +102,21 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
navigationRootCoordinator.setRootCoordinator(navigationStackCoordinator)
}
private func startQRCodeLogin() {
let coordinator = QRCodeLoginScreenCoordinator(parameters: .init(qrCodeLoginService: QRCodeLoginService()))
coordinator.actionsPublisher.sink { [weak self] action in
guard let self else {
return
}
switch action {
case .cancel:
navigationStackCoordinator.setSheetCoordinator(nil)
}
}
.store(in: &cancellables)
navigationStackCoordinator.setSheetCoordinator(coordinator)
}
private func showReportProblemScreen() {
bugReportFlowCoordinator = BugReportFlowCoordinator(parameters: .init(presentationMode: .sheet(navigationStackCoordinator),
userIndicatorController: userIndicatorController,
+19 -1
View File
@@ -1167,6 +1167,24 @@ internal enum L10n {
internal static var screenPollsHistoryFilterPast: String { return L10n.tr("Localizable", "screen_polls_history_filter_past") }
/// Polls
internal static var screenPollsHistoryTitle: String { return L10n.tr("Localizable", "screen_polls_history_title") }
/// Open Element on a desktop device
internal static var screenQrCodeLoginInitialStateItem1: String { return L10n.tr("Localizable", "screen_qr_code_login_initial_state_item_1") }
/// Click on your avatar
internal static var screenQrCodeLoginInitialStateItem2: String { return L10n.tr("Localizable", "screen_qr_code_login_initial_state_item_2") }
/// Select %1$@
internal static func screenQrCodeLoginInitialStateItem3(_ p1: Any) -> String {
return L10n.tr("Localizable", "screen_qr_code_login_initial_state_item_3", String(describing: p1))
}
/// “Link new device”
internal static var screenQrCodeLoginInitialStateItem3Action: String { return L10n.tr("Localizable", "screen_qr_code_login_initial_state_item_3_action") }
/// Select %1$@
internal static func screenQrCodeLoginInitialStateItem4(_ p1: Any) -> String {
return L10n.tr("Localizable", "screen_qr_code_login_initial_state_item_4", String(describing: p1))
}
/// “Show QR code”
internal static var screenQrCodeLoginInitialStateItem4Action: String { return L10n.tr("Localizable", "screen_qr_code_login_initial_state_item_4_action") }
/// Open Element on another device to get the QR code
internal static var screenQrCodeLoginInitialStateTitle: String { return L10n.tr("Localizable", "screen_qr_code_login_initial_state_title") }
/// Get a new recovery key if you've lost your existing one. After changing your recovery key, your old one will no longer work.
internal static var screenRecoveryKeyChangeDescription: String { return L10n.tr("Localizable", "screen_recovery_key_change_description") }
/// Generate a new recovery key
@@ -1185,7 +1203,7 @@ internal enum L10n {
internal static var screenRecoveryKeyConfirmErrorContent: String { return L10n.tr("Localizable", "screen_recovery_key_confirm_error_content") }
/// Incorrect recovery key
internal static var screenRecoveryKeyConfirmErrorTitle: String { return L10n.tr("Localizable", "screen_recovery_key_confirm_error_title") }
/// If you have a recovery passphrase or secret passphrase/key, this will work too.
/// If you have a security key or security phrase, this will work too.
internal static var screenRecoveryKeyConfirmKeyDescription: String { return L10n.tr("Localizable", "screen_recovery_key_confirm_key_description") }
/// Recovery key or passcode
internal static var screenRecoveryKeyConfirmKeyLabel: String { return L10n.tr("Localizable", "screen_recovery_key_confirm_key_label") }
@@ -2338,6 +2338,26 @@ class PollInteractionHandlerMock: PollInteractionHandlerProtocol {
}
}
}
class QRCodeLoginServiceMock: QRCodeLoginServiceProtocol {
//MARK: - requestAuthorizationIfNeeded
var requestAuthorizationIfNeededCallsCount = 0
var requestAuthorizationIfNeededCalled: Bool {
return requestAuthorizationIfNeededCallsCount > 0
}
var requestAuthorizationIfNeededReturnValue: Bool!
var requestAuthorizationIfNeededClosure: (() async -> Bool)?
func requestAuthorizationIfNeeded() async -> Bool {
requestAuthorizationIfNeededCallsCount += 1
if let requestAuthorizationIfNeededClosure = requestAuthorizationIfNeededClosure {
return await requestAuthorizationIfNeededClosure()
} else {
return requestAuthorizationIfNeededReturnValue
}
}
}
class RoomDirectorySearchProxyMock: RoomDirectorySearchProxyProtocol {
var resultsPublisher: CurrentValuePublisher<[RoomDirectorySearchResult], Never> {
get { return underlyingResultsPublisher }
@@ -0,0 +1,81 @@
//
// Copyright 2024 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 SFSafeSymbols
import SwiftUI
/// The view can only display a max 9 items as of right now
struct SFNumberedListView: View {
let items: [AttributedString]
var body: some View {
VStack(alignment: .leading, spacing: 24) {
ForEach(0..<items.count, id: \.self) { index in
Label {
Text(items[index])
} icon: {
Image(systemSymbol: getSymbol(for: index))
.imageScale(.large)
.fontWeight(.light)
.foregroundColor(.compound.textPlaceholder)
}
.foregroundColor(.compound.textPrimary)
.font(.compound.bodyMD)
}
}
}
private func getSymbol(for index: Int) -> SFSymbol {
switch index {
case 0:
return ._1Circle
case 1:
return ._2Circle
case 2:
return ._3Circle
case 3:
return ._4Circle
case 4:
return ._5Circle
case 5:
return ._6Circle
case 6:
return ._7Circle
case 7:
return ._8Circle
case 8:
return ._9Circle
default:
return ._0Circle
}
}
}
struct SFNumberedListView_Previews: PreviewProvider, TestablePreview {
static let items = {
var results: [AttributedString] = []
for index in 1...9 {
results.append(AttributedString("Item \(index)"))
}
return results
}()
static var previews: some View {
SFNumberedListView(items: items)
.padding()
.previewLayout(.sizeThatFits)
}
}
@@ -96,7 +96,7 @@ struct AuthenticationStartScreen: View {
var buttons: some View {
VStack(spacing: 16) {
if context.viewState.isQRCodeLoginEnabled {
Button { context.send(viewAction: .loginManually) } label: {
Button { context.send(viewAction: .loginWithQR) } label: {
Label(L10n.screenOnboardingSignInWithQrCode, icon: \.qrCode)
}
.buttonStyle(.compound(.primary))
@@ -0,0 +1,60 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// periphery:ignore:all - this is just a qRCodeLogin remove this comment once generating the final file
import Combine
import SwiftUI
struct QRCodeLoginScreenCoordinatorParameters {
let qrCodeLoginService: QRCodeLoginServiceProtocol
}
enum QRCodeLoginScreenCoordinatorAction {
case cancel
}
final class QRCodeLoginScreenCoordinator: CoordinatorProtocol {
private let viewModel: QRCodeLoginScreenViewModelProtocol
private var cancellables = Set<AnyCancellable>()
private let actionsSubject: PassthroughSubject<QRCodeLoginScreenCoordinatorAction, Never> = .init()
var actionsPublisher: AnyPublisher<QRCodeLoginScreenCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(parameters: QRCodeLoginScreenCoordinatorParameters) {
viewModel = QRCodeLoginScreenViewModel(qrCodeLoginService: parameters.qrCodeLoginService)
}
func start() {
viewModel.actionsPublisher.sink { [weak self] action in
MXLog.info("Coordinator: received view model action: \(action)")
guard let self else { return }
switch action {
case .cancel:
self.actionsSubject.send(.cancel)
}
}
.store(in: &cancellables)
}
func toPresentable() -> AnyView {
AnyView(QRCodeLoginScreen(context: viewModel.context))
}
}
@@ -0,0 +1,67 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
enum QRCodeLoginScreenViewModelAction {
case cancel
}
struct QRCodeLoginScreenViewState: BindableState {
var state: QRCodeLoginState = .initial
private let listItem3AttributedText = {
let boldPlaceholder = "{bold}"
var finalString = AttributedString(L10n.screenQrCodeLoginInitialStateItem3(boldPlaceholder))
var boldString = AttributedString(L10n.screenQrCodeLoginInitialStateItem3Action)
boldString.bold()
finalString.replace(boldPlaceholder, with: boldString)
return finalString
}()
private let listItem4AttributedText = {
let boldPlaceholder = "{bold}"
var finalString = AttributedString(L10n.screenQrCodeLoginInitialStateItem4(boldPlaceholder))
var boldString = AttributedString(L10n.screenQrCodeLoginInitialStateItem4Action)
boldString.bold()
finalString.replace(boldPlaceholder, with: boldString)
return finalString
}()
var listItems: [AttributedString] {
[
AttributedString(L10n.screenQrCodeLoginInitialStateItem1),
AttributedString(L10n.screenQrCodeLoginInitialStateItem2),
listItem3AttributedText,
listItem4AttributedText
]
}
}
enum QRCodeLoginScreenViewAction {
case cancel
case startScan
}
enum QRCodeLoginState {
case initial
case scanning
case error(QRCodeLoginErrorState)
enum QRCodeLoginErrorState {
case noCameraPermission
}
}
@@ -0,0 +1,50 @@
//
// 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 SwiftUI
typealias QRCodeLoginScreenViewModelType = StateStoreViewModel<QRCodeLoginScreenViewState, QRCodeLoginScreenViewAction>
class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScreenViewModelProtocol {
private let qrCodeLoginService: QRCodeLoginServiceProtocol
private let actionsSubject: PassthroughSubject<QRCodeLoginScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<QRCodeLoginScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(qrCodeLoginService: QRCodeLoginServiceProtocol) {
self.qrCodeLoginService = qrCodeLoginService
super.init(initialViewState: QRCodeLoginScreenViewState())
}
// MARK: - Public
override func process(viewAction: QRCodeLoginScreenViewAction) {
switch viewAction {
case .cancel:
actionsSubject.send(.cancel)
case .startScan:
Task { await startScanIfPossible() }
}
}
private func startScanIfPossible() async {
state.state = await qrCodeLoginService.requestAuthorizationIfNeeded() ? .scanning : .error(.noCameraPermission)
}
}
@@ -0,0 +1,23 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
@MainActor
protocol QRCodeLoginScreenViewModelProtocol {
var actionsPublisher: AnyPublisher<QRCodeLoginScreenViewModelAction, Never> { get }
var context: QRCodeLoginScreenViewModelType.Context { get }
}
@@ -0,0 +1,86 @@
//
// 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 CONnDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Compound
import SwiftUI
struct QRCodeLoginScreen: View {
@ObservedObject var context: QRCodeLoginScreenViewModel.Context
var body: some View {
NavigationStack {
mainContent
.toolbar { toolbar }
.toolbar(.visible, for: .navigationBar)
.background()
.environment(\.backgroundStyle, AnyShapeStyle(Color.compound.bgSubtleSecondary))
.interactiveDismissDisabled()
}
}
@ViewBuilder
var mainContent: some View {
switch context.viewState.state {
case .initial:
initialContent
case .scanning, .error:
// TODO: Handle states
EmptyView()
}
}
var initialContent: some View {
FullscreenDialog {
VStack(alignment: .leading, spacing: 40) {
VStack(spacing: 16) {
HeroImage(icon: \.computer, style: .subtle)
Text(L10n.screenQrCodeLoginInitialStateTitle)
.foregroundColor(.compound.textPrimary)
.font(.compound.headingMDBold)
.multilineTextAlignment(.center)
}
.padding(.horizontal, 24)
SFNumberedListView(items: context.viewState.listItems)
}
} bottomContent: {
Button(L10n.actionContinue) {
context.send(viewAction: .startScan)
}
.buttonStyle(.compound(.primary))
}
}
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
Button(L10n.actionCancel) {
context.send(viewAction: .cancel)
}
}
}
}
// MARK: - Previews
struct QRCodeLoginScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = QRCodeLoginScreenViewModel(qrCodeLoginService: QRCodeLoginServiceMock())
static var previews: some View {
QRCodeLoginScreen(context: viewModel.context)
.previewDisplayName("Initial")
}
}
@@ -21,7 +21,7 @@ enum ResetRecoveryKeyScreenViewModelAction {
}
struct ResetRecoveryKeyScreenViewState: BindableState {
let listItem3AttributedText = {
private let listItem3AttributedText = {
let boldPlaceholder = "{bold}"
var finalString = AttributedString(L10n.screenCreateNewRecoveryKeyListItem3(boldPlaceholder))
var boldString = AttributedString(L10n.screenCreateNewRecoveryKeyListItem3ResetAll)
@@ -29,6 +29,16 @@ struct ResetRecoveryKeyScreenViewState: BindableState {
finalString.replace(boldPlaceholder, with: boldString)
return finalString
}()
var listItems: [AttributedString] {
[
AttributedString(L10n.screenCreateNewRecoveryKeyListItem1),
AttributedString(L10n.screenCreateNewRecoveryKeyListItem2),
listItem3AttributedText,
AttributedString(L10n.screenCreateNewRecoveryKeyListItem4),
AttributedString(L10n.screenCreateNewRecoveryKeyListItem5)
]
}
}
enum ResetRecoveryKeyScreenViewAction {
@@ -34,6 +34,7 @@ class ResetRecoveryKeyScreenViewModel: ResetRecoveryKeyScreenViewModelType, Rese
override func process(viewAction: ResetRecoveryKeyScreenViewAction) {
switch viewAction {
case .cancel:
// We might also need to display first an alert and do a logOut API call in some cases
actionsSubject.send(.cancel)
}
}
@@ -38,7 +38,7 @@ struct ResetRecoveryKeyScreen: View {
private var mainContent: some View {
VStack(spacing: 40) {
header
list
SFNumberedListView(items: context.viewState.listItems)
}
}
@@ -53,65 +53,6 @@ struct ResetRecoveryKeyScreen: View {
}
}
private var list: some View {
VStack(alignment: .leading, spacing: 24) {
Label {
Text(L10n.screenCreateNewRecoveryKeyListItem1)
} icon: {
Image(systemSymbol: ._1Circle)
.imageScale(.large)
.fontWeight(.light)
.foregroundColor(.compound.textPlaceholder)
}
.foregroundColor(.compound.textPrimary)
.font(.compound.bodyMD)
Label {
Text(L10n.screenCreateNewRecoveryKeyListItem2)
} icon: {
Image(systemSymbol: ._2Circle)
.imageScale(.large)
.fontWeight(.light)
.foregroundColor(.compound.textPlaceholder)
}
.foregroundColor(.compound.textPrimary)
.font(.compound.bodyMD)
Label {
Text(context.viewState.listItem3AttributedText)
} icon: {
Image(systemSymbol: ._3Circle)
.imageScale(.large)
.fontWeight(.light)
.foregroundColor(.compound.textPlaceholder)
}
.foregroundColor(.compound.textPrimary)
.font(.compound.bodyMD)
Label {
Text(L10n.screenCreateNewRecoveryKeyListItem4)
} icon: {
Image(systemSymbol: ._4Circle)
.imageScale(.large)
.fontWeight(.light)
.foregroundColor(.compound.textPlaceholder)
}
.foregroundColor(.compound.textPrimary)
.font(.compound.bodyMD)
Label {
Text(L10n.screenCreateNewRecoveryKeyListItem5)
} icon: {
Image(systemSymbol: ._5Circle)
.imageScale(.large)
.fontWeight(.light)
.foregroundColor(.compound.textPlaceholder)
}
.foregroundColor(.compound.textPrimary)
.font(.compound.bodyMD)
}
}
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
@@ -0,0 +1,37 @@
//
// Copyright 2024 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
final class QRCodeLoginService: QRCodeLoginServiceProtocol {
func requestAuthorizationIfNeeded() async -> Bool {
let status = AVCaptureDevice.authorizationStatus(for: .video)
// Determine if the user previously authorized camera access.
if status == .authorized {
return true
}
var isAuthorized = false
// If the system hasn't determined the user's authorization status,
// explicitly prompt them for approval.
if status == .notDetermined {
isAuthorized = await AVCaptureDevice.requestAccess(for: .video)
}
return isAuthorized
}
}
@@ -0,0 +1,20 @@
//
// Copyright 2024 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.
//
// sourcery: AutoMockable
protocol QRCodeLoginServiceProtocol {
func requestAuthorizationIfNeeded() async -> Bool
}