* replace NavigationStack with ElementNavigationStack to allow the content to be rendered without a NavigationStack in a11y tests * fix a11y tests * update xcodeproject * swiftformat fix * use iOS 26.1 for CI * use a wrapper to solve the issue for a11y tests * ElementNavigationStack only uses the trick in DEBUG mode, and added a swiftlint rule to prevent the usage of NavigationStack
389 lines
17 KiB
Swift
389 lines
17 KiB
Swift
//
|
|
// Copyright 2025 Element Creations Ltd.
|
|
// Copyright 2022-2025 New Vector Ltd.
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
// Please see LICENSE files in the repository root for full details.
|
|
//
|
|
|
|
import Compound
|
|
import SwiftUI
|
|
|
|
struct QRCodeLoginScreen: View {
|
|
@ObservedObject var context: QRCodeLoginScreenViewModel.Context
|
|
|
|
@State private var qrFrame = CGRect.zero
|
|
@FocusState private var checkCodeInputFocus
|
|
|
|
var backgroundStyle: Color {
|
|
if case .error = context.viewState.state {
|
|
.compound.bgCanvasDefault
|
|
} else {
|
|
.compound.bgSubtleSecondary
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
mainContent
|
|
.toolbar { toolbar }
|
|
.toolbar(.visible, for: .navigationBar)
|
|
.background()
|
|
.backgroundStyle(backgroundStyle)
|
|
.interactiveDismissDisabled()
|
|
.navigationBarBackButtonHidden(!context.viewState.shouldDisplayBackButton)
|
|
}
|
|
|
|
@ViewBuilder
|
|
var mainContent: some View {
|
|
switch context.viewState.state {
|
|
case .loginInstructions:
|
|
loginInstructionsContent
|
|
case .linkDesktopInstructions:
|
|
linkDesktopInstructionsContent
|
|
case .scan:
|
|
qrScannerContent
|
|
case .displayCode:
|
|
displayCodeContent
|
|
case .displayQR:
|
|
displayQRContent
|
|
case .confirmCode:
|
|
confirmCodeContent
|
|
case .error(let errorState):
|
|
QRCodeErrorView(errorState: errorState, canSignInManually: context.viewState.canSignInManually) { action in
|
|
context.send(viewAction: .errorAction(action))
|
|
}
|
|
}
|
|
}
|
|
|
|
private var loginInstructionsContent: some View {
|
|
FullscreenDialog(topPadding: 24, horizontalPadding: 24) {
|
|
VStack(alignment: .leading, spacing: 40) {
|
|
TitleAndIcon(title: L10n.screenQrCodeLoginInitialStateTitle(InfoPlistReader.main.productionAppName),
|
|
subtitle: L10n.screenQrCodeLoginInitialStateSubtitle,
|
|
icon: \.computer,
|
|
iconStyle: .default)
|
|
|
|
SFNumberedListView(items: context.viewState.instructions.loginItems)
|
|
}
|
|
} bottomContent: {
|
|
Button(L10n.screenQrCodeLoginInitialStateButtonTitle) {
|
|
context.send(viewAction: .startScan)
|
|
}
|
|
.buttonStyle(.compound(.primary))
|
|
}
|
|
}
|
|
|
|
private var linkDesktopInstructionsContent: some View {
|
|
FullscreenDialog(topPadding: 24, horizontalPadding: 24) {
|
|
VStack(alignment: .leading, spacing: 40) {
|
|
TitleAndIcon(title: L10n.screenLinkNewDeviceDesktopTitle(InfoPlistReader.main.productionAppName),
|
|
icon: \.computer,
|
|
iconStyle: .default)
|
|
|
|
SFNumberedListView(items: context.viewState.instructions.linkDesktopItems)
|
|
}
|
|
} bottomContent: {
|
|
Button(L10n.screenLinkNewDeviceDesktopSubmit) {
|
|
context.send(viewAction: .startScan)
|
|
}
|
|
.buttonStyle(.compound(.primary))
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var displayCodeContent: some View {
|
|
if case let .displayCode(displayCodeState) = context.viewState.state {
|
|
FullscreenDialog(topPadding: 24) {
|
|
VStack(spacing: 32) {
|
|
VStack(spacing: 24) {
|
|
displayCodeHeader(state: displayCodeState)
|
|
.padding(.horizontal, 8)
|
|
|
|
PINTextField(pinCode: .constant(displayCodeState.code),
|
|
maxLength: displayCodeState.code.count,
|
|
size: .small)
|
|
.disabled(true)
|
|
}
|
|
|
|
VStack(spacing: 4) {
|
|
ProgressView()
|
|
|
|
Text(L10n.screenQrCodeLoginVerifyCodeLoading)
|
|
.foregroundColor(.compound.textSecondary)
|
|
.font(.compound.bodySM)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
}
|
|
} bottomContent: {
|
|
Button(L10n.actionCancel) {
|
|
context.send(viewAction: .cancel)
|
|
}
|
|
.buttonStyle(.compound(.secondary))
|
|
}
|
|
}
|
|
}
|
|
|
|
private func displayCodeHeader(state: QRCodeLoginState.DisplayCodeState) -> some View {
|
|
switch state {
|
|
case .deviceCode:
|
|
TitleAndIcon(title: L10n.screenQrCodeLoginDeviceCodeTitle,
|
|
subtitle: L10n.screenQrCodeLoginDeviceCodeSubtitle,
|
|
icon: \.computer,
|
|
iconStyle: .default)
|
|
case .verificationCode:
|
|
TitleAndIcon(title: L10n.screenQrCodeLoginVerifyCodeTitle,
|
|
subtitle: L10n.screenQrCodeLoginVerifyCodeSubtitle,
|
|
icon: \.lock,
|
|
iconStyle: .default)
|
|
}
|
|
}
|
|
|
|
private var qrScannerContent: some View {
|
|
FullscreenDialog(topPadding: 24) {
|
|
VStack(spacing: 40) {
|
|
TitleAndIcon(title: L10n.screenQrCodeLoginScanningStateTitle,
|
|
icon: \.takePhotoSolid,
|
|
iconStyle: .default)
|
|
|
|
qrScanner
|
|
}
|
|
} bottomContent: {
|
|
qrScannerFooter
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var qrScannerFooter: 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)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
case .scanning:
|
|
// To keep the spacing consistent between states
|
|
Button("") { }
|
|
.buttonStyle(.compound(.primary))
|
|
.hidden()
|
|
case .scanFailed(let error):
|
|
VStack(spacing: 16) {
|
|
Button(L10n.screenQrCodeLoginInvalidScanStateRetryButton) {
|
|
context.send(viewAction: .startScan)
|
|
}
|
|
.buttonStyle(.compound(.primary))
|
|
|
|
VStack(spacing: 4) {
|
|
Label(error.title,
|
|
icon: \.errorSolid,
|
|
iconSize: .medium,
|
|
relativeTo: .compound.bodyMDSemibold)
|
|
.labelStyle(.custom(spacing: 10))
|
|
.font(.compound.bodyMDSemibold)
|
|
.foregroundColor(.compound.textCriticalPrimary)
|
|
|
|
Text(error.description)
|
|
.foregroundColor(.compound.textSecondary)
|
|
.font(.compound.bodySM)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var qrScanner: some View {
|
|
QRCodeScannerView(result: $context.qrResult, isScanning: context.viewState.state.isScanning)
|
|
.aspectRatio(1.0, contentMode: .fill)
|
|
.frame(maxWidth: 312)
|
|
.readFrame($qrFrame)
|
|
.background(.compound.bgCanvasDefault)
|
|
.overlay(QRScannerViewOverlay(length: qrFrame.height))
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var displayQRContent: some View {
|
|
if case let .displayQR(image) = context.viewState.state {
|
|
FullscreenDialog(topPadding: 24, horizontalPadding: 24) {
|
|
VStack(spacing: 32) {
|
|
TitleAndIcon(title: L10n.screenLinkNewDeviceMobileTitle(InfoPlistReader.main.productionAppName),
|
|
icon: \.takePhotoSolid,
|
|
iconStyle: .default)
|
|
|
|
Image(uiImage: image)
|
|
.interpolation(.none) // to stop it getting blurred
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(width: 200, height: 200)
|
|
.accessibilityLabel(L10n.a11yQrCode)
|
|
|
|
SFNumberedListView(items: context.viewState.instructions.linkMobileItems)
|
|
}
|
|
} bottomContent: { }
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var confirmCodeContent: some View {
|
|
if case let .confirmCode(confirmCode) = context.viewState.state {
|
|
FullscreenDialog(topPadding: 24, horizontalPadding: 24) {
|
|
VStack(spacing: 24) {
|
|
TitleAndIcon(title: L10n.screenLinkNewDeviceEnterNumberTitle,
|
|
subtitle: L10n.screenLinkNewDeviceEnterNumberSubtitle,
|
|
icon: \.computer,
|
|
iconStyle: .default)
|
|
|
|
VStack(spacing: 10) {
|
|
Text(L10n.screenLinkNewDeviceEnterNumberNotice)
|
|
.font(.compound.bodyMDSemibold)
|
|
.multilineTextAlignment(.center)
|
|
.foregroundStyle(.compound.textSecondary)
|
|
|
|
PINTextField(pinCode: $context.checkCodeInput, maxLength: 2, size: .medium)
|
|
.focused($checkCodeInputFocus)
|
|
.disabled(confirmCode.isSending)
|
|
|
|
if case .confirmCode(.invalidCode) = context.viewState.state {
|
|
Label(L10n.screenLinkNewDeviceEnterNumberErrorNumbersDoNotMatch,
|
|
icon: \.errorSolid,
|
|
iconSize: .medium,
|
|
relativeTo: .compound.bodyMDSemibold)
|
|
.labelStyle(.custom(spacing: 10))
|
|
.font(.compound.bodyMDSemibold)
|
|
.foregroundColor(.compound.textCriticalPrimary)
|
|
}
|
|
}
|
|
}
|
|
} bottomContent: {
|
|
switch confirmCode {
|
|
case .inputCode, .sendingCode:
|
|
Button(L10n.actionContinue) {
|
|
context.send(viewAction: .sendCheckCode)
|
|
}
|
|
.buttonStyle(.compound(.primary))
|
|
.disabled(context.checkCodeInput.count < 2 || confirmCode.isSending)
|
|
case .invalidCode:
|
|
Button(L10n.actionStartOver) {
|
|
context.send(viewAction: .errorAction(.startOver))
|
|
}
|
|
.buttonStyle(.compound(.primary))
|
|
}
|
|
}
|
|
.onAppear { checkCodeInputFocus = true }
|
|
}
|
|
}
|
|
|
|
@ToolbarContentBuilder
|
|
private var toolbar: some ToolbarContent {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
if context.viewState.shouldDisplayCancelButton {
|
|
Button(L10n.actionCancel) {
|
|
context.send(viewAction: .cancel)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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: 6.0, lineCap: .square, dash: [dashLength, emptyLength], dashPhase: dashPhase))
|
|
}
|
|
}
|
|
|
|
// MARK: - Previews
|
|
|
|
import MatrixRustSDKMocks
|
|
|
|
struct QRCodeLoginScreen_Previews: PreviewProvider, TestablePreview {
|
|
// Instructions
|
|
static let loginInstructionsStateViewModel = QRCodeLoginScreenViewModel.mock(state: .loginInstructions)
|
|
static let linkInstructionsStateViewModel = QRCodeLoginScreenViewModel.mock(state: .linkDesktopInstructions)
|
|
|
|
/// Scanning
|
|
static let scanningStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.scanning))
|
|
|
|
static let connectingStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.connecting))
|
|
|
|
static let invalidStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.scanFailed(.invalid)))
|
|
|
|
static let notAllowedStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.scanFailed(.notAllowed(scannedProvider: "evil.com",
|
|
allowedProviders: ["example.com",
|
|
"server.net"]))))
|
|
|
|
static let deviceNotSignedInStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.scanFailed(.deviceNotSignedIn)))
|
|
|
|
/// Showing
|
|
static let showingStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayQR(LinkNewDeviceServiceMock.mockQRCodeImage))
|
|
|
|
// Displaying codes
|
|
static let deviceCodeStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayCode(.deviceCode("12")))
|
|
static let verificationCodeStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayCode(.verificationCode("123456")))
|
|
|
|
static let confirmCodeStateViewModel = QRCodeLoginScreenViewModel.mock(state: .confirmCode(.inputCode(CheckCodeSenderProxy(underlyingSender: CheckCodeSenderSDKMock()))))
|
|
static let confirmCodeEnteredStateViewModel = QRCodeLoginScreenViewModel.mock(state: .confirmCode(.inputCode(CheckCodeSenderProxy(underlyingSender: CheckCodeSenderSDKMock()))), checkCodeInput: "12")
|
|
static let confirmCodeInvalidStateViewModel = QRCodeLoginScreenViewModel.mock(state: .confirmCode(.invalidCode))
|
|
|
|
/// Errors (no need to test them all QRCodeErrorView covers that).
|
|
static let errorStateViewModel = QRCodeLoginScreenViewModel.mock(state: .error(.declined))
|
|
|
|
static var previews: some View {
|
|
ElementNavigationStack { QRCodeLoginScreen(context: loginInstructionsStateViewModel.context) }
|
|
.previewDisplayName("Login instructions")
|
|
ElementNavigationStack { QRCodeLoginScreen(context: linkInstructionsStateViewModel.context) }
|
|
.previewDisplayName("Link instructions")
|
|
|
|
ElementNavigationStack { QRCodeLoginScreen(context: scanningStateViewModel.context) }
|
|
.previewDisplayName("Scanning")
|
|
ElementNavigationStack { QRCodeLoginScreen(context: connectingStateViewModel.context) }
|
|
.previewDisplayName("Connecting")
|
|
ElementNavigationStack { QRCodeLoginScreen(context: invalidStateViewModel.context) }
|
|
.previewDisplayName("Invalid")
|
|
ElementNavigationStack { QRCodeLoginScreen(context: notAllowedStateViewModel.context) }
|
|
.previewDisplayName("Not allowed")
|
|
ElementNavigationStack { QRCodeLoginScreen(context: deviceNotSignedInStateViewModel.context) }
|
|
.previewDisplayName("Device not signed in")
|
|
|
|
ElementNavigationStack { QRCodeLoginScreen(context: showingStateViewModel.context) }
|
|
.previewDisplayName("Showing")
|
|
|
|
ElementNavigationStack { QRCodeLoginScreen(context: deviceCodeStateViewModel.context) }
|
|
.previewDisplayName("Device code")
|
|
ElementNavigationStack { QRCodeLoginScreen(context: verificationCodeStateViewModel.context) }
|
|
.previewDisplayName("Verification code")
|
|
|
|
ElementNavigationStack { QRCodeLoginScreen(context: confirmCodeStateViewModel.context) }
|
|
.previewDisplayName("Confirm code")
|
|
ElementNavigationStack { QRCodeLoginScreen(context: confirmCodeEnteredStateViewModel.context) }
|
|
.previewDisplayName("Confirm code entered")
|
|
ElementNavigationStack { QRCodeLoginScreen(context: confirmCodeInvalidStateViewModel.context) }
|
|
.previewDisplayName("Confirm code invalid")
|
|
|
|
ElementNavigationStack { QRCodeLoginScreen(context: errorStateViewModel.context) }
|
|
.previewDisplayName("Error")
|
|
}
|
|
}
|