[106] [Scaffold] Scan QR Screen (#321)

- scan UI view
- UI representable
- scaffold of scan view
- rect of interest support
- simple URI parser and validator
- tests

[106] [Scaffold] Scan QR Screen

- scan status added
- scan status tests

[106] [Scaffold] Scan QR Screen

- custom back button added

[106] [Scaffold] Scan QR Screen

- fixed typo

[106] [Scaffold] Scan QR Screen (321)

- valid vs. invalid code part of the enum

[106] [Scaffold] Scan QR Screen (321)

- refactor

[106] [Scaffold] Scan QR Screen (321)

- cleanup

[106] [Scaffold] Scan QR Screen (321)

- review comments solved
- some improvements added
- firing the valid qr code after 1s

[106] [Scaffold] Scan QR Screen (321)

- tests fixed
- onAppear test added
- print() cleanup

[106] [Scaffold] Scan QR Screen (321)

- wrapped audio services
- vibrate when QR found
- alphabetical order of environmental parameters
- AVCaptureSession stopRunning explicit call, probably handled by AVCaptureSession itself but I didn't find proof
- QRCodeScanView simplified and cleaned up

[106] [Scaffold] Scan QR Screen (321)

- URIParser added to the project
This commit is contained in:
Lukas Korba 2022-05-24 08:30:12 +02:00 committed by GitHub
parent b753efbc5f
commit 4cc7737b21
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 802 additions and 64 deletions

View File

@ -82,6 +82,10 @@
66A0807B271993C500118B79 /* OnboardingProgressIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66A0807A271993C500118B79 /* OnboardingProgressIndicator.swift */; };
66D50668271D9B6100E51F0D /* NavigationButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66D50667271D9B6100E51F0D /* NavigationButtonStyle.swift */; };
66DC733F271D88CC0053CBB6 /* StandardButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66DC733E271D88CC0053CBB6 /* StandardButtonStyle.swift */; };
9E01F8202833861A000EFC57 /* WrappedCaptureDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E01F81F2833861A000EFC57 /* WrappedCaptureDevice.swift */; };
9E01F8222833BFAE000EFC57 /* URIParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E01F8212833BFAE000EFC57 /* URIParser.swift */; };
9E01F8242833C0D8000EFC57 /* WrappedURIParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E01F8232833C0D8000EFC57 /* WrappedURIParser.swift */; };
9E01F8282833CDA0000EFC57 /* ScanTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E01F8272833CDA0000EFC57 /* ScanTests.swift */; };
9E02B56A27FED43E005B809B /* WrappedFileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E02B56927FED43E005B809B /* WrappedFileManager.swift */; };
9E02B56C27FED475005B809B /* DatabaseFilesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E02B56B27FED475005B809B /* DatabaseFilesTests.swift */; };
9E02B5C3280458D2005B809B /* WrappedDerivationTool.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E02B5C2280458D2005B809B /* WrappedDerivationTool.swift */; };
@ -118,7 +122,10 @@
9E7FE0E8282E7B7C00C374E8 /* WrappedDatabaseFiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E7FE0E7282E7B7C00C374E8 /* WrappedDatabaseFiles.swift */; };
9E7FE0EC282E7D9400C374E8 /* TransactionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5BF63B2818305D00BA3F17 /* TransactionState.swift */; };
9E7FE0EE282E7E8700C374E8 /* SendFlowTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E7FE0D0282D26BD00C374E8 /* SendFlowTransaction.swift */; };
9E7FE0F628327F6F00C374E8 /* ScanUIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E7FE0F528327F6F00C374E8 /* ScanUIView.swift */; };
9E7FE0F92832824C00C374E8 /* QRCodeScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E7FE0F82832824C00C374E8 /* QRCodeScanView.swift */; };
9E80B47227E4B34B008FF493 /* UserPreferencesStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E80B47127E4B34B008FF493 /* UserPreferencesStorage.swift */; };
9E87ADF128363DE400122FCC /* WrappedAudioServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E87ADF028363DE400122FCC /* WrappedAudioServices.swift */; };
9EAFEB822805793200199FC9 /* AppReducerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EAFEB812805793200199FC9 /* AppReducerTests.swift */; };
9EAFEB84280597B700199FC9 /* WrappedSecItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EAFEB83280597B700199FC9 /* WrappedSecItem.swift */; };
9EAFEB862805A23100199FC9 /* WrappedSecItemTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EAFEB852805A23100199FC9 /* WrappedSecItemTests.swift */; };
@ -265,6 +272,10 @@
66A0807A271993C500118B79 /* OnboardingProgressIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingProgressIndicator.swift; sourceTree = "<group>"; };
66D50667271D9B6100E51F0D /* NavigationButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationButtonStyle.swift; sourceTree = "<group>"; };
66DC733E271D88CC0053CBB6 /* StandardButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StandardButtonStyle.swift; sourceTree = "<group>"; };
9E01F81F2833861A000EFC57 /* WrappedCaptureDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedCaptureDevice.swift; sourceTree = "<group>"; };
9E01F8212833BFAE000EFC57 /* URIParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URIParser.swift; sourceTree = "<group>"; };
9E01F8232833C0D8000EFC57 /* WrappedURIParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedURIParser.swift; sourceTree = "<group>"; };
9E01F8272833CDA0000EFC57 /* ScanTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanTests.swift; sourceTree = "<group>"; };
9E02B56927FED43E005B809B /* WrappedFileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedFileManager.swift; sourceTree = "<group>"; };
9E02B56B27FED475005B809B /* DatabaseFilesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseFilesTests.swift; sourceTree = "<group>"; };
9E02B5C2280458D2005B809B /* WrappedDerivationTool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedDerivationTool.swift; sourceTree = "<group>"; };
@ -300,7 +311,10 @@
9E7FE0DE282D2DD600C374E8 /* ZcashBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZcashBadge.swift; sourceTree = "<group>"; };
9E7FE0E5282E7B1100C374E8 /* StoredWallet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredWallet.swift; sourceTree = "<group>"; };
9E7FE0E7282E7B7C00C374E8 /* WrappedDatabaseFiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedDatabaseFiles.swift; sourceTree = "<group>"; };
9E7FE0F528327F6F00C374E8 /* ScanUIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanUIView.swift; sourceTree = "<group>"; };
9E7FE0F82832824C00C374E8 /* QRCodeScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeScanView.swift; sourceTree = "<group>"; };
9E80B47127E4B34B008FF493 /* UserPreferencesStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPreferencesStorage.swift; sourceTree = "<group>"; };
9E87ADF028363DE400122FCC /* WrappedAudioServices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedAudioServices.swift; sourceTree = "<group>"; };
9EAFEB812805793200199FC9 /* AppReducerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppReducerTests.swift; sourceTree = "<group>"; };
9EAFEB83280597B700199FC9 /* WrappedSecItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedSecItem.swift; sourceTree = "<group>"; };
9EAFEB852805A23100199FC9 /* WrappedSecItemTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedSecItemTests.swift; sourceTree = "<group>"; };
@ -462,6 +476,7 @@
0D4E7A1926B364180058B01E /* secantTests */ = {
isa = PBXGroup;
children = (
9E01F8262833CD84000EFC57 /* ScanTests */,
9E5BF642281FEC8700BA3F17 /* SendTests */,
9E5BF63D281953F900BA3F17 /* TransactionHistoryTests */,
9EAFEB802805791400199FC9 /* AppReducerTests */,
@ -685,6 +700,14 @@
path = CircularFrame;
sourceTree = "<group>";
};
9E01F8262833CD84000EFC57 /* ScanTests */ = {
isa = PBXGroup;
children = (
9E01F8272833CDA0000EFC57 /* ScanTests.swift */,
);
path = ScanTests;
sourceTree = "<group>";
};
9E02B56827FED42D005B809B /* Wrappers */ = {
isa = PBXGroup;
children = (
@ -699,6 +722,9 @@
9E7FE0D8282D289B00C374E8 /* WrappedFeedbackGenerator.swift */,
9E7FE0DA282D28F100C374E8 /* WrappedPasteboard.swift */,
9E7FE0E7282E7B7C00C374E8 /* WrappedDatabaseFiles.swift */,
9E01F81F2833861A000EFC57 /* WrappedCaptureDevice.swift */,
9E01F8232833C0D8000EFC57 /* WrappedURIParser.swift */,
9E87ADF028363DE400122FCC /* WrappedAudioServices.swift */,
);
path = Wrappers;
sourceTree = "<group>";
@ -808,6 +834,7 @@
9EAFEB892806F48100199FC9 /* ZCashSDKEnvironment.swift */,
9E80B47127E4B34B008FF493 /* UserPreferencesStorage.swift */,
9ECAE56727FC713C0089A0EF /* DatabaseFiles.swift */,
9E01F8212833BFAE000EFC57 /* URIParser.swift */,
);
path = Dependencies;
sourceTree = "<group>";
@ -885,6 +912,15 @@
path = TCATextField;
sourceTree = "<group>";
};
9E7FE0F72832823100C374E8 /* UIKitBridge */ = {
isa = PBXGroup;
children = (
9E7FE0F528327F6F00C374E8 /* ScanUIView.swift */,
9E7FE0F82832824C00C374E8 /* QRCodeScanView.swift */,
);
path = UIKitBridge;
sourceTree = "<group>";
};
9EAFEB802805791400199FC9 /* AppReducerTests */ = {
isa = PBXGroup;
children = (
@ -972,6 +1008,7 @@
children = (
F9971A5E27680DF600A2DB75 /* ScanStore.swift */,
F9971A5D27680DF600A2DB75 /* ScanView.swift */,
9E7FE0F72832823100C374E8 /* UIKitBridge */,
);
path = Scan;
sourceTree = "<group>";
@ -1224,6 +1261,7 @@
9EAFEB902808183D00199FC9 /* SandboxStore.swift in Sources */,
0D35CC46277A36E00074316A /* ScrollableWhenScaled.swift in Sources */,
F96B41E9273B501F0021B49A /* TransactionHistoryFlowView.swift in Sources */,
9E01F8202833861A000EFC57 /* WrappedCaptureDevice.swift in Sources */,
2EDA07A027EDE18C00D6F09B /* TCATextField.swift in Sources */,
9E7FE0DB282D28F100C374E8 /* WrappedPasteboard.swift in Sources */,
2EB7758727FC67FD00269373 /* TransactionAmountTextFieldStore.swift in Sources */,
@ -1264,6 +1302,7 @@
9E7FE0D3282D274E00C374E8 /* Date+Readable.swift in Sources */,
F93673D62742CB840099C6AF /* Previews.swift in Sources */,
0D18581B272728D60046B928 /* PhraseChip.swift in Sources */,
9E7FE0F92832824C00C374E8 /* QRCodeScanView.swift in Sources */,
0DF482BA2787ADA800EB37D6 /* ConditionalModifier.swift in Sources */,
9E7FE0EC282E7D9400C374E8 /* TransactionState.swift in Sources */,
9E2F1C8F280EDE09004E65FE /* Drawer.swift in Sources */,
@ -1278,6 +1317,7 @@
0D3D04082728B3440032ABC1 /* RecoveryPhraseDisplayView.swift in Sources */,
F9971A5F27680DF600A2DB75 /* ScanView.swift in Sources */,
F9971A4E27680DC400A2DB75 /* AppView.swift in Sources */,
9E87ADF128363DE400122FCC /* WrappedAudioServices.swift in Sources */,
2EA11F5B27467EF800709571 /* OnboardingFooterView.swift in Sources */,
66D50668271D9B6100E51F0D /* NavigationButtonStyle.swift in Sources */,
2EDA07A427EDE2A900D6F09B /* DebugFrame.swift in Sources */,
@ -1307,6 +1347,7 @@
9ECAE56827FC713C0089A0EF /* DatabaseFiles.swift in Sources */,
9E5BF6462821028C00BA3F17 /* WrappedUserDefaults.swift in Sources */,
F9971A6B27680E1000A2DB75 /* WalletInfoStore.swift in Sources */,
9E7FE0F628327F6F00C374E8 /* ScanUIView.swift in Sources */,
0D185819272723FF0046B928 /* ColoredChip.swift in Sources */,
2EA11F5D27467F7700709571 /* OnboardingContentView.swift in Sources */,
9E7FE0EE282E7E8700C374E8 /* SendFlowTransaction.swift in Sources */,
@ -1316,6 +1357,7 @@
0D8A43C6272B129C005A6414 /* WordChipGrid.swift in Sources */,
66A0807B271993C500118B79 /* OnboardingProgressIndicator.swift in Sources */,
0D7DF08C271DCC0E00530046 /* ScreenBackground.swift in Sources */,
9E01F8242833C0D8000EFC57 /* WrappedURIParser.swift in Sources */,
F9C165C22740403600592F76 /* CreateTransactionView.swift in Sources */,
F9C165B4274031F600592F76 /* Bindings.swift in Sources */,
2E35F99A27B3E99C00EB79CD /* TextFieldTitleAccessoryButtonStyle.swift in Sources */,
@ -1324,6 +1366,7 @@
F96B41EB273B50520021B49A /* Strings.swift in Sources */,
2EDA07A227EDE1AE00D6F09B /* TextFieldFooter.swift in Sources */,
F9971A5427680DD000A2DB75 /* ProfileView.swift in Sources */,
9E01F8222833BFAE000EFC57 /* URIParser.swift in Sources */,
F9971A6027680DF600A2DB75 /* ScanStore.swift in Sources */,
9EF8139127F191BF0075AF48 /* WrappedWalletStorage.swift in Sources */,
0DFE93E1272C9ECB000FCCA5 /* RecoveryPhraseBackupView.swift in Sources */,
@ -1346,6 +1389,7 @@
files = (
0DFE93DF272C6D4B000FCCA5 /* RecoveryPhraseBackupTests.swift in Sources */,
9EDDEAA22829610D00B4100C /* CurrencySelectionTests.swift in Sources */,
9E01F8282833CDA0000EFC57 /* ScanTests.swift in Sources */,
9EDDEAA42829610D00B4100C /* TransactionAddressInputTests.swift in Sources */,
6654C7442715A4AC00901167 /* OnboardingStoreTests.swift in Sources */,
9EDDEAA32829610D00B4100C /* TransactionAmountInputTests.swift in Sources */,

View File

@ -0,0 +1,23 @@
//
// URIParser.swift
// secant-testnet
//
// Created by Lukáš Korba on 17.05.2022.
//
import Foundation
struct URIParser {
enum URIParserError: Error {
}
private let derivationTool: WrappedDerivationTool
init(derivationTool: WrappedDerivationTool) {
self.derivationTool = derivationTool
}
func isValidURI(_ uri: String) throws -> Bool {
try derivationTool.isValidZcashAddress(uri)
}
}

View File

@ -60,33 +60,39 @@ enum AppAction: Equatable {
// MARK: - Environment
struct AppEnvironment {
let SDKSynchronizer: WrappedSDKSynchronizer
let audioServices: WrappedAudioServices
let databaseFiles: WrappedDatabaseFiles
let derivationTool: WrappedDerivationTool
let feedbackGenerator: WrappedFeedbackGenerator
let mnemonic: WrappedMnemonic
let scheduler: AnySchedulerOf<DispatchQueue>
let SDKSynchronizer: WrappedSDKSynchronizer
let walletStorage: WrappedWalletStorage
let derivationTool: WrappedDerivationTool
let zcashSDKEnvironment: ZCashSDKEnvironment
}
extension AppEnvironment {
static let live = AppEnvironment(
SDKSynchronizer: LiveWrappedSDKSynchronizer(),
audioServices: .haptic,
databaseFiles: .live(),
derivationTool: .live(),
feedbackGenerator: .haptic,
mnemonic: .live,
scheduler: DispatchQueue.main.eraseToAnyScheduler(),
SDKSynchronizer: LiveWrappedSDKSynchronizer(),
walletStorage: .live(),
derivationTool: .live(),
zcashSDKEnvironment: .mainnet
)
static let mock = AppEnvironment(
SDKSynchronizer: LiveWrappedSDKSynchronizer(),
audioServices: .silent,
databaseFiles: .live(),
derivationTool: .live(derivationTool: DerivationTool(networkType: .mainnet)),
feedbackGenerator: .silent,
mnemonic: .mock,
scheduler: DispatchQueue.main.eraseToAnyScheduler(),
SDKSynchronizer: LiveWrappedSDKSynchronizer(),
walletStorage: .live(),
derivationTool: .live(derivationTool: DerivationTool(networkType: .mainnet)),
zcashSDKEnvironment: .mainnet
)
}
@ -94,7 +100,7 @@ extension AppEnvironment {
// MARK: - Reducer
extension AppReducer {
private struct ListenerId: Hashable {}
private struct CancelId: Hashable {}
static let `default` = AppReducer.combine(
[
@ -144,7 +150,7 @@ extension AppReducer {
return Effect(value: .updateRoute(.onboarding))
.delay(for: 3, scheduler: environment.scheduler)
.eraseToEffect()
.cancellable(id: ListenerId(), cancelInFlight: true)
.cancellable(id: CancelId(), cancelInFlight: true)
}
return .none
@ -205,7 +211,7 @@ extension AppReducer {
return Effect(value: .updateRoute(landingRoute))
.delay(for: 3, scheduler: environment.scheduler)
.eraseToEffect()
.cancellable(id: ListenerId(), cancelInFlight: true)
.cancellable(id: CancelId(), cancelInFlight: true)
case .createNewWallet:
do {
@ -251,7 +257,7 @@ extension AppReducer {
case .welcome(.debugMenuStartup), .home(.debugMenuStartup):
return .concatenate(
Effect.cancel(id: ListenerId()),
Effect.cancel(id: CancelId()),
Effect(value: .updateRoute(.startup))
)
@ -308,11 +314,13 @@ extension AppReducer {
action: /AppAction.home,
environment: { environment in
HomeEnvironment(
audioServices: environment.audioServices,
derivationTool: environment.derivationTool,
feedbackGenerator: environment.feedbackGenerator,
mnemonic: environment.mnemonic,
scheduler: environment.scheduler,
walletStorage: environment.walletStorage,
derivationTool: environment.derivationTool,
SDKSynchronizer: environment.SDKSynchronizer
SDKSynchronizer: environment.SDKSynchronizer,
walletStorage: environment.walletStorage
)
}
)
@ -338,7 +346,14 @@ extension AppReducer {
private static let phraseDisplayReducer: AppReducer = RecoveryPhraseDisplayReducer.default.pullback(
state: \AppState.phraseDisplayState,
action: /AppAction.phraseDisplay,
environment: { _ in RecoveryPhraseDisplayEnvironment.live }
environment: { environment in
RecoveryPhraseDisplayEnvironment(
scheduler: environment.scheduler,
newPhrase: { Effect(value: .init(words: RecoveryPhrase.placeholder.words)) },
pasteboard: .live,
feedbackGenerator: environment.feedbackGenerator
)
}
)
private static let sandboxReducer: AppReducer = SandboxReducer.default.pullback(

View File

@ -2,6 +2,9 @@ import ComposableArchitecture
import SwiftUI
import ZcashLightClientKit
import UIKit
import AVFoundation
typealias HomeReducer = Reducer<HomeState, HomeAction, HomeEnvironment>
typealias HomeStore = Store<HomeState, HomeAction>
typealias HomeViewStore = ViewStore<HomeState, HomeAction>
@ -51,23 +54,26 @@ enum HomeAction: Equatable {
// MARK: Environment
struct HomeEnvironment {
let audioServices: WrappedAudioServices
let derivationTool: WrappedDerivationTool
let feedbackGenerator: WrappedFeedbackGenerator
let mnemonic: WrappedMnemonic
let scheduler: AnySchedulerOf<DispatchQueue>
let walletStorage: WrappedWalletStorage
let derivationTool: WrappedDerivationTool
let SDKSynchronizer: WrappedSDKSynchronizer
let walletStorage: WrappedWalletStorage
}
// MARK: - Reducer
extension HomeReducer {
private struct ListenerId: Hashable {}
private struct CancelId: Hashable {}
static let `default` = HomeReducer.combine(
[
homeReducer,
historyReducer,
sendReducer,
scanReducer,
profileReducer
]
)
@ -79,10 +85,10 @@ extension HomeReducer {
return environment.SDKSynchronizer.stateChanged
.map(HomeAction.synchronizerStateChanged)
.eraseToEffect()
.cancellable(id: ListenerId(), cancelInFlight: true)
.cancellable(id: CancelId(), cancelInFlight: true)
case .onDisappear:
return Effect.cancel(id: ListenerId())
return Effect.cancel(id: CancelId())
case .synchronizerStateChanged(.synced):
return .merge(
@ -119,7 +125,7 @@ extension HomeReducer {
case .updateSynchronizerStatus:
state.synchronizerStatus = environment.SDKSynchronizer.status()
return .none
case .updateRoute(let route):
state.route = route
return .none
@ -129,9 +135,6 @@ extension HomeReducer {
case .request(let action):
return .none
case .scan(let action):
return .none
case .transactionHistory(.updateRoute(.all)):
return state.drawerOverlay != .full ? Effect(value: .updateDrawer(.full)) : .none
@ -148,6 +151,13 @@ extension HomeReducer {
case .send(let action):
return .none
case .scan(.found(let code)):
environment.audioServices.systemSoundVibrate()
return Effect(value: .updateRoute(nil))
case .scan(let action):
return .none
case .debugMenuStartup:
return .none
}
@ -178,6 +188,18 @@ extension HomeReducer {
}
)
private static let scanReducer: HomeReducer = ScanReducer.default.pullback(
state: \HomeState.scanState,
action: /HomeAction.scan,
environment: { environment in
ScanEnvironment(
captureDevice: .real,
scheduler: environment.scheduler,
uriParser: .live(uriParser: URIParser(derivationTool: environment.derivationTool))
)
}
)
private static let profileReducer: HomeReducer = ProfileReducer.default.pullback(
state: \HomeState.profileState,
action: /HomeAction.profile,
@ -273,11 +295,13 @@ extension HomeStore {
initialState: .placeholder,
reducer: .default.debug(),
environment: HomeEnvironment(
audioServices: .silent,
derivationTool: .live(),
feedbackGenerator: .silent,
mnemonic: .live,
scheduler: DispatchQueue.main.eraseToAnyScheduler(),
walletStorage: .live(),
derivationTool: .live(),
SDKSynchronizer: LiveWrappedSDKSynchronizer()
SDKSynchronizer: LiveWrappedSDKSynchronizer(),
walletStorage: .live()
)
)
}

View File

@ -31,7 +31,7 @@ enum RecoveryPhraseDisplayAction: Equatable {
// MARK: - Environment
struct RecoveryPhraseDisplayEnvironment {
let mainQueue: AnySchedulerOf<DispatchQueue>
let scheduler: AnySchedulerOf<DispatchQueue>
let newPhrase: () -> Effect<RecoveryPhrase, RecoveryPhraseError>
let pasteboard: WrappedPasteboard
let feedbackGenerator: WrappedFeedbackGenerator
@ -44,18 +44,11 @@ extension RecoveryPhraseDisplayEnvironment {
}
static let demo = Self(
mainQueue: DispatchQueue.main.eraseToAnyScheduler(),
scheduler: DispatchQueue.main.eraseToAnyScheduler(),
newPhrase: { Effect(value: .init(words: RecoveryPhrase.placeholder.words)) },
pasteboard: .test,
feedbackGenerator: .silent
)
static let live = Self(
mainQueue: DispatchQueue.main.eraseToAnyScheduler(),
newPhrase: { Effect(value: .init(words: RecoveryPhrase.placeholder.words)) },
pasteboard: .live,
feedbackGenerator: .haptic
)
}
// MARK: - Reducer
@ -65,7 +58,7 @@ extension RecoveryPhraseDisplayReducer {
switch action {
case .createPhrase:
return environment.newPhrase()
.receive(on: environment.mainQueue)
.receive(on: environment.scheduler)
.catchToEffect(RecoveryPhraseDisplayAction.phraseResponse)
case .copyToBufferPressed:
guard let phrase = state.phrase?.toString() else { return .none }

View File

@ -133,7 +133,7 @@ enum RecoveryPhraseValidationFlowAction: Equatable {
// MARK: - Environment
struct RecoveryPhraseValidationFlowEnvironment {
let mainQueue: AnySchedulerOf<DispatchQueue>
let scheduler: AnySchedulerOf<DispatchQueue>
let newPhrase: () -> Effect<RecoveryPhrase, RecoveryPhraseError>
let pasteboard: WrappedPasteboard
let feedbackGenerator: WrappedFeedbackGenerator
@ -146,14 +146,14 @@ extension RecoveryPhraseValidationFlowEnvironment {
}
static let demo = Self(
mainQueue: DispatchQueue.main.eraseToAnyScheduler(),
scheduler: DispatchQueue.main.eraseToAnyScheduler(),
newPhrase: { Effect(value: .init(words: RecoveryPhrase.placeholder.words)) },
pasteboard: .test,
feedbackGenerator: .silent
)
static let live = Self(
mainQueue: DispatchQueue.main.eraseToAnyScheduler(),
scheduler: DispatchQueue.main.eraseToAnyScheduler(),
newPhrase: { Effect(value: .init(words: RecoveryPhrase.placeholder.words)) },
pasteboard: .live,
feedbackGenerator: .haptic
@ -182,7 +182,7 @@ extension RecoveryPhraseValidationFlowReducer {
if state.isComplete {
let value: RecoveryPhraseValidationFlowAction = state.isValid ? .succeed : .fail
let effect = Effect<RecoveryPhraseValidationFlowAction, Never>(value: value)
.delay(for: 1, scheduler: environment.mainQueue)
.delay(for: 1, scheduler: environment.scheduler)
.eraseToEffect()
if value == .succeed {

View File

@ -1,3 +1,10 @@
//
// ScanUIView.swift
// secant-testnet
//
// Created by Lukáš Korba on 16.05.2022.
//
import ComposableArchitecture
typealias ScanReducer = Reducer<ScanState, ScanAction, ScanEnvironment>
@ -7,24 +14,107 @@ typealias ScanViewStore = ViewStore<ScanState, ScanAction>
// MARK: - State
struct ScanState: Equatable {
enum ScanStatus: Equatable {
case failed
case value(String)
case unknown
}
var isTorchAvailable = false
var isTorchOn = false
var isValidValue = false
var scanStatus: ScanStatus = .unknown
var scannedValue: String? {
guard case let .value(scannedValue) = scanStatus else {
return nil
}
return scannedValue
}
}
// MARK: - Action
enum ScanAction: Equatable {
case noOp
case onAppear
case onDisappear
case found(String)
case scanFailed
case scan(String)
case torchPressed
}
// MARK: - Environment
struct ScanEnvironment { }
struct ScanEnvironment {
let captureDevice: WrappedCaptureDevice
let scheduler: AnySchedulerOf<DispatchQueue>
let uriParser: WrappedURIParser
}
// MARK: - Reducer
extension ScanReducer {
static let `default` = ScanReducer { _, action, _ in
private struct CancelId: Hashable {}
static let `default` = ScanReducer { state, action, environment in
switch action {
default:
case .onAppear:
// reset the values
state.scanStatus = .unknown
state.isValidValue = false
state.isTorchOn = false
// check the torch availability
do {
state.isTorchAvailable = try environment.captureDevice.isTorchAvailable()
} catch {
// TODO: handle torch errors, issue #322 (https://github.com/zcash/secant-ios-wallet/issues/322)
}
return .none
case .onDisappear:
return Effect.cancel(id: CancelId())
case .found(let code):
return .none
case .scanFailed:
state.scanStatus = .failed
return .none
case .scan(let code):
// the logic for the same scanned code is skipped until some new code
if let prevCode = state.scannedValue, prevCode == code {
return .none
}
state.scanStatus = .value(code)
state.isValidValue = false
do {
if try environment.uriParser.isValidURI(code) {
state.isValidValue = true
// 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()),
Effect(value: .found(code))
.delay(for: 1.0, scheduler: environment.scheduler)
.eraseToEffect()
.cancellable(id: CancelId(), cancelInFlight: true)
)
}
} catch {
state.scanStatus = .failed
}
return Effect.cancel(id: CancelId())
case .torchPressed:
do {
try environment.captureDevice.torch(!state.isTorchOn)
state.isTorchOn.toggle()
} catch {
// TODO: handle torch errors, issue #322 (https://github.com/zcash/secant-ios-wallet/issues/322)
}
return .none
}
}
@ -42,6 +132,10 @@ extension ScanStore {
static let placeholder = ScanStore(
initialState: .placeholder,
reducer: .default,
environment: ScanEnvironment()
environment: ScanEnvironment(
captureDevice: .real,
scheduler: DispatchQueue.main.eraseToAnyScheduler(),
uriParser: .live()
)
)
}

View File

@ -1,16 +1,148 @@
//
// ScanStore.swift
// secant-testnet
//
// Created by Lukáš Korba on 16.05.2022.
//
import SwiftUI
import ComposableArchitecture
struct ScanView: View {
@Environment(\.presentationMode) var presentationMode
let store: ScanStore
var body: some View {
WithViewStore(store) { _ in
Text("\(String(describing: Self.self)) PlaceHolder")
WithViewStore(store) { viewStore in
GeometryReader { proxy in
ZStack {
QRCodeScanView(
rectOfInterest: normalizedRectOfInterest(proxy.size),
onQRScanningDidFail: { viewStore.send(.scanFailed) },
onQRScanningSucceededWithCode: { viewStore.send(.scan($0)) }
)
backButton
if viewStore.isTorchAvailable {
torchButton(viewStore)
}
frameOfInterest(proxy.size)
VStack {
Spacer()
Text("We will validate any Zcash URI and take you to the appropriate action.")
.padding(.bottom, 10)
if let scannedValue = viewStore.scannedValue {
Text("\(scannedValue)")
.foregroundColor(viewStore.isValidValue ? .green : .red)
} else {
Text("Scanning...")
}
}
.padding()
}
.navigationBarHidden(true)
.applyScreenBackground()
.onAppear { viewStore.send(.onAppear) }
.onDisappear { viewStore.send(.onDisappear) }
}
.ignoresSafeArea()
}
}
}
extension ScanView {
var backButton: some View {
VStack {
HStack {
Button(action: {
presentationMode.wrappedValue.dismiss()
}, label: {
Image(systemName: "arrow.backward")
.foregroundColor(Asset.Colors.QRScan.frame.color)
.font(.system(size: 30.0))
})
.padding(.top, 10)
Spacer()
}
.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.QRScan.frame.color)
.font(.system(size: 30.0))
}
)
.padding(.top, 10)
}
.padding()
Spacer()
}
.padding()
}
func frameOfInterest(_ size: CGSize) -> some View {
RoundedRectangle(cornerSize: CGSize(width: 5.0, height: 5.0))
.stroke(Asset.Colors.QRScan.frame.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
)
}
}
extension ScanView {
func frameSize(_ size: CGSize) -> CGFloat {
size.width * 0.55
}
func rectOfInterest(_ size: CGSize) -> CGRect {
CGRect(
x: size.width * 0.5,
y: size.height * 0.5,
width: frameSize(size),
height: frameSize(size)
)
}
func normalizedRectOfInterest(_ size: CGSize) -> CGRect {
CGRect(
x: 0.25,
y: 0.25,
width: 0.5,
height: 0.5
)
}
}
// MARK: - Previews
struct ScanView_Previews: PreviewProvider {

View File

@ -0,0 +1,28 @@
//
// QRCodeScanView.swift
// secant-testnet
//
// Created by Lukáš Korba on 16.05.2022.
//
import Foundation
import UIKit
import SwiftUI
struct QRCodeScanView: UIViewRepresentable {
let rectOfInterest: CGRect
let onQRScanningDidFail: () -> Void
let onQRScanningSucceededWithCode: (String) -> Void
public func makeUIView(context: UIViewRepresentableContext<QRCodeScanView>) -> ScanUIView {
let view = ScanUIView()
view.rectOfInterest = rectOfInterest
view.onQRScanningDidFail = onQRScanningDidFail
view.onQRScanningSucceededWithCode = onQRScanningSucceededWithCode
return view
}
public func updateUIView(_ uiView: ScanUIView, context: UIViewRepresentableContext<QRCodeScanView>) { }
public typealias UIViewType = ScanUIView
}

View File

@ -0,0 +1,118 @@
//
// ScanUIView.swift
// secant-testnet
//
// Created by Lukáš Korba on 16.05.2022.
//
import Foundation
import UIKit
import AVFoundation
public class ScanUIView: UIView {
var captureSession: AVCaptureSession?
var metadataOutput: AVCaptureMetadataOutput?
/// Rect of interest = area of the camera view used to try to recognize the qr codes.
private var internalRectOfInterest = CGRect(x: 0.0, y: 0.0, width: 1.0, height: 1.0)
var rectOfInterest: CGRect {
get { internalRectOfInterest }
set {
internalRectOfInterest = newValue
metadataOutput?.rectOfInterest = internalRectOfInterest
}
}
var onQRScanningDidFail: (() -> Void)?
var onQRScanningSucceededWithCode: ((String) -> Void)?
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
doInitialSetup()
}
override init(frame: CGRect) {
super.init(frame: frame)
doInitialSetup()
}
deinit {
captureSession?.stopRunning()
}
override public class var layerClass: AnyClass {
return AVCaptureVideoPreviewLayer.self
}
override public var layer: AVCaptureVideoPreviewLayer {
return super.layer as? AVCaptureVideoPreviewLayer ?? AVCaptureVideoPreviewLayer()
}
}
extension ScanUIView {
private func doInitialSetup() {
clipsToBounds = true
captureSession = AVCaptureSession()
guard let videoCaptureDevice = AVCaptureDevice.default(for: .video) else {
scanningDidFail()
return
}
let videoInput: AVCaptureDeviceInput
do {
videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice)
} catch {
scanningDidFail()
return
}
if captureSession?.canAddInput(videoInput) ?? false {
captureSession?.addInput(videoInput)
} else {
scanningDidFail()
return
}
metadataOutput = AVCaptureMetadataOutput()
if let metadataOutput = metadataOutput, captureSession?.canAddOutput(metadataOutput) ?? false {
captureSession?.addOutput(metadataOutput)
metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
metadataOutput.metadataObjectTypes = [.qr]
} else {
scanningDidFail()
return
}
self.layer.session = captureSession
self.layer.videoGravity = .resizeAspectFill
captureSession?.commitConfiguration()
captureSession?.startRunning()
}
func scanningDidFail() {
onQRScanningDidFail?()
captureSession = nil
}
func found(code: String) {
onQRScanningSucceededWithCode?(code)
}
}
extension ScanUIView: AVCaptureMetadataOutputObjectsDelegate {
public func metadataOutput(
_ output: AVCaptureMetadataOutput,
didOutput metadataObjects: [AVMetadataObject],
from connection: AVCaptureConnection
) {
if let metadataObject = metadataObjects.first {
guard let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject else { return }
guard let stringValue = readableObject.stringValue else { return }
found(code: stringValue)
}
}
}

View File

@ -40,7 +40,7 @@ struct TransactionHistoryFlowEnvironment {
// MARK: - Reducer
extension TransactionHistoryFlowReducer {
private struct ListenerId: Hashable {}
private struct CancelId: Hashable {}
static let `default` = TransactionHistoryFlowReducer { state, action, environment in
switch action {
@ -48,10 +48,10 @@ extension TransactionHistoryFlowReducer {
return environment.SDKSynchronizer.stateChanged
.map(TransactionHistoryFlowAction.synchronizerStateChanged)
.eraseToEffect()
.cancellable(id: ListenerId(), cancelInFlight: true)
.cancellable(id: CancelId(), cancelInFlight: true)
case .onDisappear:
return Effect.cancel(id: ListenerId())
return Effect.cancel(id: CancelId())
case .synchronizerStateChanged(.synced):
return environment.SDKSynchronizer.getAllTransactions()

View File

@ -25,6 +25,8 @@
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSCameraUsageDescription</key>
<string>Scan zAddress Qr Codes</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x00",
"green" : "0xB8",
"red" : "0xFF"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -23,9 +23,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.000",
"green" : "0.725",
"red" : "1.000"
"blue" : "0x00",
"green" : "0xB8",
"red" : "0xFF"
}
},
"idiom" : "universal"

View File

@ -85,6 +85,9 @@ internal enum Asset {
internal static let gradientRight = ColorAsset(name: "GradientRight")
internal static let negativeSpace = ColorAsset(name: "NegativeSpace")
}
internal enum QRScan {
internal static let frame = ColorAsset(name: "frame")
}
internal enum ScreenBackground {
internal static let gradientEnd = ColorAsset(name: "gradientEnd")
internal static let gradientStart = ColorAsset(name: "gradientStart")

View File

@ -39,11 +39,11 @@ struct EnumeratedChip: View {
.cornerRadius(6)
.shadow(color: Asset.Colors.Shadow.numberedTextShadow.color, radius: 3, x: 0, y: 1)
.overlay(
GeometryReader { geometry in
GeometryReader { proxy in
Text("\(index)")
.foregroundColor(Asset.Colors.Text.highlightedSuperscriptText.color)
.font(.custom(FontFamily.Roboto.bold.name, size: 10))
.frame(width: geometry.size.width, height: geometry.size.height, alignment: .topLeading)
.frame(width: proxy.size.width, height: proxy.size.height, alignment: .topLeading)
.padding(.leading, basePadding)
.padding(.top, 4)
}

View File

@ -0,0 +1,23 @@
//
// WrappedAudioServices.swift
// secant-testnet
//
// Created by Lukáš Korba on 19.05.2022.
//
import Foundation
import AVFoundation
struct WrappedAudioServices {
let systemSoundVibrate: () -> Void
}
extension WrappedAudioServices {
static let haptic = WrappedAudioServices(
systemSoundVibrate: { AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate)) }
)
static let silent = WrappedAudioServices(
systemSoundVibrate: { }
)
}

View File

@ -0,0 +1,54 @@
//
// WrappedCaptureDevice.swift
// secant-testnet
//
// Created by Lukáš Korba on 17.05.2022.
//
import Foundation
import AVFoundation
struct WrappedCaptureDevice {
enum WrappedCaptureDeviceError: Error {
case captureDeviceFailed
case lockFailed
case torchUnavailable
}
let isTorchAvailable: () throws -> Bool
let torch: (Bool) throws -> Void
}
extension WrappedCaptureDevice {
static let real = WrappedCaptureDevice(
isTorchAvailable: {
guard let videoCaptureDevice = AVCaptureDevice.default(for: .video) else {
throw WrappedCaptureDeviceError.captureDeviceFailed
}
return videoCaptureDevice.hasTorch
},
torch: { isTorchOn in
guard let videoCaptureDevice = AVCaptureDevice.default(for: .video) else {
throw WrappedCaptureDeviceError.captureDeviceFailed
}
guard videoCaptureDevice.hasTorch else {
throw WrappedCaptureDeviceError.torchUnavailable
}
do {
try videoCaptureDevice.lockForConfiguration()
videoCaptureDevice.torchMode = isTorchOn ? .on : .off
videoCaptureDevice.unlockForConfiguration()
} catch {
throw WrappedCaptureDeviceError.lockFailed
}
}
)
static let none = WrappedCaptureDevice(
isTorchAvailable: { false },
torch: { _ in }
)
}

View File

@ -0,0 +1,23 @@
//
// WrappedURIParser.swift
// secant-testnet
//
// Created by Lukáš Korba on 17.05.2022.
//
import Foundation
struct WrappedURIParser {
let isValidURI: (String) throws -> Bool
}
extension WrappedURIParser {
static func live(uriParser: URIParser = URIParser(derivationTool: .live())) -> Self {
Self(
isValidURI: { uri in
try uriParser.isValidURI(uri)
}
)
}
}

View File

@ -13,23 +13,27 @@ class AppReducerTests: XCTestCase {
static let testScheduler = DispatchQueue.test
let testEnvironment = AppEnvironment(
SDKSynchronizer: TestWrappedSDKSynchronizer(),
audioServices: .silent,
databaseFiles: .throwing,
derivationTool: .live(),
feedbackGenerator: .silent,
mnemonic: .mock,
scheduler: testScheduler.eraseToAnyScheduler(),
SDKSynchronizer: TestWrappedSDKSynchronizer(),
walletStorage: .throwing,
derivationTool: .live(),
zcashSDKEnvironment: .mainnet
)
func testWalletInitializationState_Uninitialized() throws {
let uninitializedEnvironment = AppEnvironment(
SDKSynchronizer: TestWrappedSDKSynchronizer(),
audioServices: .silent,
databaseFiles: .throwing,
derivationTool: .live(),
feedbackGenerator: .silent,
mnemonic: .mock,
scheduler: DispatchQueue.test.eraseToAnyScheduler(),
SDKSynchronizer: TestWrappedSDKSynchronizer(),
walletStorage: .throwing,
derivationTool: .live(),
zcashSDKEnvironment: .mainnet
)
@ -46,12 +50,14 @@ class AppReducerTests: XCTestCase {
)
let keysMissingEnvironment = AppEnvironment(
SDKSynchronizer: TestWrappedSDKSynchronizer(),
audioServices: .silent,
databaseFiles: .live(databaseFiles: DatabaseFiles(fileManager: wfmMock)),
derivationTool: .live(),
feedbackGenerator: .silent,
mnemonic: .mock,
scheduler: Self.testScheduler.eraseToAnyScheduler(),
SDKSynchronizer: TestWrappedSDKSynchronizer(),
walletStorage: .throwing,
derivationTool: .live(),
zcashSDKEnvironment: .mainnet
)
@ -68,12 +74,14 @@ class AppReducerTests: XCTestCase {
)
let keysMissingEnvironment = AppEnvironment(
SDKSynchronizer: TestWrappedSDKSynchronizer(),
audioServices: .silent,
databaseFiles: .live(databaseFiles: DatabaseFiles(fileManager: wfmMock)),
derivationTool: .live(),
feedbackGenerator: .silent,
mnemonic: .mock,
scheduler: Self.testScheduler.eraseToAnyScheduler(),
SDKSynchronizer: TestWrappedSDKSynchronizer(),
walletStorage: .throwing,
derivationTool: .live(),
zcashSDKEnvironment: .testnet
)

View File

@ -13,7 +13,7 @@ class RecoveryPhraseValidationTests: XCTestCase {
static let testScheduler = DispatchQueue.test
let testEnvironment = RecoveryPhraseValidationFlowEnvironment(
mainQueue: testScheduler.eraseToAnyScheduler(),
scheduler: testScheduler.eraseToAnyScheduler(),
newPhrase: { Effect(value: .init(words: RecoveryPhrase.placeholder.words)) },
pasteboard: .test,
feedbackGenerator: .silent

View File

@ -0,0 +1,128 @@
//
// ScanTests.swift
// secantTests
//
// Created by Lukáš Korba on 17.05.2022.
//
import XCTest
@testable import secant_testnet
import ComposableArchitecture
import ZcashLightClientKit
class ScanTests: XCTestCase {
func testOnAppearResetValues() throws {
let store = TestStore(
initialState:
ScanState(
isTorchAvailable: true,
isTorchOn: true,
isValidValue: true,
scanStatus: .value("t1gXqfSSQt6WfpwyuCU3Wi7sSVZ66DYQ3Po")
),
reducer: ScanReducer.default,
environment: ScanEnvironment(
captureDevice: .none,
scheduler: DispatchQueue.test.eraseToAnyScheduler(),
uriParser: .live()
)
)
store.send(.onAppear) { state in
state.isTorchAvailable = false
state.isTorchOn = false
state.isValidValue = false
state.scanStatus = .unknown
}
}
func testTorchOn() throws {
let store = TestStore(
initialState: ScanState(),
reducer: ScanReducer.default,
environment: ScanEnvironment(
captureDevice: .none,
scheduler: DispatchQueue.test.eraseToAnyScheduler(),
uriParser: .live()
)
)
store.send(.torchPressed) { state in
state.isTorchOn = true
}
}
func testTorchOff() throws {
let store = TestStore(
initialState: ScanState(
isTorchOn: true
),
reducer: ScanReducer.default,
environment: ScanEnvironment(
captureDevice: .none,
scheduler: DispatchQueue.test.eraseToAnyScheduler(),
uriParser: .live()
)
)
store.send(.torchPressed) { state in
state.isTorchOn = false
}
}
func testScannedInvalidValue() throws {
let store = TestStore(
initialState: ScanState(),
reducer: ScanReducer.default,
environment: ScanEnvironment(
captureDevice: .none,
scheduler: DispatchQueue.test.eraseToAnyScheduler(),
uriParser: .live()
)
)
store.send(.scan("test")) { state in
state.scanStatus = .value("test")
state.isValidValue = false
}
}
func testScannedValidAddress() throws {
let testScheduler = DispatchQueue.test
let store = TestStore(
initialState: ScanState(),
reducer: ScanReducer.default,
environment: ScanEnvironment(
captureDevice: .none,
scheduler: testScheduler.eraseToAnyScheduler(),
uriParser: .live()
)
)
store.send(.scan("t1gXqfSSQt6WfpwyuCU3Wi7sSVZ66DYQ3Po")) { state in
state.scanStatus = .value("t1gXqfSSQt6WfpwyuCU3Wi7sSVZ66DYQ3Po")
state.isValidValue = true
}
testScheduler.advance(by: 1.01)
store.receive(.found("t1gXqfSSQt6WfpwyuCU3Wi7sSVZ66DYQ3Po"))
}
func testScanFailed() throws {
let store = TestStore(
initialState: ScanState(),
reducer: ScanReducer.default,
environment: ScanEnvironment(
captureDevice: .none,
scheduler: DispatchQueue.test.eraseToAnyScheduler(),
uriParser: .live()
)
)
store.send(.scanFailed) { state in
state.scanStatus = .failed
}
}
}