QR Code scan view (#2674)

This commit is contained in:
Mauro
2024-04-10 12:47:23 +02:00
committed by GitHub
parent 0fc0ad101e
commit bcd7e2254c
26 changed files with 364 additions and 20 deletions
@@ -30,9 +30,11 @@ class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType
init(appSettings: AppSettings) {
self.appSettings = appSettings
super.init(initialViewState: AuthenticationStartScreenViewState())
appSettings.$qrCodeLoginEnabled
.weakAssign(to: \.state.isQRCodeLoginEnabled, on: self)
.store(in: &cancellables)
if !ProcessInfo.processInfo.isiOSAppOnMac {
appSettings.$qrCodeLoginEnabled
.weakAssign(to: \.state.isQRCodeLoginEnabled, on: self)
.store(in: &cancellables)
}
}
override func process(viewAction: AuthenticationStartScreenViewAction) {
@@ -21,6 +21,7 @@ import SwiftUI
struct QRCodeLoginScreenCoordinatorParameters {
let qrCodeLoginService: QRCodeLoginServiceProtocol
let orientationManager: OrientationManagerProtocol
}
enum QRCodeLoginScreenCoordinatorAction {
@@ -29,6 +30,7 @@ enum QRCodeLoginScreenCoordinatorAction {
final class QRCodeLoginScreenCoordinator: CoordinatorProtocol {
private let viewModel: QRCodeLoginScreenViewModelProtocol
private let orientationManager: OrientationManagerProtocol
private var cancellables = Set<AnyCancellable>()
@@ -39,6 +41,7 @@ final class QRCodeLoginScreenCoordinator: CoordinatorProtocol {
init(parameters: QRCodeLoginScreenCoordinatorParameters) {
viewModel = QRCodeLoginScreenViewModel(qrCodeLoginService: parameters.qrCodeLoginService)
orientationManager = parameters.orientationManager
}
func start() {
@@ -52,6 +55,13 @@ final class QRCodeLoginScreenCoordinator: CoordinatorProtocol {
}
}
.store(in: &cancellables)
orientationManager.setOrientation(.portrait)
orientationManager.lockOrientation(.portrait)
}
func stop() {
orientationManager.lockOrientation(.all)
}
func toPresentable() -> AnyView {
@@ -56,12 +56,24 @@ enum QRCodeLoginScreenViewAction {
case startScan
}
enum QRCodeLoginState {
enum QRCodeLoginState: Equatable {
/// Initial state where the user is informed how to perform the scan
case initial
case scanning
/// The camera is scanning
case scan(QRCodeLoginScanningState)
/// Any full screen error state
case error(QRCodeLoginErrorState)
enum QRCodeLoginErrorState {
enum QRCodeLoginErrorState: Equatable {
case noCameraPermission
}
enum QRCodeLoginScanningState: Equatable {
/// the qr code is scanning
case scanning
/// the qr code has been detected and is being processed
case connecting
/// the qr code has been processed and is invalid
case invalid
}
}
@@ -45,6 +45,18 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
}
private func startScanIfPossible() async {
state.state = await qrCodeLoginService.requestAuthorizationIfNeeded() ? .scanning : .error(.noCameraPermission)
state.state = await qrCodeLoginService.requestAuthorizationIfNeeded() ? .scan(.scanning) : .error(.noCameraPermission)
}
/// Only for mocking initial states
fileprivate init(state: QRCodeLoginState) {
qrCodeLoginService = QRCodeLoginServiceMock(configuration: .init())
super.init(initialViewState: .init(state: state))
}
}
extension QRCodeLoginScreenViewModel {
static func mock(state: QRCodeLoginState) -> QRCodeLoginScreenViewModel {
QRCodeLoginScreenViewModel(state: state)
}
}
@@ -19,6 +19,7 @@ import SwiftUI
struct QRCodeLoginScreen: View {
@ObservedObject var context: QRCodeLoginScreenViewModel.Context
@State private var qrFrame = CGRect.zero
var body: some View {
NavigationStack {
@@ -36,13 +37,15 @@ struct QRCodeLoginScreen: View {
switch context.viewState.state {
case .initial:
initialContent
case .scanning, .error:
// TODO: Handle states
case .scan:
qrScanContent
case .error:
// TODO: Handle error states
EmptyView()
}
}
var initialContent: some View {
private var initialContent: some View {
FullscreenDialog {
VStack(alignment: .leading, spacing: 40) {
VStack(spacing: 16) {
@@ -64,6 +67,75 @@ struct QRCodeLoginScreen: View {
.buttonStyle(.compound(.primary))
}
}
private var qrScanContent: some View {
FullscreenDialog {
VStack(spacing: 40) {
VStack(spacing: 16) {
HeroImage(icon: \.takePhotoSolid, style: .subtle)
Text(L10n.screenQrCodeLoginScanningStateTitle)
.foregroundColor(.compound.textPrimary)
.font(.compound.headingMDBold)
.multilineTextAlignment(.center)
}
qrScanner
}
} bottomContent: {
qrScanFooter
}
.padding(.horizontal, 24)
}
@ViewBuilder
private var qrScanFooter: some View {
if case let .scan(scanState) = context.viewState.state {
switch scanState {
case .connecting:
VStack(spacing: 4) {
ProgressView()
Text(L10n.screenQrCodeLoginConnectingSubtitle)
.foregroundColor(.compound.textSecondary)
.font(.compound.bodySM)
}
case .scanning:
// To keep the spacing consistent between states
Button("") { }
.buttonStyle(.compound(.primary))
.hidden()
case .invalid:
VStack(spacing: 16) {
Button(L10n.screenQrCodeLoginInvalidScanStateRetryButton) {
// TODO: Implement try again
}
.buttonStyle(.compound(.primary))
VStack(spacing: 0) {
Label(L10n.screenQrCodeLoginInvalidScanStateSubtitle, icon: \.error, iconSize: .medium, relativeTo: .compound.bodyMDSemibold)
.labelStyle(.custom(spacing: 10))
.font(.compound.bodyMDSemibold)
.foregroundColor(.compound.textCriticalPrimary)
Text(L10n.screenQrCodeLoginInvalidScanStateDescription)
.foregroundColor(.compound.textSecondary)
.font(.compound.bodySM)
}
}
}
}
}
private var qrScanner: some View {
QRCodeScannerView()
.aspectRatio(1.0, contentMode: .fill)
.frame(maxWidth: 312)
.readFrame($qrFrame)
.background(.compound.bgCanvasDefault)
.overlay(
QRScannerViewOverlay(length: qrFrame.height)
)
}
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
@@ -75,12 +147,53 @@ struct QRCodeLoginScreen: View {
}
}
private struct QRScannerViewOverlay: View {
let length: CGFloat
private let dashRatio: CGFloat = 80.0 / 312.0
private let emptyRatio: CGFloat = 232.0 / 312.0
private let dashPhaseRatio: CGFloat = 40.0 / 312.0
private var dashLength: CGFloat {
length * dashRatio
}
private var emptyLength: CGFloat {
length * emptyRatio
}
private var dashPhase: CGFloat {
length * dashPhaseRatio
}
var body: some View {
Rectangle()
.stroke(.compound.textPrimary, style: StrokeStyle(lineWidth: 4.0, lineCap: .square, dash: [dashLength, emptyLength], dashPhase: dashPhase))
}
}
// MARK: - Previews
struct QRCodeLoginScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = QRCodeLoginScreenViewModel(qrCodeLoginService: QRCodeLoginServiceMock())
static let initialStateViewModel = QRCodeLoginScreenViewModel.mock(state: .initial)
static let scanningStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.scanning))
static let connectingStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.connecting))
static let invalidStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.invalid))
static var previews: some View {
QRCodeLoginScreen(context: viewModel.context)
QRCodeLoginScreen(context: initialStateViewModel.context)
.previewDisplayName("Initial")
QRCodeLoginScreen(context: scanningStateViewModel.context)
.previewDisplayName("Scanning")
QRCodeLoginScreen(context: connectingStateViewModel.context)
.previewDisplayName("Connecting")
QRCodeLoginScreen(context: invalidStateViewModel.context)
.previewDisplayName("Invalid")
}
}
@@ -0,0 +1,87 @@
//
// 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
import SwiftUI
import UIKit
struct QRCodeScannerView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> QRScannerController {
let controller = QRScannerController()
return controller
}
func updateUIViewController(_ uiViewController: QRScannerController, context: Context) { }
}
final class QRScannerController: UIViewController {
private var captureSession = AVCaptureSession()
private var videoPreviewLayer: AVCaptureVideoPreviewLayer?
private var qrCodeFrameView: UIView?
var delegate: AVCaptureMetadataOutputObjectsDelegate?
override func viewDidLoad() {
super.viewDidLoad()
// Get the back-facing camera for capturing videos
guard let captureDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else {
MXLog.error("Failed to get the camera device")
return
}
let videoInput: AVCaptureDeviceInput
do {
// Get an instance of the AVCaptureDeviceInput class using the previous device object.
videoInput = try AVCaptureDeviceInput(device: captureDevice)
} catch {
// If any error occurs, simply print it out and don't continue any more.
MXLog.error("ACaptureDeviceInput error: \(error)")
return
}
// Set the input device on the capture session.
captureSession.addInput(videoInput)
// Initialize a AVCaptureMetadataOutput object and set it as the output device to the capture session.
let captureMetadataOutput = AVCaptureMetadataOutput()
captureSession.addOutput(captureMetadataOutput)
// Set delegate and use the default dispatch queue to execute the call back
captureMetadataOutput.setMetadataObjectsDelegate(delegate, queue: DispatchQueue.main)
captureMetadataOutput.metadataObjectTypes = [.qr]
// Initialize the video preview layer and add it as a sublayer to the viewPreview view's layer.
let previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
videoPreviewLayer = previewLayer
videoPreviewLayer?.videoGravity = AVLayerVideoGravity.resizeAspectFill
videoPreviewLayer?.frame = view.layer.bounds
view.layer.addSublayer(previewLayer)
// Start video capture.
DispatchQueue.global(qos: .userInitiated).async {
self.captureSession.startRunning()
}
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
videoPreviewLayer?.frame = view.layer.bounds
}
}