- 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 ### 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.

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 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
} }

View File

@ -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
} }
} }
) )

View File

@ -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 }
) )

View File

@ -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,

View File

@ -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)
} }
} }

View File

@ -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()
} }
} }

View File

@ -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()
} }
} }

View File

@ -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

View File

@ -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 {

View File

@ -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

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: %@)"; "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";

View File

@ -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")
} }

View File

@ -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()

View File

@ -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()