- new layout for the scan screen

[#988] Scan UI

- new layout and design for scan
- camera not authorized case + UI
- scan store refactored to the latest TCA
- unit test fixed

[#988] Scan UI (#1069)

- changelog update
This commit is contained in:
Lukas Korba 2024-02-17 12:04:55 +01:00
parent 32b7afb867
commit 5a96727b01
20 changed files with 337 additions and 260 deletions

View File

@ -8,6 +8,7 @@ directly impact users rather than highlighting other crucial architectural updat
### Changed
- Settings screen options have been reduced and some were moved to the new Advanced Settings screen.
- Scan of QR codes has been re-worked with new design and behaviours.
### Added
- Pending values (changes) at the Balances tab.

View File

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1520"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "Scan"
BuildableName = "Scan"
BlueprintName = "Scan"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "Scan"
BuildableName = "Scan"
BlueprintName = "Scan"
ReferencedContainer = "container:">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -16,11 +16,13 @@ extension DependencyValues {
public struct CaptureDeviceClient {
public enum CaptureDeviceClientError: Error {
case captureDeviceFailed
case lockForConfigurationFailed
case authorizationStatus
case captureDevice
case lockForConfiguration
case torchUnavailable
}
public let isTorchAvailable: () throws -> Bool
public let isAuthorized: () -> Bool
public let isTorchAvailable: () -> Bool
public let torch: (Bool) throws -> Void
}

View File

@ -10,16 +10,19 @@ import ComposableArchitecture
extension CaptureDeviceClient: DependencyKey {
public static let liveValue = Self(
isAuthorized: {
AVCaptureDevice.authorizationStatus(for: .video) == .authorized
},
isTorchAvailable: {
guard let videoCaptureDevice = AVCaptureDevice.default(for: .video) else {
throw CaptureDeviceClientError.captureDeviceFailed
return false
}
return videoCaptureDevice.hasTorch
},
torch: { isTorchOn in
guard let videoCaptureDevice = AVCaptureDevice.default(for: .video) else {
throw CaptureDeviceClientError.captureDeviceFailed
throw CaptureDeviceClientError.captureDevice
}
guard videoCaptureDevice.hasTorch else {
@ -31,7 +34,7 @@ extension CaptureDeviceClient: DependencyKey {
videoCaptureDevice.torchMode = isTorchOn ? .on : .off
videoCaptureDevice.unlockForConfiguration()
} catch {
throw CaptureDeviceClientError.lockForConfigurationFailed
throw CaptureDeviceClientError.lockForConfiguration
}
}
)

View File

@ -10,6 +10,7 @@ import XCTestDynamicOverlay
extension CaptureDeviceClient: TestDependencyKey {
public static let testValue = Self(
isAuthorized: XCTUnimplemented("\(Self.self).isAuthorized", placeholder: false),
isTorchAvailable: XCTUnimplemented("\(Self.self).isTorchAvailable", placeholder: false),
torch: XCTUnimplemented("\(Self.self).torch")
)
@ -17,6 +18,7 @@ extension CaptureDeviceClient: TestDependencyKey {
extension CaptureDeviceClient {
public static let noOp = Self(
isAuthorized: { false },
isTorchAvailable: { false },
torch: { _ in }
)

View File

@ -31,7 +31,7 @@ public struct HomeReducer: Reducer {
public var canRequestReview = false
public var isRestoringWallet = false
public var requiredTransactionConfirmations = 0
public var scanState: ScanReducer.State
public var scanState: Scan.State
public var shieldedBalance: Zatoshi
public var synchronizerStatusSnapshot: SyncStatusSnapshot
public var syncProgressState: SyncProgressReducer.State
@ -55,7 +55,7 @@ public struct HomeReducer: Reducer {
canRequestReview: Bool = false,
isRestoringWallet: Bool = false,
requiredTransactionConfirmations: Int = 0,
scanState: ScanReducer.State,
scanState: Scan.State,
shieldedBalance: Zatoshi,
synchronizerStatusSnapshot: SyncStatusSnapshot,
syncProgressState: SyncProgressReducer.State,

View File

@ -42,7 +42,7 @@ public struct SandboxView: View {
case .recoveryPhraseDisplay:
RecoveryPhraseDisplayView(store: RecoveryPhraseDisplay.placeholder)
case .scan:
ScanView(store: .placeholder)
ScanView(store: Scan.placeholder)
}
}

View File

@ -1,5 +1,5 @@
//
// ScanUIView.swift
// Scan.swift
// secant-testnet
//
// Created by Lukáš Korba on 16.05.2022.
@ -14,47 +14,24 @@ import ZcashLightClientKit
import Generated
import ZcashSDKEnvironment
public typealias ScanStore = Store<ScanReducer.State, ScanReducer.Action>
public typealias ScanViewStore = ViewStore<ScanReducer.State, ScanReducer.Action>
public struct ScanReducer: Reducer {
@Reducer
public struct Scan {
private enum CancelId { case timer }
@ObservableState
public struct State: Equatable {
public enum ScanStatus: Equatable {
case failed
case value(RedactableString)
case unknown
}
@PresentationState public var alert: AlertState<Action>?
public var info = ""
public var isTorchAvailable = false
public var isTorchOn = false
public var scanStatus: ScanStatus = .unknown
public var scannedValue: String? {
guard case let .value(scannedValue) = scanStatus else {
return nil
}
return scannedValue.data
}
public var isValidValue: Bool {
if case .value = scanStatus {
return true
}
return false
}
public init(
info: String = "",
isTorchAvailable: Bool = false,
isTorchOn: Bool = false,
scanStatus: ScanStatus = .unknown
isTorchOn: Bool = false
) {
self.info = info
self.isTorchAvailable = isTorchAvailable
self.isTorchOn = isTorchOn
self.scanStatus = scanStatus
}
}
@ -64,7 +41,8 @@ public struct ScanReducer: Reducer {
@Dependency(\.zcashSDKEnvironment) var zcashSDKEnvironment
public enum Action: Equatable {
case alert(PresentationAction<Action>)
case cancelPressed
case clearInfo
case onAppear
case onDisappear
case found(RedactableString)
@ -79,98 +57,54 @@ public struct ScanReducer: Reducer {
public var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .alert(.presented(let action)):
return Effect.send(action)
case .alert(.dismiss):
state.alert = nil
return .none
case .alert:
return .none
case .onAppear:
// reset the values
state.scanStatus = .unknown
state.isTorchOn = false
// check the torch availability
do {
state.isTorchAvailable = try captureDevice.isTorchAvailable()
} catch {
state.alert = AlertState.cantInitializeCamera(error.toZcashError())
state.isTorchAvailable = captureDevice.isTorchAvailable()
if !captureDevice.isAuthorized() {
state.info = L10n.Scan.cameraSettings
}
return .none
case .onDisappear:
return .cancel(id: CancelId.timer)
case .cancelPressed:
return .none
case .clearInfo:
state.info = ""
return .cancel(id: CancelId.timer)
case .found:
return .none
case .scanFailed:
state.scanStatus = .failed
return .none
state.info = L10n.Scan.invalidQR
return .concatenate(
Effect.cancel(id: CancelId.timer),
.run { send in
try await mainQueue.sleep(for: .seconds(3))
await send(.clearInfo)
}
.cancellable(id: CancelId.timer, cancelInFlight: true)
)
case .scan(let code):
// the logic for the same scanned code is skipped until some new code
if let prevCode = state.scannedValue, prevCode == code.data {
return .none
}
if uriParser.isValidURI(code.data, zcashSDKEnvironment.network.networkType) {
state.scanStatus = .value(code)
// once valid URI is scanned we want to start the timer to deliver the code
// any new code cancels the schedule and fires new one
return .concatenate(
Effect.cancel(id: CancelId.timer),
.run { send in
try await mainQueue.sleep(for: .seconds(1))
await send(.found(code))
}
.cancellable(id: CancelId.timer, cancelInFlight: true)
)
return .send(.found(code))
} else {
state.scanStatus = .failed
return .send(.scanFailed)
}
return .cancel(id: CancelId.timer)
case .torchPressed:
do {
try captureDevice.torch(!state.isTorchOn)
state.isTorchOn.toggle()
} catch {
state.alert = AlertState.cantInitializeCamera(error.toZcashError())
}
} catch { }
return .none
}
}
.ifLet(\.$alert, action: /Action.alert)
}
}
// MARK: Alerts
extension AlertState where Action == ScanReducer.Action {
public static func cantInitializeCamera(_ error: ZcashError) -> AlertState {
AlertState {
TextState(L10n.Scan.Alert.CantInitializeCamera.title)
} message: {
TextState(L10n.Scan.Alert.CantInitializeCamera.message(error.message, error.code.rawValue))
}
}
}
// MARK: Placeholders
extension ScanReducer.State {
public static var initial: Self {
.init()
}
}
extension ScanStore {
public static let placeholder = ScanStore(
initialState: .initial
) {
ScanReducer()
}
}

View File

@ -11,134 +11,165 @@ import Generated
import UIComponents
public struct ScanView: View {
@Environment(\.presentationMode) var presentationMode
let store: StoreOf<Scan>
let store: ScanStore
public init(store: ScanStore) {
public init(store: StoreOf<Scan>) {
self.store = store
}
public var body: some View {
WithViewStore(store, observe: { $0 }) { viewStore in
GeometryReader { proxy in
ZStack {
WithPerceptionTracking {
ZStack {
GeometryReader { proxy in
QRCodeScanView(
rectOfInterest: normalizedRectOfInterest(proxy.size),
onQRScanningDidFail: { viewStore.send(.scanFailed) },
onQRScanningSucceededWithCode: { viewStore.send(.scan($0.redacted)) }
onQRScanningDidFail: { store.send(.scanFailed) },
onQRScanningSucceededWithCode: { store.send(.scan($0.redacted)) }
)
backButton
if viewStore.isTorchAvailable {
torchButton(viewStore)
}
frameOfInterest(proxy.size)
VStack {
Spacer()
Text(L10n.Scan.info)
.padding(.bottom, 10)
if let scannedValue = viewStore.scannedValue {
Text(scannedValue)
.foregroundColor(viewStore.isValidValue ? .green : .red)
} else {
Text(L10n.Scan.scanning)
}
if store.isTorchAvailable {
torchButton(store, size: proxy.size)
}
.padding()
}
.navigationBarHidden(true)
.applyScreenBackground()
.onAppear { viewStore.send(.onAppear) }
.onDisappear { viewStore.send(.onDisappear) }
VStack {
Spacer()
Text(store.info)
.font(Font.custom("Inter", size: 14))
.foregroundColor(Asset.Colors.secondary.color)
.multilineTextAlignment(.center)
.padding(.bottom, 20)
Button(L10n.General.cancel.uppercased()) {
store.send(.cancelPressed)
}
.zcashStyle(.secondary)
.padding(.horizontal, 50)
.padding(.bottom, 70)
}
.padding(.horizontal, 30)
}
.edgesIgnoringSafeArea(.all)
.ignoresSafeArea()
.navigationBarHidden(true)
.applyScreenBackground()
.onAppear { store.send(.onAppear) }
.onDisappear { store.send(.onDisappear) }
}
.alert(store: store.scope(
state: \.$alert,
action: { .alert($0) }
))
}
}
extension ScanView {
var backButton: some View {
VStack {
HStack {
Button(action: {
presentationMode.wrappedValue.dismiss()
}, label: {
Image(systemName: "arrow.backward")
.foregroundColor(Asset.Colors.primary.color)
.font(
.custom(FontFamily.Inter.regular.name, size: 30)
)
})
.padding(.top, 10)
Spacer()
func torchButton(_ store: StoreOf<Scan>, size: CGSize) -> some View {
let center = ScanView.rectOfInterest(size).origin
let frameHalfSize = ScanView.frameSize(size) * 0.5
return Button {
store.send(.torchPressed)
} label: {
if store.isTorchOn {
Asset.Assets.torchOff.image
.renderingMode(.template)
.resizable()
.frame(width: 20, height: 20)
.tint(.white)
} else {
Asset.Assets.torchOn.image
.renderingMode(.template)
.resizable()
.frame(width: 20, height: 20)
.tint(.white)
}
.padding()
Spacer()
}
.padding()
}
func torchButton(_ viewStore: ScanViewStore) -> some View {
VStack {
HStack {
Spacer()
Button(
action: { viewStore.send(.torchPressed) },
label: {
Image(
systemName: viewStore.isTorchOn ? "lightbulb.fill" : "lightbulb.slash"
)
.foregroundColor(Asset.Colors.primary.color)
.font(
.custom(FontFamily.Inter.regular.name, size: 30)
)
}
)
.padding(.top, 10)
}
.padding()
Spacer()
}
.padding()
.position(
x: center.x + frameHalfSize - 5,
y: center.y + frameHalfSize + 20
)
}
func frameOfInterest(_ size: CGSize) -> some View {
Rectangle()
.stroke(Asset.Colors.primary.color, lineWidth: 5.0)
.frame(
width: frameSize(size),
height: frameSize(size),
alignment: .center
)
.edgesIgnoringSafeArea(.all)
.ignoresSafeArea()
.position(
x: rectOfInterest(size).origin.x,
y: rectOfInterest(size).origin.y
)
let center = ScanView.rectOfInterest(size).origin
let frameSize = ScanView.frameSize(size)
let halfSize = frameSize * 0.5
let cornersLength = 36.0
let cornersHalfLength = cornersLength * 0.5
let leadMarkColor = Color.white
return ZStack {
Color.black
.opacity(0.65)
.edgesIgnoringSafeArea(.all)
.ignoresSafeArea()
.reverseMask {
Rectangle()
.frame(
width: frameSize,
height: frameSize,
alignment: .center
)
.position(
x: center.x,
y: center.y
)
}
// horizontal lead marks
leadMarkColor
.frame(width: cornersLength, height: 1)
.position(x: center.x - halfSize + cornersHalfLength, y: center.y - halfSize)
leadMarkColor
.frame(width: cornersLength, height: 1)
.position(x: center.x + halfSize - cornersHalfLength, y: center.y - halfSize)
leadMarkColor
.frame(width: cornersLength, height: 1)
.position(x: center.x - halfSize + cornersHalfLength, y: center.y + halfSize)
leadMarkColor
.frame(width: cornersLength, height: 1)
.position(x: center.x + halfSize - cornersHalfLength, y: center.y + halfSize)
// vertical lead marks
leadMarkColor
.frame(width: 1, height: cornersLength)
.position(x: center.x - halfSize, y: center.y - halfSize + cornersHalfLength)
leadMarkColor
.frame(width: 1, height: cornersLength)
.position(x: center.x - halfSize, y: center.y + halfSize - cornersHalfLength)
leadMarkColor
.frame(width: 1, height: cornersLength)
.position(x: center.x + halfSize, y: center.y - halfSize + cornersHalfLength)
leadMarkColor
.frame(width: 1, height: cornersLength)
.position(x: center.x + halfSize, y: center.y + halfSize - cornersHalfLength)
}
}
}
extension View {
@inlinable
public func reverseMask<Mask: View>(
alignment: Alignment = .center,
@ViewBuilder _ mask: () -> Mask
) -> some View {
self.mask {
Rectangle()
.overlay(alignment: alignment) {
mask()
.blendMode(.destinationOut)
}
}
}
}
extension ScanView {
func frameSize(_ size: CGSize) -> CGFloat {
static func frameSize(_ size: CGSize) -> CGFloat {
size.width * 0.55
}
func rectOfInterest(_ size: CGSize) -> CGRect {
static func rectOfInterest(_ size: CGSize) -> CGRect {
CGRect(
x: size.width * 0.5,
y: size.height * 0.5,
@ -161,6 +192,20 @@ extension ScanView {
struct ScanView_Previews: PreviewProvider {
static var previews: some View {
ScanView(store: .placeholder)
ScanView(store: Scan.placeholder)
}
}
// MARK: Placeholders
extension Scan.State {
public static var initial = Scan.State()
}
extension Scan {
public static let placeholder = StoreOf<Scan>(
initialState: .initial
) {
Scan()
}
}

View File

@ -37,7 +37,7 @@ public struct SendFlowReducer: Reducer {
public var destination: Destination?
public var isSending = false
public var memoState: MessageEditorReducer.State
public var scanState: ScanReducer.State
public var scanState: Scan.State
public var shieldedBalance = Zatoshi.zero
public var totalBalance = Zatoshi.zero
public var transactionAddressInputState: TransactionAddressTextFieldReducer.State
@ -106,7 +106,7 @@ public struct SendFlowReducer: Reducer {
addMemoState: Bool,
destination: Destination? = nil,
memoState: MessageEditorReducer.State,
scanState: ScanReducer.State,
scanState: Scan.State,
shieldedBalance: Zatoshi = .zero,
totalBalance: Zatoshi = .zero,
transactionAddressInputState: TransactionAddressTextFieldReducer.State,
@ -130,7 +130,7 @@ public struct SendFlowReducer: Reducer {
case onAppear
case onDisappear
case reviewPressed
case scan(ScanReducer.Action)
case scan(Scan.Action)
case sendPressed
case sendDone(TransactionState)
case sendFailed(ZcashError)
@ -164,7 +164,7 @@ public struct SendFlowReducer: Reducer {
}
Scope(state: \.scanState, action: /Action.scan) {
ScanReducer()
Scan()
}
Reduce { state, action in
@ -283,6 +283,10 @@ public struct SendFlowReducer: Reducer {
audioServices.systemSoundVibrate()
return Effect.send(.updateDestination(nil))
case .scan(.cancelPressed):
state.destination = nil
return .none
case .scan:
return .none
}
@ -312,7 +316,7 @@ extension SendFlowStore {
)
}
func scanStore() -> ScanStore {
func scanStore() -> StoreOf<Scan> {
self.scope(
state: \.scanState,
action: SendFlowReducer.Action.scan

View File

@ -111,19 +111,14 @@ public struct ServerSetupView: View {
#Preview {
ServerSetupView(
store: .init(
initialState:
ServerSetup.State(server: .custom)
) {
ServerSetup()
}
store: ServerSetup.placeholder
)
}
// MARK: Placeholders
extension ServerSetup.State {
public static let initial = ServerSetup.State()
public static var initial = ServerSetup.State()
}
extension ServerSetup {

View File

@ -477,20 +477,10 @@ public enum L10n {
}
}
public enum Scan {
/// We will validate any Zcash URI and take you to the appropriate action.
public static let info = L10n.tr("Localizable", "scan.info", fallback: "We will validate any Zcash URI and take you to the appropriate action.")
/// Scanning...
public static let scanning = L10n.tr("Localizable", "scan.scanning", fallback: "Scanning...")
public enum Alert {
public enum CantInitializeCamera {
/// Error: %@ (code: %@)
public static func message(_ p1: Any, _ p2: Any) -> String {
return L10n.tr("Localizable", "scan.alert.cantInitializeCamera.message", String(describing: p1), String(describing: p2), fallback: "Error: %@ (code: %@)")
}
/// Can't initialize the camera
public static let title = L10n.tr("Localizable", "scan.alert.cantInitializeCamera.title", fallback: "Can't initialize the camera")
}
}
/// The camera is not authorized. Please go to the system settings of Zashi and turn it on.
public static let cameraSettings = L10n.tr("Localizable", "scan.cameraSettings", fallback: "The camera is not authorized. Please go to the system settings of Zashi and turn it on.")
/// This QR code doesn't hold a valid Zcash address.
public static let invalidQR = L10n.tr("Localizable", "scan.invalidQR", fallback: "This QR code doesn't hold a valid Zcash address.")
}
public enum SecurityWarning {
/// I acknowledge

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "torchOff.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "torchOn.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

@ -130,10 +130,8 @@
"balances.alert.shieldFunds.failure.message" = "Error: %@ (code: %@)";
// MARK: - Scan
"scan.info" = "We will validate any Zcash URI and take you to the appropriate action.";
"scan.scanning" = "Scanning...";
"scan.alert.cantInitializeCamera.title" = "Can't initialize the camera";
"scan.alert.cantInitializeCamera.message" = "Error: %@ (code: %@)";
"scan.invalidQR" = "This QR code doesn't hold a valid Zcash address.";
"scan.cameraSettings" = "The camera is not authorized. Please go to the system settings of Zashi and turn it on.";
// MARK: - Send
"send.title" = "Send Zcash";

View File

@ -34,6 +34,8 @@ public enum Asset {
public static let share = ImageAsset(name: "share")
public static let shield = ImageAsset(name: "shield")
public static let surroundedShield = ImageAsset(name: "surroundedShield")
public static let torchOff = ImageAsset(name: "torchOff")
public static let torchOn = ImageAsset(name: "torchOn")
public static let upArrow = ImageAsset(name: "upArrow")
public static let zashiTitle = ImageAsset(name: "zashiTitle")
}

View File

@ -28,7 +28,7 @@ public struct ZcashButtonStyle: ButtonStyle {
: isEnabled ? Asset.Colors.primary.color : Asset.Colors.shade72.color
)
.frame(height: height)
.border(isEnabled ? Asset.Colors.primary.color : Asset.Colors.shade72.color)
.border(appearance == .secondary && isEnabled ? Asset.Colors.secondary.color : Asset.Colors.primary.color)
.offset(CGSize(width: shadowOffset, height: shadowOffset))
Rectangle()

View File

@ -9,6 +9,7 @@ import XCTest
import ComposableArchitecture
import ZcashLightClientKit
import Scan
import Generated
@testable import secant_testnet
@MainActor
@ -16,13 +17,12 @@ class ScanTests: XCTestCase {
func testOnAppearResetValues() async throws {
let store = TestStore(
initialState:
ScanReducer.State(
Scan.State(
isTorchAvailable: true,
isTorchOn: true,
scanStatus: .value("t1gXqfSSQt6WfpwyuCU3Wi7sSVZ66DYQ3Po".redacted)
isTorchOn: true
)
) {
ScanReducer()
Scan()
}
store.dependencies.captureDevice = .noOp
@ -30,7 +30,7 @@ class ScanTests: XCTestCase {
await store.send(.onAppear) { state in
state.isTorchAvailable = false
state.isTorchOn = false
state.scanStatus = .unknown
state.info = L10n.Scan.cameraSettings
}
await store.finish()
@ -38,9 +38,9 @@ class ScanTests: XCTestCase {
func testTorchOn() async throws {
let store = TestStore(
initialState: ScanReducer.State()
initialState: Scan.State()
) {
ScanReducer()
Scan()
}
store.dependencies.captureDevice = .noOp
@ -54,11 +54,11 @@ class ScanTests: XCTestCase {
func testTorchOff() async throws {
let store = TestStore(
initialState: ScanReducer.State(
initialState: Scan.State(
isTorchOn: true
)
) {
ScanReducer()
Scan()
}
store.dependencies.captureDevice = .noOp
@ -72,27 +72,34 @@ class ScanTests: XCTestCase {
func testScannedInvalidValue() async throws {
let store = TestStore(
initialState: ScanReducer.State()
initialState: Scan.State()
) {
ScanReducer()
Scan()
}
store.dependencies.uriParser.isValidURI = { _, _ in false }
store.dependencies.mainQueue = .immediate
let value = "test".redacted
await store.send(.scan(value)) { state in
state.scanStatus = .failed
await store.send(.scan(value))
await store.receive(.scanFailed) { state in
state.info = L10n.Scan.invalidQR
}
await store.receive(.clearInfo) { state in
state.info = ""
}
await store.finish()
}
@MainActor func testScannedValidAddress() async throws {
func testScannedValidAddress() async throws {
let store = TestStore(
initialState: ScanReducer.State()
initialState: Scan.State()
) {
ScanReducer()
Scan()
}
store.dependencies.mainQueue = .immediate
@ -100,9 +107,7 @@ class ScanTests: XCTestCase {
let address = "t1gXqfSSQt6WfpwyuCU3Wi7sSVZ66DYQ3Po".redacted
await store.send(.scan(address)) { state in
state.scanStatus = .value(address)
}
await store.send(.scan(address))
await store.receive(.found(address))
@ -111,13 +116,19 @@ class ScanTests: XCTestCase {
func testScanFailed() async throws {
let store = TestStore(
initialState: ScanReducer.State()
initialState: Scan.State()
) {
ScanReducer()
Scan()
}
store.dependencies.mainQueue = .immediate
await store.send(.scanFailed) { state in
state.scanStatus = .failed
state.info = L10n.Scan.invalidQR
}
await store.receive(.clearInfo) { state in
state.info = ""
}
await store.finish()