QR Code error views (#2678)

Co-authored-by: Doug <douglase@element.io>
This commit is contained in:
Mauro 2024-04-11 10:32:56 +02:00 committed by GitHub
parent eda7d59518
commit 9bc24e2038
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 202 additions and 25 deletions

View File

@ -901,6 +901,18 @@ class ApplicationMock: ApplicationProtocol {
openReceivedInvocations.append(url) openReceivedInvocations.append(url)
openClosure?(url) openClosure?(url)
} }
//MARK: - openAppSettings
var openAppSettingsCallsCount = 0
var openAppSettingsCalled: Bool {
return openAppSettingsCallsCount > 0
}
var openAppSettingsClosure: (() -> Void)?
func openAppSettings() {
openAppSettingsCallsCount += 1
openAppSettingsClosure?()
}
} }
class AudioConverterMock: AudioConverterProtocol { class AudioConverterMock: AudioConverterProtocol {

View File

@ -25,6 +25,7 @@ struct HeroImage: View {
case normal case normal
case positive case positive
case subtle case subtle
case critical
var foregroundColor: Color { var foregroundColor: Color {
switch self { switch self {
@ -34,6 +35,8 @@ struct HeroImage: View {
return .compound.iconSuccessPrimary return .compound.iconSuccessPrimary
case .subtle: case .subtle:
return .compound.iconSecondary return .compound.iconSecondary
case .critical:
return .compound.iconCriticalPrimary
} }
} }
@ -45,6 +48,8 @@ struct HeroImage: View {
return .compound.bgSuccessSubtle return .compound.bgSuccessSubtle
case .subtle: case .subtle:
return .compound.bgSubtlePrimary return .compound.bgSubtlePrimary
case .critical:
return .compound.bgCanvasDefault
} }
} }
} }

View File

@ -49,11 +49,7 @@ final class StaticLocationScreenCoordinator: CoordinatorProtocol {
case .close: case .close:
actionsSubject.send(.close) actionsSubject.send(.close)
case .openSystemSettings: case .openSystemSettings:
guard let url = URL(string: UIApplication.openSettingsURLString), UIApplication.shared.openAppSettings()
UIApplication.shared.canOpenURL(url) else {
return
}
UIApplication.shared.open(url)
case .sendLocation(let geoURI, let isUserLocation): case .sendLocation(let geoURI, let isUserLocation):
actionsSubject.send(.selectedLocation(geoURI, isUserLocation: isUserLocation)) actionsSubject.send(.selectedLocation(geoURI, isUserLocation: isUserLocation))
} }

View File

@ -40,7 +40,8 @@ final class QRCodeLoginScreenCoordinator: CoordinatorProtocol {
} }
init(parameters: QRCodeLoginScreenCoordinatorParameters) { init(parameters: QRCodeLoginScreenCoordinatorParameters) {
viewModel = QRCodeLoginScreenViewModel(qrCodeLoginService: parameters.qrCodeLoginService) viewModel = QRCodeLoginScreenViewModel(qrCodeLoginService: parameters.qrCodeLoginService,
application: UIApplication.shared)
orientationManager = parameters.orientationManager orientationManager = parameters.orientationManager
} }

View File

@ -23,7 +23,7 @@ enum QRCodeLoginScreenViewModelAction {
struct QRCodeLoginScreenViewState: BindableState { struct QRCodeLoginScreenViewState: BindableState {
var state: QRCodeLoginState = .initial var state: QRCodeLoginState = .initial
private let listItem3AttributedText = { private static let initialStateListItem3AttributedText = {
let boldPlaceholder = "{bold}" let boldPlaceholder = "{bold}"
var finalString = AttributedString(L10n.screenQrCodeLoginInitialStateItem3(boldPlaceholder)) var finalString = AttributedString(L10n.screenQrCodeLoginInitialStateItem3(boldPlaceholder))
var boldString = AttributedString(L10n.screenQrCodeLoginInitialStateItem3Action) var boldString = AttributedString(L10n.screenQrCodeLoginInitialStateItem3Action)
@ -32,7 +32,7 @@ struct QRCodeLoginScreenViewState: BindableState {
return finalString return finalString
}() }()
private let listItem4AttributedText = { private static let initialStateListItem4AttributedText = {
let boldPlaceholder = "{bold}" let boldPlaceholder = "{bold}"
var finalString = AttributedString(L10n.screenQrCodeLoginInitialStateItem4(boldPlaceholder)) var finalString = AttributedString(L10n.screenQrCodeLoginInitialStateItem4(boldPlaceholder))
var boldString = AttributedString(L10n.screenQrCodeLoginInitialStateItem4Action) var boldString = AttributedString(L10n.screenQrCodeLoginInitialStateItem4Action)
@ -41,19 +41,24 @@ struct QRCodeLoginScreenViewState: BindableState {
return finalString return finalString
}() }()
var listItems: [AttributedString] { let initialStateListItems = [
[ AttributedString(L10n.screenQrCodeLoginInitialStateItem1(InfoPlistReader.main.productionAppName)),
AttributedString(L10n.screenQrCodeLoginInitialStateItem1(InfoPlistReader.main.productionAppName)), AttributedString(L10n.screenQrCodeLoginInitialStateItem2),
AttributedString(L10n.screenQrCodeLoginInitialStateItem2), initialStateListItem3AttributedText,
listItem3AttributedText, initialStateListItem4AttributedText
listItem4AttributedText ]
]
} let connectionNotSecureListItems = [
AttributedString(L10n.screenQrCodeLoginConnectionNoteSecureStateListItem1),
AttributedString(L10n.screenQrCodeLoginConnectionNoteSecureStateListItem2),
AttributedString(L10n.screenQrCodeLoginConnectionNoteSecureStateListItem3)
]
} }
enum QRCodeLoginScreenViewAction { enum QRCodeLoginScreenViewAction {
case cancel case cancel
case startScan case startScan
case openSettings
} }
enum QRCodeLoginState: Equatable { enum QRCodeLoginState: Equatable {
@ -66,6 +71,8 @@ enum QRCodeLoginState: Equatable {
enum QRCodeLoginErrorState: Equatable { enum QRCodeLoginErrorState: Equatable {
case noCameraPermission case noCameraPermission
case connectionNotSecure
case unknown
} }
enum QRCodeLoginScanningState: Equatable { enum QRCodeLoginScanningState: Equatable {

View File

@ -22,14 +22,17 @@ typealias QRCodeLoginScreenViewModelType = StateStoreViewModel<QRCodeLoginScreen
class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScreenViewModelProtocol { class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScreenViewModelProtocol {
private let qrCodeLoginService: QRCodeLoginServiceProtocol private let qrCodeLoginService: QRCodeLoginServiceProtocol
private let application: ApplicationProtocol
private let actionsSubject: PassthroughSubject<QRCodeLoginScreenViewModelAction, Never> = .init() private let actionsSubject: PassthroughSubject<QRCodeLoginScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<QRCodeLoginScreenViewModelAction, Never> { var actionsPublisher: AnyPublisher<QRCodeLoginScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher() actionsSubject.eraseToAnyPublisher()
} }
init(qrCodeLoginService: QRCodeLoginServiceProtocol) { init(qrCodeLoginService: QRCodeLoginServiceProtocol,
application: ApplicationProtocol) {
self.qrCodeLoginService = qrCodeLoginService self.qrCodeLoginService = qrCodeLoginService
self.application = application
super.init(initialViewState: QRCodeLoginScreenViewState()) super.init(initialViewState: QRCodeLoginScreenViewState())
} }
@ -41,6 +44,8 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
actionsSubject.send(.cancel) actionsSubject.send(.cancel)
case .startScan: case .startScan:
Task { await startScanIfPossible() } Task { await startScanIfPossible() }
case .openSettings:
application.openAppSettings()
} }
} }
@ -51,6 +56,7 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
/// Only for mocking initial states /// Only for mocking initial states
fileprivate init(state: QRCodeLoginState) { fileprivate init(state: QRCodeLoginState) {
qrCodeLoginService = QRCodeLoginServiceMock(configuration: .init()) qrCodeLoginService = QRCodeLoginServiceMock(configuration: .init())
application = ApplicationMock()
super.init(initialViewState: .init(state: state)) super.init(initialViewState: .init(state: state))
} }
} }

View File

@ -40,8 +40,7 @@ struct QRCodeLoginScreen: View {
case .scan: case .scan:
qrScanContent qrScanContent
case .error: case .error:
// TODO: Handle error states errorContent
EmptyView()
} }
} }
@ -58,7 +57,7 @@ struct QRCodeLoginScreen: View {
} }
.padding(.horizontal, 24) .padding(.horizontal, 24)
SFNumberedListView(items: context.viewState.listItems) SFNumberedListView(items: context.viewState.initialStateListItems)
} }
} bottomContent: { } bottomContent: {
Button(L10n.actionContinue) { Button(L10n.actionContinue) {
@ -107,7 +106,7 @@ struct QRCodeLoginScreen: View {
case .invalid: case .invalid:
VStack(spacing: 16) { VStack(spacing: 16) {
Button(L10n.screenQrCodeLoginInvalidScanStateRetryButton) { Button(L10n.screenQrCodeLoginInvalidScanStateRetryButton) {
// TODO: Implement try again context.send(viewAction: .startScan)
} }
.buttonStyle(.compound(.primary)) .buttonStyle(.compound(.primary))
@ -125,7 +124,7 @@ struct QRCodeLoginScreen: View {
} }
} }
} }
private var qrScanner: some View { private var qrScanner: some View {
QRCodeScannerView() QRCodeScannerView()
.aspectRatio(1.0, contentMode: .fill) .aspectRatio(1.0, contentMode: .fill)
@ -136,7 +135,7 @@ struct QRCodeLoginScreen: View {
QRScannerViewOverlay(length: qrFrame.height) QRScannerViewOverlay(length: qrFrame.height)
) )
} }
@ToolbarContentBuilder @ToolbarContentBuilder
private var toolbar: some ToolbarContent { private var toolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
@ -145,6 +144,98 @@ struct QRCodeLoginScreen: View {
} }
} }
} }
@ViewBuilder
private var errorContent: some View {
if case let .error(errorState) = context.viewState.state {
FullscreenDialog {
errorContentHeader(errorState: errorState)
} bottomContent: {
errorContentFooter(errorState: errorState)
}
.padding(.horizontal, 24)
}
}
@ViewBuilder
private func errorContentHeader(errorState: QRCodeLoginState.QRCodeLoginErrorState) -> some View {
switch errorState {
case .noCameraPermission:
VStack(spacing: 16) {
HeroImage(icon: \.takePhotoSolid, style: .subtle)
VStack(spacing: 8) {
Text(L10n.screenQrCodeLoginNoCameraPermissionStateTitle)
.foregroundColor(.compound.textPrimary)
.font(.compound.headingMDBold)
.multilineTextAlignment(.center)
Text(L10n.screenQrCodeLoginNoCameraPermissionStateDescription)
.foregroundColor(.compound.textSecondary)
.font(.compound.bodyMD)
.multilineTextAlignment(.center)
}
}
case .connectionNotSecure:
VStack(spacing: 40) {
VStack(spacing: 16) {
HeroImage(icon: \.error, style: .critical)
VStack(spacing: 8) {
Text(L10n.screenQrCodeLoginConnectionNoteSecureStateTitle)
.foregroundColor(.compound.textPrimary)
.font(.compound.headingMDBold)
.multilineTextAlignment(.center)
Text(L10n.screenQrCodeLoginConnectionNoteSecureStateDescription)
.foregroundColor(.compound.textSecondary)
.font(.compound.bodyMD)
.multilineTextAlignment(.center)
}
}
VStack(spacing: 24) {
Text(L10n.screenQrCodeLoginConnectionNoteSecureStateListHeader)
.foregroundColor(.compound.textPrimary)
.font(.compound.bodyLGSemibold)
.multilineTextAlignment(.center)
SFNumberedListView(items: context.viewState.connectionNotSecureListItems)
}
}
case .unknown:
VStack(spacing: 16) {
HeroImage(icon: \.error, style: .critical)
VStack(spacing: 8) {
Text(L10n.commonSomethingWentWrong)
.foregroundColor(.compound.textPrimary)
.font(.compound.headingMDBold)
.multilineTextAlignment(.center)
Text(L10n.screenQrCodeLoginUnknownErrorDescription)
.foregroundColor(.compound.textSecondary)
.font(.compound.bodyMD)
.multilineTextAlignment(.center)
}
}
}
}
private func errorContentFooter(errorState: QRCodeLoginState.QRCodeLoginErrorState) -> some View {
switch errorState {
case .noCameraPermission:
Button(L10n.screenQrCodeLoginNoCameraPermissionButton) {
context.send(viewAction: .openSettings)
}
.buttonStyle(.compound(.primary))
case .connectionNotSecure, .unknown:
Button(L10n.screenQrCodeLoginStartOverButton) {
context.send(viewAction: .startScan)
}
.buttonStyle(.compound(.primary))
}
}
} }
private struct QRScannerViewOverlay: View { private struct QRScannerViewOverlay: View {
@ -183,6 +274,12 @@ struct QRCodeLoginScreen_Previews: PreviewProvider, TestablePreview {
static let invalidStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.invalid)) static let invalidStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.invalid))
static let noCameraPermissionStateViewModel = QRCodeLoginScreenViewModel.mock(state: .error(.noCameraPermission))
static let connectionNotSecureStateViewModel = QRCodeLoginScreenViewModel.mock(state: .error(.connectionNotSecure))
static let unknownErrorStateViewModel = QRCodeLoginScreenViewModel.mock(state: .error(.unknown))
static var previews: some View { static var previews: some View {
QRCodeLoginScreen(context: initialStateViewModel.context) QRCodeLoginScreen(context: initialStateViewModel.context)
.previewDisplayName("Initial") .previewDisplayName("Initial")
@ -195,5 +292,14 @@ struct QRCodeLoginScreen_Previews: PreviewProvider, TestablePreview {
QRCodeLoginScreen(context: invalidStateViewModel.context) QRCodeLoginScreen(context: invalidStateViewModel.context)
.previewDisplayName("Invalid") .previewDisplayName("Invalid")
QRCodeLoginScreen(context: noCameraPermissionStateViewModel.context)
.previewDisplayName("No Camera Permission")
QRCodeLoginScreen(context: connectionNotSecureStateViewModel.context)
.previewDisplayName("Connection not secure")
QRCodeLoginScreen(context: unknownErrorStateViewModel.context)
.previewDisplayName("Unknown error")
} }
} }

View File

@ -619,8 +619,7 @@ class RoomScreenInteractionHandler {
} }
private func openSystemSettings() { private func openSystemSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return } application.openAppSettings()
application.open(url)
} }
private func displayMediaActionIfPossible(timelineItem: RoomTimelineItemProtocol) async -> RoomTimelineControllerAction { private func displayMediaActionIfPossible(timelineItem: RoomTimelineItemProtocol) async -> RoomTimelineControllerAction {

View File

@ -24,6 +24,8 @@ protocol ApplicationProtocol {
func endBackgroundTask(_ identifier: UIBackgroundTaskIdentifier) func endBackgroundTask(_ identifier: UIBackgroundTaskIdentifier)
func open(_ url: URL) func open(_ url: URL)
func openAppSettings()
var backgroundTimeRemaining: TimeInterval { get } var backgroundTimeRemaining: TimeInterval { get }
@ -34,4 +36,11 @@ extension UIApplication: ApplicationProtocol {
func open(_ url: URL) { func open(_ url: URL) {
open(url, options: [:], completionHandler: nil) open(url, options: [:], completionHandler: nil)
} }
func openAppSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else {
return
}
open(url)
}
} }