Final tweaks to the existing QR code screens to match the designs. (#4874)

* Simplify QR errors even more and use the correct error state for deviceAlreadySignedIn.

* Remove the pre-defined padding on TitleAndIcon and use it in the QRCodeLoginScreen too.

* Update FullscreenDialog to allow different horizontal padding in each section.
This commit is contained in:
Doug
2025-12-17 17:23:55 +00:00
committed by GitHub
parent 117185de87
commit 1a62f2cca6
94 changed files with 325 additions and 362 deletions
+8
View File
@@ -1956,6 +1956,10 @@ internal enum L10n {
internal static var screenLinkNewDeviceRootMobileDevice: String { return L10n.tr("Localizable", "screen_link_new_device_root_mobile_device") }
/// What type of device do you want to link?
internal static var screenLinkNewDeviceRootTitle: String { return L10n.tr("Localizable", "screen_link_new_device_root_title") }
/// Please try again and make sure that you’ve entered the 2-digit code correctly. If the numbers still don’t match then contact your account provider.
internal static var screenLinkNewDeviceWrongNumberSubtitle: String { return L10n.tr("Localizable", "screen_link_new_device_wrong_number_subtitle") }
/// The numbers don’t match
internal static var screenLinkNewDeviceWrongNumberTitle: String { return L10n.tr("Localizable", "screen_link_new_device_wrong_number_title") }
/// This account has been deactivated.
internal static var screenLoginErrorDeactivatedAccount: String { return L10n.tr("Localizable", "screen_login_error_deactivated_account") }
/// Incorrect username and/or password
@@ -2188,6 +2192,10 @@ internal enum L10n {
internal static var screenQrCodeLoginErrorDeclinedSubtitle: String { return L10n.tr("Localizable", "screen_qr_code_login_error_declined_subtitle") }
/// Sign in declined
internal static var screenQrCodeLoginErrorDeclinedTitle: String { return L10n.tr("Localizable", "screen_qr_code_login_error_declined_title") }
/// You don’t need to do anything else.
internal static var screenQrCodeLoginErrorDeviceAlreadySignedInSubtitle: String { return L10n.tr("Localizable", "screen_qr_code_login_error_device_already_signed_in_subtitle") }
/// Your other device is already signed in
internal static var screenQrCodeLoginErrorDeviceAlreadySignedInTitle: String { return L10n.tr("Localizable", "screen_qr_code_login_error_device_already_signed_in_title") }
/// Sign in expired. Please try again.
internal static var screenQrCodeLoginErrorExpiredSubtitle: String { return L10n.tr("Localizable", "screen_qr_code_login_error_expired_subtitle") }
/// The sign in was not completed in time
@@ -21,8 +21,10 @@ struct FullscreenDialog<Content: View, BottomContent: View>: View {
/// Padding applied to the top of the content automatically. Use `UIConstants` for preset values.
var topPadding: CGFloat = UIConstants.titleTopPaddingToNavigationBar
/// Padding applied to the content and buttons automatically
/// Padding applied to the content automatically
var horizontalPadding: CGFloat = 16
/// Padding applied to the buttons automatically
var bottomHorizontalPadding: CGFloat = 16
/// The spacing between the content and the buttons.
var spacing: CGFloat = 16
@@ -83,7 +85,7 @@ struct FullscreenDialog<Content: View, BottomContent: View>: View {
VStack(spacing: 0) {
bottomContent()
.readableFrame()
.padding(.horizontal, horizontalPadding)
.padding(.horizontal, bottomHorizontalPadding)
.padding(.top, spacing)
.padding(.bottom, UIConstants.actionButtonBottomPadding)
@@ -113,7 +115,7 @@ struct FullscreenDialog<Content: View, BottomContent: View>: View {
bottomContent()
.readableFrame()
.padding(.horizontal, horizontalPadding)
.padding(.horizontal, bottomHorizontalPadding)
.padding(.bottom, UIConstants.actionButtonBottomPadding)
Spacer()
@@ -12,7 +12,7 @@ struct LinkNewDeviceScreen: View {
@Bindable var context: LinkNewDeviceScreenViewModel.Context
var body: some View {
FullscreenDialog(topPadding: 0) {
FullscreenDialog(topPadding: 24, horizontalPadding: 24) {
mainContent
} bottomContent: {
buttons
@@ -31,7 +31,6 @@ struct LinkNewDeviceScreen: View {
TitleAndIcon(title: L10n.screenLinkNewDeviceRootTitle,
icon: \.computer,
iconStyle: .default)
.padding(.horizontal, -16) // Remove the FullscreenDialog's default padding in this state.
case .notSupported:
TitleAndIcon(title: L10n.screenLinkNewDeviceErrorNotSupportedTitle,
subtitle: L10n.screenLinkNewDeviceErrorNotSupportedSubtitle,
@@ -65,7 +65,7 @@ enum QRCodeLoginState: Equatable {
/// Any full screen error state
case error(ErrorState)
enum ErrorState: Equatable {
enum ErrorState: Equatable, CaseIterable {
case noCameraPermission
case connectionNotSecure
case cancelled
@@ -73,6 +73,8 @@ enum QRCodeLoginState: Equatable {
case expired
case linkingNotSupported
case deviceNotSupported
/// Expected a QR code for a new device, however the processed code belongs to a device that is already signed in.
case deviceAlreadySignedIn
case unknown
}
@@ -91,8 +93,6 @@ enum QRCodeLoginState: Equatable {
case notAllowed(scannedProvider: String, allowedProviders: [String])
/// The QR code has been processed but it belongs to a device not signed in.
case deviceNotSignedIn
/// Expected a QR code for a new device, however the processed code belongs to a device that is already signed in.
case deviceAlreadySignedIn
var title: String {
switch self {
@@ -102,8 +102,6 @@ enum QRCodeLoginState: Equatable {
L10n.screenChangeServerErrorUnauthorizedHomeserverTitle(scannedProvider)
case .deviceNotSignedIn:
L10n.screenQrCodeLoginDeviceNotSignedInScanStateSubtitle
case .deviceAlreadySignedIn:
L10n.screenQrCodeLoginDeviceNotSignedInScanStateSubtitle // FIXME: Update string
}
}
@@ -115,8 +113,6 @@ enum QRCodeLoginState: Equatable {
L10n.screenChangeServerErrorUnauthorizedHomeserverContent(allowedProviders.formatted(.list(type: .and)))
case .deviceNotSignedIn:
L10n.screenQrCodeLoginDeviceNotSignedInScanStateDescription
case .deviceAlreadySignedIn:
L10n.screenQrCodeLoginDeviceNotSignedInScanStateDescription // FIXME: Update string
}
}
}
@@ -129,7 +129,7 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
case .deviceNotSignedIn:
state.state = .scan(.scanFailed(.deviceNotSignedIn))
case .deviceAlreadySignedIn:
state.state = .scan(.scanFailed(.deviceAlreadySignedIn))
state.state = .error(.deviceAlreadySignedIn)
case .cancelled:
state.state = .error(.cancelled)
case .connectionInsecure:
@@ -31,6 +31,8 @@ struct QRCodeErrorView: View {
L10n.screenQrCodeLoginErrorLinkingNotSuportedTitle
case .deviceNotSupported:
L10n.screenQrCodeLoginErrorSlidingSyncNotSupportedTitle(InfoPlistReader.main.bundleDisplayName)
case .deviceAlreadySignedIn:
L10n.screenQrCodeLoginErrorDeviceAlreadySignedInTitle
case .unknown:
L10n.commonSomethingWentWrong
}
@@ -52,56 +54,43 @@ struct QRCodeErrorView: View {
L10n.screenQrCodeLoginErrorLinkingNotSuportedSubtitle(InfoPlistReader.main.bundleDisplayName)
case .deviceNotSupported:
L10n.screenQrCodeLoginErrorSlidingSyncNotSupportedSubtitle(InfoPlistReader.main.bundleDisplayName)
case .deviceAlreadySignedIn:
L10n.screenQrCodeLoginErrorDeviceAlreadySignedInSubtitle
case .unknown:
L10n.screenQrCodeLoginUnknownErrorDescription
}
}
var body: some View {
FullscreenDialog {
header
} bottomContent: {
footer
var icon: KeyPath<CompoundIcons, Image> {
switch errorState {
case .noCameraPermission: \.takePhotoSolid
case .deviceAlreadySignedIn: \.checkCircleSolid
default: \.errorSolid
}
}
var iconStyle: BigIcon.Style {
switch errorState {
case .noCameraPermission: .defaultSolid
case .deviceAlreadySignedIn: .successSolid
default: .alertSolid
}
}
var body: some View {
FullscreenDialog(topPadding: 24, horizontalPadding: 24) {
mainContent
} bottomContent: {
buttons
}
.padding(.horizontal, 24)
}
@ViewBuilder
private var header: some View {
private var mainContent: some View {
switch errorState {
case .noCameraPermission:
VStack(spacing: 16) {
BigIcon(icon: \.takePhotoSolid, style: .defaultSolid)
VStack(spacing: 8) {
Text(title)
.foregroundColor(.compound.textPrimary)
.font(.compound.headingMDBold)
.multilineTextAlignment(.center)
Text(subtitle)
.foregroundColor(.compound.textSecondary)
.font(.compound.bodyMD)
.multilineTextAlignment(.center)
}
}
case .connectionNotSecure:
VStack(spacing: 40) {
VStack(spacing: 16) {
BigIcon(icon: \.errorSolid, style: .alertSolid)
VStack(spacing: 8) {
Text(title)
.foregroundColor(.compound.textPrimary)
.font(.compound.headingMDBold)
.multilineTextAlignment(.center)
Text(subtitle)
.foregroundColor(.compound.textSecondary)
.font(.compound.bodyMD)
.multilineTextAlignment(.center)
}
}
header
VStack(spacing: 24) {
Text(L10n.screenQrCodeLoginConnectionNoteSecureStateListHeader)
@@ -117,31 +106,19 @@ struct QRCodeErrorView: View {
}
}
default:
simpleErrorStack
header
}
}
@ViewBuilder
private var simpleErrorStack: some View {
VStack(spacing: 16) {
BigIcon(icon: \.errorSolid, style: .alertSolid)
VStack(spacing: 8) {
Text(title)
.foregroundColor(.compound.textPrimary)
.font(.compound.headingMDBold)
.multilineTextAlignment(.center)
Text(subtitle)
.foregroundColor(.compound.textSecondary)
.font(.compound.bodyMD)
.multilineTextAlignment(.center)
}
}
var header: some View {
TitleAndIcon(title: title,
subtitle: subtitle,
icon: icon,
iconStyle: iconStyle)
}
@ViewBuilder
private var footer: some View {
private var buttons: some View {
switch errorState {
case .noCameraPermission:
Button(L10n.screenQrCodeLoginNoCameraPermissionButton) {
@@ -172,6 +149,11 @@ struct QRCodeErrorView: View {
}
.buttonStyle(.compound(.tertiary))
}
case .deviceAlreadySignedIn:
Button(L10n.actionContinue) {
action(.cancel)
}
.buttonStyle(.compound(.primary))
}
}
}
@@ -180,42 +162,34 @@ struct QRCodeErrorView: View {
struct QRCodeErrorView_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
QRCodeErrorViewSnapshot(errorState: .noCameraPermission, canSignInManually: false)
.previewDisplayName("No Camera Permission")
ForEach(QRCodeLoginState.ErrorState.allCases, id: \.self) { errorState in
NavigationStack {
QRCodeErrorView(errorState: errorState, canSignInManually: true) { _ in }
.toolbar(.visible, for: .navigationBar)
}
.previewDisplayName(errorState.previewDisplayName)
}
QRCodeErrorViewSnapshot(errorState: .connectionNotSecure, canSignInManually: false)
.previewDisplayName("Connection not secure")
QRCodeErrorViewSnapshot(errorState: .linkingNotSupported, canSignInManually: true)
.previewDisplayName("Linking unsupported")
QRCodeErrorViewSnapshot(errorState: .linkingNotSupported, canSignInManually: false)
.previewDisplayName("Linking unsupported restricted flow")
QRCodeErrorViewSnapshot(errorState: .cancelled, canSignInManually: false)
.previewDisplayName("Cancelled")
QRCodeErrorViewSnapshot(errorState: .declined, canSignInManually: false)
.previewDisplayName("Declined")
QRCodeErrorViewSnapshot(errorState: .expired, canSignInManually: false)
.previewDisplayName("Expired")
QRCodeErrorViewSnapshot(errorState: .deviceNotSupported, canSignInManually: false)
.previewDisplayName("Device not supported")
QRCodeErrorViewSnapshot(errorState: .unknown, canSignInManually: false)
.previewDisplayName("Unknown error")
NavigationStack {
QRCodeErrorView(errorState: .linkingNotSupported, canSignInManually: false) { _ in }
.toolbar(.visible, for: .navigationBar)
}
.previewDisplayName("Linking unsupported restricted flow")
}
}
private struct QRCodeErrorViewSnapshot: View {
let errorState: QRCodeLoginState.ErrorState
let canSignInManually: Bool
var body: some View {
NavigationStack {
QRCodeErrorView(errorState: errorState, canSignInManually: canSignInManually) { _ in }
.toolbar(.visible, for: .navigationBar)
private extension QRCodeLoginState.ErrorState {
var previewDisplayName: String {
switch self {
case .noCameraPermission: "No Camera Permission"
case .connectionNotSecure: "Connection not secure"
case .linkingNotSupported: "Linking unsupported"
case .cancelled: "Cancelled"
case .declined: "Declined"
case .expired: "Expired"
case .deviceNotSupported: "Device not supported"
case .deviceAlreadySignedIn: "Device already signed in"
case .unknown: "Unknown error"
}
}
}
@@ -49,24 +49,12 @@ struct QRCodeLoginScreen: View {
}
private var initialContent: some View {
FullscreenDialog {
FullscreenDialog(topPadding: 24, horizontalPadding: 24) {
VStack(alignment: .leading, spacing: 40) {
VStack(spacing: 16) {
BigIcon(icon: \.computer, style: .default)
VStack(spacing: 8) {
Text(L10n.screenQrCodeLoginInitialStateTitle(InfoPlistReader.main.productionAppName))
.foregroundColor(.compound.textPrimary)
.font(.compound.headingMDBold)
.multilineTextAlignment(.center)
Text(L10n.screenQrCodeLoginInitialStateSubtitle)
.font(.compound.bodyMD)
.multilineTextAlignment(.center)
.foregroundColor(.compound.textSecondary)
}
}
.padding(.horizontal, 24)
TitleAndIcon(title: L10n.screenQrCodeLoginInitialStateTitle(InfoPlistReader.main.productionAppName),
subtitle: L10n.screenQrCodeLoginInitialStateSubtitle,
icon: \.computer,
iconStyle: .default)
SFNumberedListView(items: context.viewState.initialStateListItems)
}
@@ -81,17 +69,21 @@ struct QRCodeLoginScreen: View {
@ViewBuilder
private var displayCodeContent: some View {
if case let .displayCode(displayCodeState) = context.viewState.state {
FullscreenDialog {
FullscreenDialog(topPadding: 24) {
VStack(spacing: 32) {
VStack(spacing: 40) {
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)
@@ -104,63 +96,36 @@ struct QRCodeLoginScreen: View {
}
.buttonStyle(.compound(.secondary))
}
.padding(.horizontal, 24)
}
}
private func displayCodeHeader(state: QRCodeLoginState.QRCodeLoginDisplayCodeState) -> some View {
VStack(spacing: 16) {
switch state {
case .deviceCode:
BigIcon(icon: \.computer, style: .default)
VStack(spacing: 8) {
Text(L10n.screenQrCodeLoginDeviceCodeTitle)
.foregroundColor(.compound.textPrimary)
.font(.compound.headingMDBold)
.multilineTextAlignment(.center)
Text(L10n.screenQrCodeLoginDeviceCodeSubtitle)
.foregroundColor(.compound.textSecondary)
.font(.compound.bodyMD)
.multilineTextAlignment(.center)
}
case .verificationCode:
BigIcon(icon: \.lock, style: .default)
VStack(spacing: 8) {
Text(L10n.screenQrCodeLoginVerifyCodeTitle)
.foregroundColor(.compound.textPrimary)
.font(.compound.headingMDBold)
.multilineTextAlignment(.center)
Text(L10n.screenQrCodeLoginVerifyCodeSubtitle)
.foregroundColor(.compound.textSecondary)
.font(.compound.bodyMD)
.multilineTextAlignment(.center)
}
}
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 qrScanContent: some View {
FullscreenDialog {
FullscreenDialog(topPadding: 24) {
VStack(spacing: 40) {
VStack(spacing: 16) {
BigIcon(icon: \.takePhotoSolid, style: .default)
Text(L10n.screenQrCodeLoginScanningStateTitle)
.foregroundColor(.compound.textPrimary)
.font(.compound.headingMDBold)
.multilineTextAlignment(.center)
}
TitleAndIcon(title: L10n.screenQrCodeLoginScanningStateTitle,
icon: \.takePhotoSolid,
iconStyle: .default)
qrScanner
}
} bottomContent: {
qrScanFooter
}
.padding(.horizontal, 24)
}
@ViewBuilder