Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

QR Code Login Initial view state #2667

Merged
merged 9 commits into from
Apr 9, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 79 additions & 23 deletions ElementX.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,13 @@
"screen_polls_history_filter_ongoing" = "Ongoing";
"screen_polls_history_filter_past" = "Past";
"screen_polls_history_title" = "Polls";
"screen_qr_code_login_initial_state_item_1" = "Open Element on a desktop device";
"screen_qr_code_login_initial_state_item_2" = "Click on your avatar";
"screen_qr_code_login_initial_state_item_3" = "Select %1$@";
"screen_qr_code_login_initial_state_item_3_action" = "“Link new device”";
"screen_qr_code_login_initial_state_item_4" = "Select %1$@";
"screen_qr_code_login_initial_state_item_4_action" = "“Show QR code”";
"screen_qr_code_login_initial_state_title" = "Open Element on another device to get the QR code";
"screen_recovery_key_change_description" = "Get a new recovery key if you've lost your existing one. After changing your recovery key, your old one will no longer work.";
"screen_recovery_key_change_generate_key" = "Generate a new recovery key";
"screen_recovery_key_change_generate_key_description" = "Make sure you can store your recovery key somewhere safe";
Expand All @@ -485,7 +492,7 @@
"screen_recovery_key_confirm_description" = "Make sure nobody can see this screen!";
"screen_recovery_key_confirm_error_content" = "Please try again to confirm access to your chat backup.";
"screen_recovery_key_confirm_error_title" = "Incorrect recovery key";
"screen_recovery_key_confirm_key_description" = "If you have a recovery passphrase or secret passphrase/key, this will work too.";
"screen_recovery_key_confirm_key_description" = "If you have a security key or security phrase, this will work too.";
"screen_recovery_key_confirm_key_label" = "Recovery key or passcode";
"screen_recovery_key_confirm_key_placeholder" = "Enter…";
"screen_recovery_key_confirm_success" = "Recovery key confirmed";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand All @@ -103,6 +102,21 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
navigationRootCoordinator.setRootCoordinator(navigationStackCoordinator)
}

private func startQrCodeLogin() {
let coordinator = QRCodeLoginScreenCoordinator(parameters: .init(qrCodeLoginController: QRCodeLoginController()))
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,
Expand Down
20 changes: 19 additions & 1 deletion ElementX/Sources/Generated/Strings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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") }
Expand Down
20 changes: 20 additions & 0 deletions ElementX/Sources/Mocks/Generated/GeneratedMocks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2338,6 +2338,26 @@ class PollInteractionHandlerMock: PollInteractionHandlerProtocol {
}
}
}
class QRCodeLoginControllerMock: QRCodeLoginControllerProtocol {

//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 }
Expand Down
81 changes: 81 additions & 0 deletions ElementX/Sources/Other/SwiftUI/Views/SFNumberedListView.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
@@ -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 qrCodeLoginController: QRCodeLoginControllerProtocol
}

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(qrCodeLoginController: parameters.qrCodeLoginController)
}

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))
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
//
// 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 qrCodeLoginController: QRCodeLoginControllerProtocol

private let actionsSubject: PassthroughSubject<QRCodeLoginScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<QRCodeLoginScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}

init(qrCodeLoginController: QRCodeLoginControllerProtocol) {
self.qrCodeLoginController = qrCodeLoginController
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 {
let status = AVCaptureDevice.authorizationStatus(for: .video)

// Determine if the user previously authorized camera access.
var isAuthorized = status == .authorized

// 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)
}

state.state = isAuthorized ? .scanning : .error(.noCameraPermission)
}
}
Loading
Loading