[#988] Scan UI
- 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:
parent
32b7afb867
commit
5a96727b01
|
@ -8,6 +8,7 @@ directly impact users rather than highlighting other crucial architectural updat
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Settings screen options have been reduced and some were moved to the new Advanced Settings screen.
|
- 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
|
### Added
|
||||||
- Pending values (changes) at the Balances tab.
|
- Pending values (changes) at the Balances tab.
|
||||||
|
|
|
@ -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>
|
|
@ -16,11 +16,13 @@ extension DependencyValues {
|
||||||
|
|
||||||
public struct CaptureDeviceClient {
|
public struct CaptureDeviceClient {
|
||||||
public enum CaptureDeviceClientError: Error {
|
public enum CaptureDeviceClientError: Error {
|
||||||
case captureDeviceFailed
|
case authorizationStatus
|
||||||
case lockForConfigurationFailed
|
case captureDevice
|
||||||
|
case lockForConfiguration
|
||||||
case torchUnavailable
|
case torchUnavailable
|
||||||
}
|
}
|
||||||
|
|
||||||
public let isTorchAvailable: () throws -> Bool
|
public let isAuthorized: () -> Bool
|
||||||
|
public let isTorchAvailable: () -> Bool
|
||||||
public let torch: (Bool) throws -> Void
|
public let torch: (Bool) throws -> Void
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,16 +10,19 @@ import ComposableArchitecture
|
||||||
|
|
||||||
extension CaptureDeviceClient: DependencyKey {
|
extension CaptureDeviceClient: DependencyKey {
|
||||||
public static let liveValue = Self(
|
public static let liveValue = Self(
|
||||||
|
isAuthorized: {
|
||||||
|
AVCaptureDevice.authorizationStatus(for: .video) == .authorized
|
||||||
|
},
|
||||||
isTorchAvailable: {
|
isTorchAvailable: {
|
||||||
guard let videoCaptureDevice = AVCaptureDevice.default(for: .video) else {
|
guard let videoCaptureDevice = AVCaptureDevice.default(for: .video) else {
|
||||||
throw CaptureDeviceClientError.captureDeviceFailed
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return videoCaptureDevice.hasTorch
|
return videoCaptureDevice.hasTorch
|
||||||
},
|
},
|
||||||
torch: { isTorchOn in
|
torch: { isTorchOn in
|
||||||
guard let videoCaptureDevice = AVCaptureDevice.default(for: .video) else {
|
guard let videoCaptureDevice = AVCaptureDevice.default(for: .video) else {
|
||||||
throw CaptureDeviceClientError.captureDeviceFailed
|
throw CaptureDeviceClientError.captureDevice
|
||||||
}
|
}
|
||||||
|
|
||||||
guard videoCaptureDevice.hasTorch else {
|
guard videoCaptureDevice.hasTorch else {
|
||||||
|
@ -31,7 +34,7 @@ extension CaptureDeviceClient: DependencyKey {
|
||||||
videoCaptureDevice.torchMode = isTorchOn ? .on : .off
|
videoCaptureDevice.torchMode = isTorchOn ? .on : .off
|
||||||
videoCaptureDevice.unlockForConfiguration()
|
videoCaptureDevice.unlockForConfiguration()
|
||||||
} catch {
|
} catch {
|
||||||
throw CaptureDeviceClientError.lockForConfigurationFailed
|
throw CaptureDeviceClientError.lockForConfiguration
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
@ -10,6 +10,7 @@ import XCTestDynamicOverlay
|
||||||
|
|
||||||
extension CaptureDeviceClient: TestDependencyKey {
|
extension CaptureDeviceClient: TestDependencyKey {
|
||||||
public static let testValue = Self(
|
public static let testValue = Self(
|
||||||
|
isAuthorized: XCTUnimplemented("\(Self.self).isAuthorized", placeholder: false),
|
||||||
isTorchAvailable: XCTUnimplemented("\(Self.self).isTorchAvailable", placeholder: false),
|
isTorchAvailable: XCTUnimplemented("\(Self.self).isTorchAvailable", placeholder: false),
|
||||||
torch: XCTUnimplemented("\(Self.self).torch")
|
torch: XCTUnimplemented("\(Self.self).torch")
|
||||||
)
|
)
|
||||||
|
@ -17,6 +18,7 @@ extension CaptureDeviceClient: TestDependencyKey {
|
||||||
|
|
||||||
extension CaptureDeviceClient {
|
extension CaptureDeviceClient {
|
||||||
public static let noOp = Self(
|
public static let noOp = Self(
|
||||||
|
isAuthorized: { false },
|
||||||
isTorchAvailable: { false },
|
isTorchAvailable: { false },
|
||||||
torch: { _ in }
|
torch: { _ in }
|
||||||
)
|
)
|
||||||
|
|
|
@ -31,7 +31,7 @@ public struct HomeReducer: Reducer {
|
||||||
public var canRequestReview = false
|
public var canRequestReview = false
|
||||||
public var isRestoringWallet = false
|
public var isRestoringWallet = false
|
||||||
public var requiredTransactionConfirmations = 0
|
public var requiredTransactionConfirmations = 0
|
||||||
public var scanState: ScanReducer.State
|
public var scanState: Scan.State
|
||||||
public var shieldedBalance: Zatoshi
|
public var shieldedBalance: Zatoshi
|
||||||
public var synchronizerStatusSnapshot: SyncStatusSnapshot
|
public var synchronizerStatusSnapshot: SyncStatusSnapshot
|
||||||
public var syncProgressState: SyncProgressReducer.State
|
public var syncProgressState: SyncProgressReducer.State
|
||||||
|
@ -55,7 +55,7 @@ public struct HomeReducer: Reducer {
|
||||||
canRequestReview: Bool = false,
|
canRequestReview: Bool = false,
|
||||||
isRestoringWallet: Bool = false,
|
isRestoringWallet: Bool = false,
|
||||||
requiredTransactionConfirmations: Int = 0,
|
requiredTransactionConfirmations: Int = 0,
|
||||||
scanState: ScanReducer.State,
|
scanState: Scan.State,
|
||||||
shieldedBalance: Zatoshi,
|
shieldedBalance: Zatoshi,
|
||||||
synchronizerStatusSnapshot: SyncStatusSnapshot,
|
synchronizerStatusSnapshot: SyncStatusSnapshot,
|
||||||
syncProgressState: SyncProgressReducer.State,
|
syncProgressState: SyncProgressReducer.State,
|
||||||
|
|
|
@ -42,7 +42,7 @@ public struct SandboxView: View {
|
||||||
case .recoveryPhraseDisplay:
|
case .recoveryPhraseDisplay:
|
||||||
RecoveryPhraseDisplayView(store: RecoveryPhraseDisplay.placeholder)
|
RecoveryPhraseDisplayView(store: RecoveryPhraseDisplay.placeholder)
|
||||||
case .scan:
|
case .scan:
|
||||||
ScanView(store: .placeholder)
|
ScanView(store: Scan.placeholder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// ScanUIView.swift
|
// Scan.swift
|
||||||
// secant-testnet
|
// secant-testnet
|
||||||
//
|
//
|
||||||
// Created by Lukáš Korba on 16.05.2022.
|
// Created by Lukáš Korba on 16.05.2022.
|
||||||
|
@ -14,47 +14,24 @@ import ZcashLightClientKit
|
||||||
import Generated
|
import Generated
|
||||||
import ZcashSDKEnvironment
|
import ZcashSDKEnvironment
|
||||||
|
|
||||||
public typealias ScanStore = Store<ScanReducer.State, ScanReducer.Action>
|
@Reducer
|
||||||
public typealias ScanViewStore = ViewStore<ScanReducer.State, ScanReducer.Action>
|
public struct Scan {
|
||||||
|
|
||||||
public struct ScanReducer: Reducer {
|
|
||||||
private enum CancelId { case timer }
|
private enum CancelId { case timer }
|
||||||
|
|
||||||
|
@ObservableState
|
||||||
public struct State: Equatable {
|
public struct State: Equatable {
|
||||||
public enum ScanStatus: Equatable {
|
public var info = ""
|
||||||
case failed
|
|
||||||
case value(RedactableString)
|
|
||||||
case unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
@PresentationState public var alert: AlertState<Action>?
|
|
||||||
public var isTorchAvailable = false
|
public var isTorchAvailable = false
|
||||||
public var isTorchOn = 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(
|
public init(
|
||||||
|
info: String = "",
|
||||||
isTorchAvailable: Bool = false,
|
isTorchAvailable: Bool = false,
|
||||||
isTorchOn: Bool = false,
|
isTorchOn: Bool = false
|
||||||
scanStatus: ScanStatus = .unknown
|
|
||||||
) {
|
) {
|
||||||
|
self.info = info
|
||||||
self.isTorchAvailable = isTorchAvailable
|
self.isTorchAvailable = isTorchAvailable
|
||||||
self.isTorchOn = isTorchOn
|
self.isTorchOn = isTorchOn
|
||||||
self.scanStatus = scanStatus
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -64,7 +41,8 @@ public struct ScanReducer: Reducer {
|
||||||
@Dependency(\.zcashSDKEnvironment) var zcashSDKEnvironment
|
@Dependency(\.zcashSDKEnvironment) var zcashSDKEnvironment
|
||||||
|
|
||||||
public enum Action: Equatable {
|
public enum Action: Equatable {
|
||||||
case alert(PresentationAction<Action>)
|
case cancelPressed
|
||||||
|
case clearInfo
|
||||||
case onAppear
|
case onAppear
|
||||||
case onDisappear
|
case onDisappear
|
||||||
case found(RedactableString)
|
case found(RedactableString)
|
||||||
|
@ -79,98 +57,54 @@ public struct ScanReducer: Reducer {
|
||||||
public var body: some ReducerOf<Self> {
|
public var body: some ReducerOf<Self> {
|
||||||
Reduce { state, action in
|
Reduce { state, action in
|
||||||
switch action {
|
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:
|
case .onAppear:
|
||||||
// reset the values
|
// reset the values
|
||||||
state.scanStatus = .unknown
|
|
||||||
state.isTorchOn = false
|
state.isTorchOn = false
|
||||||
// check the torch availability
|
// check the torch availability
|
||||||
do {
|
state.isTorchAvailable = captureDevice.isTorchAvailable()
|
||||||
state.isTorchAvailable = try captureDevice.isTorchAvailable()
|
if !captureDevice.isAuthorized() {
|
||||||
} catch {
|
state.info = L10n.Scan.cameraSettings
|
||||||
state.alert = AlertState.cantInitializeCamera(error.toZcashError())
|
|
||||||
}
|
}
|
||||||
return .none
|
return .none
|
||||||
|
|
||||||
case .onDisappear:
|
case .onDisappear:
|
||||||
return .cancel(id: CancelId.timer)
|
return .cancel(id: CancelId.timer)
|
||||||
|
|
||||||
|
case .cancelPressed:
|
||||||
|
return .none
|
||||||
|
|
||||||
|
case .clearInfo:
|
||||||
|
state.info = ""
|
||||||
|
return .cancel(id: CancelId.timer)
|
||||||
|
|
||||||
case .found:
|
case .found:
|
||||||
return .none
|
return .none
|
||||||
|
|
||||||
case .scanFailed:
|
case .scanFailed:
|
||||||
state.scanStatus = .failed
|
state.info = L10n.Scan.invalidQR
|
||||||
return .none
|
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):
|
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) {
|
if uriParser.isValidURI(code.data, zcashSDKEnvironment.network.networkType) {
|
||||||
state.scanStatus = .value(code)
|
return .send(.found(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)
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
state.scanStatus = .failed
|
return .send(.scanFailed)
|
||||||
}
|
}
|
||||||
return .cancel(id: CancelId.timer)
|
|
||||||
|
|
||||||
case .torchPressed:
|
case .torchPressed:
|
||||||
do {
|
do {
|
||||||
try captureDevice.torch(!state.isTorchOn)
|
try captureDevice.torch(!state.isTorchOn)
|
||||||
state.isTorchOn.toggle()
|
state.isTorchOn.toggle()
|
||||||
} catch {
|
} catch { }
|
||||||
state.alert = AlertState.cantInitializeCamera(error.toZcashError())
|
|
||||||
}
|
|
||||||
return .none
|
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,134 +11,165 @@ import Generated
|
||||||
import UIComponents
|
import UIComponents
|
||||||
|
|
||||||
public struct ScanView: View {
|
public struct ScanView: View {
|
||||||
@Environment(\.presentationMode) var presentationMode
|
let store: StoreOf<Scan>
|
||||||
|
|
||||||
let store: ScanStore
|
|
||||||
|
public init(store: StoreOf<Scan>) {
|
||||||
public init(store: ScanStore) {
|
|
||||||
self.store = store
|
self.store = store
|
||||||
}
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
WithViewStore(store, observe: { $0 }) { viewStore in
|
WithPerceptionTracking {
|
||||||
GeometryReader { proxy in
|
ZStack {
|
||||||
ZStack {
|
GeometryReader { proxy in
|
||||||
QRCodeScanView(
|
QRCodeScanView(
|
||||||
rectOfInterest: normalizedRectOfInterest(proxy.size),
|
rectOfInterest: normalizedRectOfInterest(proxy.size),
|
||||||
onQRScanningDidFail: { viewStore.send(.scanFailed) },
|
onQRScanningDidFail: { store.send(.scanFailed) },
|
||||||
onQRScanningSucceededWithCode: { viewStore.send(.scan($0.redacted)) }
|
onQRScanningSucceededWithCode: { store.send(.scan($0.redacted)) }
|
||||||
)
|
)
|
||||||
|
|
||||||
backButton
|
|
||||||
|
|
||||||
if viewStore.isTorchAvailable {
|
|
||||||
torchButton(viewStore)
|
|
||||||
}
|
|
||||||
|
|
||||||
frameOfInterest(proxy.size)
|
frameOfInterest(proxy.size)
|
||||||
|
|
||||||
VStack {
|
if store.isTorchAvailable {
|
||||||
Spacer()
|
torchButton(store, size: proxy.size)
|
||||||
|
|
||||||
Text(L10n.Scan.info)
|
|
||||||
.padding(.bottom, 10)
|
|
||||||
|
|
||||||
if let scannedValue = viewStore.scannedValue {
|
|
||||||
Text(scannedValue)
|
|
||||||
.foregroundColor(viewStore.isValidValue ? .green : .red)
|
|
||||||
} else {
|
|
||||||
Text(L10n.Scan.scanning)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding()
|
|
||||||
}
|
}
|
||||||
.navigationBarHidden(true)
|
|
||||||
.applyScreenBackground()
|
VStack {
|
||||||
.onAppear { viewStore.send(.onAppear) }
|
Spacer()
|
||||||
.onDisappear { viewStore.send(.onDisappear) }
|
|
||||||
|
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()
|
.ignoresSafeArea()
|
||||||
|
.navigationBarHidden(true)
|
||||||
|
.applyScreenBackground()
|
||||||
|
.onAppear { store.send(.onAppear) }
|
||||||
|
.onDisappear { store.send(.onDisappear) }
|
||||||
}
|
}
|
||||||
.alert(store: store.scope(
|
|
||||||
state: \.$alert,
|
|
||||||
action: { .alert($0) }
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ScanView {
|
extension ScanView {
|
||||||
var backButton: some View {
|
func torchButton(_ store: StoreOf<Scan>, size: CGSize) -> some View {
|
||||||
VStack {
|
let center = ScanView.rectOfInterest(size).origin
|
||||||
HStack {
|
let frameHalfSize = ScanView.frameSize(size) * 0.5
|
||||||
Button(action: {
|
|
||||||
presentationMode.wrappedValue.dismiss()
|
return Button {
|
||||||
}, label: {
|
store.send(.torchPressed)
|
||||||
Image(systemName: "arrow.backward")
|
} label: {
|
||||||
.foregroundColor(Asset.Colors.primary.color)
|
if store.isTorchOn {
|
||||||
.font(
|
Asset.Assets.torchOff.image
|
||||||
.custom(FontFamily.Inter.regular.name, size: 30)
|
.renderingMode(.template)
|
||||||
)
|
.resizable()
|
||||||
})
|
.frame(width: 20, height: 20)
|
||||||
.padding(.top, 10)
|
.tint(.white)
|
||||||
|
} else {
|
||||||
Spacer()
|
Asset.Assets.torchOn.image
|
||||||
|
.renderingMode(.template)
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 20, height: 20)
|
||||||
|
.tint(.white)
|
||||||
}
|
}
|
||||||
.padding()
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
}
|
||||||
.padding()
|
.position(
|
||||||
}
|
x: center.x + frameHalfSize - 5,
|
||||||
|
y: center.y + frameHalfSize + 20
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func frameOfInterest(_ size: CGSize) -> some View {
|
func frameOfInterest(_ size: CGSize) -> some View {
|
||||||
Rectangle()
|
let center = ScanView.rectOfInterest(size).origin
|
||||||
.stroke(Asset.Colors.primary.color, lineWidth: 5.0)
|
let frameSize = ScanView.frameSize(size)
|
||||||
.frame(
|
let halfSize = frameSize * 0.5
|
||||||
width: frameSize(size),
|
let cornersLength = 36.0
|
||||||
height: frameSize(size),
|
let cornersHalfLength = cornersLength * 0.5
|
||||||
alignment: .center
|
let leadMarkColor = Color.white
|
||||||
)
|
|
||||||
.edgesIgnoringSafeArea(.all)
|
return ZStack {
|
||||||
.ignoresSafeArea()
|
Color.black
|
||||||
.position(
|
.opacity(0.65)
|
||||||
x: rectOfInterest(size).origin.x,
|
.edgesIgnoringSafeArea(.all)
|
||||||
y: rectOfInterest(size).origin.y
|
.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 {
|
extension ScanView {
|
||||||
func frameSize(_ size: CGSize) -> CGFloat {
|
static func frameSize(_ size: CGSize) -> CGFloat {
|
||||||
size.width * 0.55
|
size.width * 0.55
|
||||||
}
|
}
|
||||||
|
|
||||||
func rectOfInterest(_ size: CGSize) -> CGRect {
|
static func rectOfInterest(_ size: CGSize) -> CGRect {
|
||||||
CGRect(
|
CGRect(
|
||||||
x: size.width * 0.5,
|
x: size.width * 0.5,
|
||||||
y: size.height * 0.5,
|
y: size.height * 0.5,
|
||||||
|
@ -161,6 +192,20 @@ extension ScanView {
|
||||||
|
|
||||||
struct ScanView_Previews: PreviewProvider {
|
struct ScanView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,7 +37,7 @@ public struct SendFlowReducer: Reducer {
|
||||||
public var destination: Destination?
|
public var destination: Destination?
|
||||||
public var isSending = false
|
public var isSending = false
|
||||||
public var memoState: MessageEditorReducer.State
|
public var memoState: MessageEditorReducer.State
|
||||||
public var scanState: ScanReducer.State
|
public var scanState: Scan.State
|
||||||
public var shieldedBalance = Zatoshi.zero
|
public var shieldedBalance = Zatoshi.zero
|
||||||
public var totalBalance = Zatoshi.zero
|
public var totalBalance = Zatoshi.zero
|
||||||
public var transactionAddressInputState: TransactionAddressTextFieldReducer.State
|
public var transactionAddressInputState: TransactionAddressTextFieldReducer.State
|
||||||
|
@ -106,7 +106,7 @@ public struct SendFlowReducer: Reducer {
|
||||||
addMemoState: Bool,
|
addMemoState: Bool,
|
||||||
destination: Destination? = nil,
|
destination: Destination? = nil,
|
||||||
memoState: MessageEditorReducer.State,
|
memoState: MessageEditorReducer.State,
|
||||||
scanState: ScanReducer.State,
|
scanState: Scan.State,
|
||||||
shieldedBalance: Zatoshi = .zero,
|
shieldedBalance: Zatoshi = .zero,
|
||||||
totalBalance: Zatoshi = .zero,
|
totalBalance: Zatoshi = .zero,
|
||||||
transactionAddressInputState: TransactionAddressTextFieldReducer.State,
|
transactionAddressInputState: TransactionAddressTextFieldReducer.State,
|
||||||
|
@ -130,7 +130,7 @@ public struct SendFlowReducer: Reducer {
|
||||||
case onAppear
|
case onAppear
|
||||||
case onDisappear
|
case onDisappear
|
||||||
case reviewPressed
|
case reviewPressed
|
||||||
case scan(ScanReducer.Action)
|
case scan(Scan.Action)
|
||||||
case sendPressed
|
case sendPressed
|
||||||
case sendDone(TransactionState)
|
case sendDone(TransactionState)
|
||||||
case sendFailed(ZcashError)
|
case sendFailed(ZcashError)
|
||||||
|
@ -164,7 +164,7 @@ public struct SendFlowReducer: Reducer {
|
||||||
}
|
}
|
||||||
|
|
||||||
Scope(state: \.scanState, action: /Action.scan) {
|
Scope(state: \.scanState, action: /Action.scan) {
|
||||||
ScanReducer()
|
Scan()
|
||||||
}
|
}
|
||||||
|
|
||||||
Reduce { state, action in
|
Reduce { state, action in
|
||||||
|
@ -283,6 +283,10 @@ public struct SendFlowReducer: Reducer {
|
||||||
audioServices.systemSoundVibrate()
|
audioServices.systemSoundVibrate()
|
||||||
return Effect.send(.updateDestination(nil))
|
return Effect.send(.updateDestination(nil))
|
||||||
|
|
||||||
|
case .scan(.cancelPressed):
|
||||||
|
state.destination = nil
|
||||||
|
return .none
|
||||||
|
|
||||||
case .scan:
|
case .scan:
|
||||||
return .none
|
return .none
|
||||||
}
|
}
|
||||||
|
@ -312,7 +316,7 @@ extension SendFlowStore {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanStore() -> ScanStore {
|
func scanStore() -> StoreOf<Scan> {
|
||||||
self.scope(
|
self.scope(
|
||||||
state: \.scanState,
|
state: \.scanState,
|
||||||
action: SendFlowReducer.Action.scan
|
action: SendFlowReducer.Action.scan
|
||||||
|
|
|
@ -111,19 +111,14 @@ public struct ServerSetupView: View {
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
ServerSetupView(
|
ServerSetupView(
|
||||||
store: .init(
|
store: ServerSetup.placeholder
|
||||||
initialState:
|
|
||||||
ServerSetup.State(server: .custom)
|
|
||||||
) {
|
|
||||||
ServerSetup()
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Placeholders
|
// MARK: Placeholders
|
||||||
|
|
||||||
extension ServerSetup.State {
|
extension ServerSetup.State {
|
||||||
public static let initial = ServerSetup.State()
|
public static var initial = ServerSetup.State()
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ServerSetup {
|
extension ServerSetup {
|
||||||
|
|
|
@ -477,20 +477,10 @@ public enum L10n {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
public enum Scan {
|
public enum Scan {
|
||||||
/// We will validate any Zcash URI and take you to the appropriate action.
|
/// The camera is not authorized. Please go to the system settings of Zashi and turn it on.
|
||||||
public static let info = L10n.tr("Localizable", "scan.info", fallback: "We will validate any Zcash URI and take you to the appropriate action.")
|
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.")
|
||||||
/// Scanning...
|
/// This QR code doesn't hold a valid Zcash address.
|
||||||
public static let scanning = L10n.tr("Localizable", "scan.scanning", fallback: "Scanning...")
|
public static let invalidQR = L10n.tr("Localizable", "scan.invalidQR", fallback: "This QR code doesn't hold a valid Zcash address.")
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
public enum SecurityWarning {
|
public enum SecurityWarning {
|
||||||
/// I acknowledge
|
/// I acknowledge
|
||||||
|
|
12
modules/Sources/Generated/Resources/Assets.xcassets/torchOff.imageset/Contents.json
vendored
Normal file
12
modules/Sources/Generated/Resources/Assets.xcassets/torchOff.imageset/Contents.json
vendored
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "torchOff.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
BIN
modules/Sources/Generated/Resources/Assets.xcassets/torchOff.imageset/torchOff.png
vendored
Normal file
BIN
modules/Sources/Generated/Resources/Assets.xcassets/torchOff.imageset/torchOff.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.6 KiB |
12
modules/Sources/Generated/Resources/Assets.xcassets/torchOn.imageset/Contents.json
vendored
Normal file
12
modules/Sources/Generated/Resources/Assets.xcassets/torchOn.imageset/Contents.json
vendored
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "torchOn.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
BIN
modules/Sources/Generated/Resources/Assets.xcassets/torchOn.imageset/torchOn.png
vendored
Normal file
BIN
modules/Sources/Generated/Resources/Assets.xcassets/torchOn.imageset/torchOn.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.9 KiB |
|
@ -130,10 +130,8 @@
|
||||||
"balances.alert.shieldFunds.failure.message" = "Error: %@ (code: %@)";
|
"balances.alert.shieldFunds.failure.message" = "Error: %@ (code: %@)";
|
||||||
|
|
||||||
// MARK: - Scan
|
// MARK: - Scan
|
||||||
"scan.info" = "We will validate any Zcash URI and take you to the appropriate action.";
|
"scan.invalidQR" = "This QR code doesn't hold a valid Zcash address.";
|
||||||
"scan.scanning" = "Scanning...";
|
"scan.cameraSettings" = "The camera is not authorized. Please go to the system settings of Zashi and turn it on.";
|
||||||
"scan.alert.cantInitializeCamera.title" = "Can't initialize the camera";
|
|
||||||
"scan.alert.cantInitializeCamera.message" = "Error: %@ (code: %@)";
|
|
||||||
|
|
||||||
// MARK: - Send
|
// MARK: - Send
|
||||||
"send.title" = "Send Zcash";
|
"send.title" = "Send Zcash";
|
||||||
|
|
|
@ -34,6 +34,8 @@ public enum Asset {
|
||||||
public static let share = ImageAsset(name: "share")
|
public static let share = ImageAsset(name: "share")
|
||||||
public static let shield = ImageAsset(name: "shield")
|
public static let shield = ImageAsset(name: "shield")
|
||||||
public static let surroundedShield = ImageAsset(name: "surroundedShield")
|
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 upArrow = ImageAsset(name: "upArrow")
|
||||||
public static let zashiTitle = ImageAsset(name: "zashiTitle")
|
public static let zashiTitle = ImageAsset(name: "zashiTitle")
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,7 +28,7 @@ public struct ZcashButtonStyle: ButtonStyle {
|
||||||
: isEnabled ? Asset.Colors.primary.color : Asset.Colors.shade72.color
|
: isEnabled ? Asset.Colors.primary.color : Asset.Colors.shade72.color
|
||||||
)
|
)
|
||||||
.frame(height: height)
|
.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))
|
.offset(CGSize(width: shadowOffset, height: shadowOffset))
|
||||||
|
|
||||||
Rectangle()
|
Rectangle()
|
||||||
|
|
|
@ -9,6 +9,7 @@ import XCTest
|
||||||
import ComposableArchitecture
|
import ComposableArchitecture
|
||||||
import ZcashLightClientKit
|
import ZcashLightClientKit
|
||||||
import Scan
|
import Scan
|
||||||
|
import Generated
|
||||||
@testable import secant_testnet
|
@testable import secant_testnet
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
@ -16,13 +17,12 @@ class ScanTests: XCTestCase {
|
||||||
func testOnAppearResetValues() async throws {
|
func testOnAppearResetValues() async throws {
|
||||||
let store = TestStore(
|
let store = TestStore(
|
||||||
initialState:
|
initialState:
|
||||||
ScanReducer.State(
|
Scan.State(
|
||||||
isTorchAvailable: true,
|
isTorchAvailable: true,
|
||||||
isTorchOn: true,
|
isTorchOn: true
|
||||||
scanStatus: .value("t1gXqfSSQt6WfpwyuCU3Wi7sSVZ66DYQ3Po".redacted)
|
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
ScanReducer()
|
Scan()
|
||||||
}
|
}
|
||||||
|
|
||||||
store.dependencies.captureDevice = .noOp
|
store.dependencies.captureDevice = .noOp
|
||||||
|
@ -30,7 +30,7 @@ class ScanTests: XCTestCase {
|
||||||
await store.send(.onAppear) { state in
|
await store.send(.onAppear) { state in
|
||||||
state.isTorchAvailable = false
|
state.isTorchAvailable = false
|
||||||
state.isTorchOn = false
|
state.isTorchOn = false
|
||||||
state.scanStatus = .unknown
|
state.info = L10n.Scan.cameraSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
await store.finish()
|
await store.finish()
|
||||||
|
@ -38,9 +38,9 @@ class ScanTests: XCTestCase {
|
||||||
|
|
||||||
func testTorchOn() async throws {
|
func testTorchOn() async throws {
|
||||||
let store = TestStore(
|
let store = TestStore(
|
||||||
initialState: ScanReducer.State()
|
initialState: Scan.State()
|
||||||
) {
|
) {
|
||||||
ScanReducer()
|
Scan()
|
||||||
}
|
}
|
||||||
|
|
||||||
store.dependencies.captureDevice = .noOp
|
store.dependencies.captureDevice = .noOp
|
||||||
|
@ -54,11 +54,11 @@ class ScanTests: XCTestCase {
|
||||||
|
|
||||||
func testTorchOff() async throws {
|
func testTorchOff() async throws {
|
||||||
let store = TestStore(
|
let store = TestStore(
|
||||||
initialState: ScanReducer.State(
|
initialState: Scan.State(
|
||||||
isTorchOn: true
|
isTorchOn: true
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
ScanReducer()
|
Scan()
|
||||||
}
|
}
|
||||||
|
|
||||||
store.dependencies.captureDevice = .noOp
|
store.dependencies.captureDevice = .noOp
|
||||||
|
@ -72,27 +72,34 @@ class ScanTests: XCTestCase {
|
||||||
|
|
||||||
func testScannedInvalidValue() async throws {
|
func testScannedInvalidValue() async throws {
|
||||||
let store = TestStore(
|
let store = TestStore(
|
||||||
initialState: ScanReducer.State()
|
initialState: Scan.State()
|
||||||
) {
|
) {
|
||||||
ScanReducer()
|
Scan()
|
||||||
}
|
}
|
||||||
|
|
||||||
store.dependencies.uriParser.isValidURI = { _, _ in false }
|
store.dependencies.uriParser.isValidURI = { _, _ in false }
|
||||||
|
store.dependencies.mainQueue = .immediate
|
||||||
|
|
||||||
let value = "test".redacted
|
let value = "test".redacted
|
||||||
|
|
||||||
await store.send(.scan(value)) { state in
|
await store.send(.scan(value))
|
||||||
state.scanStatus = .failed
|
|
||||||
|
await store.receive(.scanFailed) { state in
|
||||||
|
state.info = L10n.Scan.invalidQR
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await store.receive(.clearInfo) { state in
|
||||||
|
state.info = ""
|
||||||
|
}
|
||||||
|
|
||||||
await store.finish()
|
await store.finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor func testScannedValidAddress() async throws {
|
func testScannedValidAddress() async throws {
|
||||||
let store = TestStore(
|
let store = TestStore(
|
||||||
initialState: ScanReducer.State()
|
initialState: Scan.State()
|
||||||
) {
|
) {
|
||||||
ScanReducer()
|
Scan()
|
||||||
}
|
}
|
||||||
|
|
||||||
store.dependencies.mainQueue = .immediate
|
store.dependencies.mainQueue = .immediate
|
||||||
|
@ -100,9 +107,7 @@ class ScanTests: XCTestCase {
|
||||||
|
|
||||||
let address = "t1gXqfSSQt6WfpwyuCU3Wi7sSVZ66DYQ3Po".redacted
|
let address = "t1gXqfSSQt6WfpwyuCU3Wi7sSVZ66DYQ3Po".redacted
|
||||||
|
|
||||||
await store.send(.scan(address)) { state in
|
await store.send(.scan(address))
|
||||||
state.scanStatus = .value(address)
|
|
||||||
}
|
|
||||||
|
|
||||||
await store.receive(.found(address))
|
await store.receive(.found(address))
|
||||||
|
|
||||||
|
@ -111,13 +116,19 @@ class ScanTests: XCTestCase {
|
||||||
|
|
||||||
func testScanFailed() async throws {
|
func testScanFailed() async throws {
|
||||||
let store = TestStore(
|
let store = TestStore(
|
||||||
initialState: ScanReducer.State()
|
initialState: Scan.State()
|
||||||
) {
|
) {
|
||||||
ScanReducer()
|
Scan()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
store.dependencies.mainQueue = .immediate
|
||||||
|
|
||||||
await store.send(.scanFailed) { state in
|
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()
|
await store.finish()
|
||||||
|
|
Loading…
Reference in New Issue