[#80] Scaffold - Send functionality (#297)

* UI + stores/reducers updated

* simple send done

cleanup

flag for sending

deny any attempt to send when sending is still in flight

cleanup

unit tests
This commit is contained in:
Lukas Korba 2022-05-03 00:35:03 +02:00 committed by GitHub
parent f71350f610
commit e54ea3aa18
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 639 additions and 262 deletions

View File

@ -103,6 +103,8 @@
9E4DC6E227C4C6B700E657F4 /* SecantButtonStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E4DC6E127C4C6B700E657F4 /* SecantButtonStyles.swift */; };
9E5BF63C2818305D00BA3F17 /* TransactionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5BF63B2818305D00BA3F17 /* TransactionState.swift */; };
9E5BF63F2819542C00BA3F17 /* TransactionHistoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5BF63E2819542C00BA3F17 /* TransactionHistoryTests.swift */; };
9E5BF641281FD7B600BA3F17 /* TransactionFailedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5BF640281FD7B600BA3F17 /* TransactionFailedView.swift */; };
9E5BF644281FEC9900BA3F17 /* SendTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5BF643281FEC9900BA3F17 /* SendTests.swift */; };
9E69A24D27FB002800A55317 /* Welcome.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E69A24C27FB002800A55317 /* Welcome.swift */; };
9E80B47227E4B34B008FF493 /* UserPreferencesStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E80B47127E4B34B008FF493 /* UserPreferencesStorage.swift */; };
9EAFEB822805793200199FC9 /* AppReducerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EAFEB812805793200199FC9 /* AppReducerTests.swift */; };
@ -142,9 +144,9 @@
F9971A6C27680E1000A2DB75 /* WalletInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9971A6A27680E1000A2DB75 /* WalletInfoView.swift */; };
F9C165B4274031F600592F76 /* Bindings.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9C165B3274031F600592F76 /* Bindings.swift */; };
F9C165BF2740403600592F76 /* SendStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9C165B72740403600592F76 /* SendStore.swift */; };
F9C165C02740403600592F76 /* ApproveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9C165B92740403600592F76 /* ApproveView.swift */; };
F9C165C22740403600592F76 /* CreateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9C165BB2740403600592F76 /* CreateView.swift */; };
F9C165C42740403600592F76 /* SentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9C165BD2740403600592F76 /* SentView.swift */; };
F9C165C02740403600592F76 /* TransactionConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9C165B92740403600592F76 /* TransactionConfirmationView.swift */; };
F9C165C22740403600592F76 /* CreateTransactionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9C165BB2740403600592F76 /* CreateTransactionView.swift */; };
F9C165C42740403600592F76 /* TransactionSentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9C165BD2740403600592F76 /* TransactionSentView.swift */; };
F9C165CB2741AB5D00592F76 /* SendView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9C165CA2741AB5D00592F76 /* SendView.swift */; };
F9EEB8162742C2210032EEB8 /* WithStateBinding.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9EEB8152742C2210032EEB8 /* WithStateBinding.swift */; };
/* End PBXBuildFile section */
@ -268,6 +270,8 @@
9E4DC6E127C4C6B700E657F4 /* SecantButtonStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecantButtonStyles.swift; sourceTree = "<group>"; };
9E5BF63B2818305D00BA3F17 /* TransactionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionState.swift; sourceTree = "<group>"; };
9E5BF63E2819542C00BA3F17 /* TransactionHistoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionHistoryTests.swift; sourceTree = "<group>"; };
9E5BF640281FD7B600BA3F17 /* TransactionFailedView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransactionFailedView.swift; sourceTree = "<group>"; };
9E5BF643281FEC9900BA3F17 /* SendTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendTests.swift; sourceTree = "<group>"; };
9E69A24C27FB002800A55317 /* Welcome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Welcome.swift; sourceTree = "<group>"; };
9E80B47127E4B34B008FF493 /* UserPreferencesStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPreferencesStorage.swift; sourceTree = "<group>"; };
9EAFEB812805793200199FC9 /* AppReducerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppReducerTests.swift; sourceTree = "<group>"; };
@ -306,9 +310,9 @@
F9971A6A27680E1000A2DB75 /* WalletInfoView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WalletInfoView.swift; sourceTree = "<group>"; };
F9C165B3274031F600592F76 /* Bindings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bindings.swift; sourceTree = "<group>"; };
F9C165B72740403600592F76 /* SendStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SendStore.swift; sourceTree = "<group>"; };
F9C165B92740403600592F76 /* ApproveView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ApproveView.swift; sourceTree = "<group>"; };
F9C165BB2740403600592F76 /* CreateView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateView.swift; sourceTree = "<group>"; };
F9C165BD2740403600592F76 /* SentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SentView.swift; sourceTree = "<group>"; };
F9C165B92740403600592F76 /* TransactionConfirmationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransactionConfirmationView.swift; sourceTree = "<group>"; };
F9C165BB2740403600592F76 /* CreateTransactionView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateTransactionView.swift; sourceTree = "<group>"; };
F9C165BD2740403600592F76 /* TransactionSentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransactionSentView.swift; sourceTree = "<group>"; };
F9C165CA2741AB5D00592F76 /* SendView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendView.swift; sourceTree = "<group>"; };
F9EEB8152742C2210032EEB8 /* WithStateBinding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WithStateBinding.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
@ -464,6 +468,7 @@
0D4E7A1926B364180058B01E /* secantTests */ = {
isa = PBXGroup;
children = (
9E5BF642281FEC8700BA3F17 /* SendTests */,
9E5BF63D281953F900BA3F17 /* TransactionHistoryTests */,
9EAFEB802805791400199FC9 /* AppReducerTests */,
9EF8135927ECC25E0075AF48 /* UtilTests */,
@ -792,6 +797,14 @@
path = TransactionHistoryTests;
sourceTree = "<group>";
};
9E5BF642281FEC8700BA3F17 /* SendTests */ = {
isa = PBXGroup;
children = (
9E5BF643281FEC9900BA3F17 /* SendTests.swift */,
);
path = SendTests;
sourceTree = "<group>";
};
9EAFEB802805791400199FC9 /* AppReducerTests */ = {
isa = PBXGroup;
children = (
@ -988,9 +1001,10 @@
isa = PBXGroup;
children = (
F9C165CA2741AB5D00592F76 /* SendView.swift */,
F9C165BB2740403600592F76 /* CreateView.swift */,
F9C165B92740403600592F76 /* ApproveView.swift */,
F9C165BD2740403600592F76 /* SentView.swift */,
F9C165BB2740403600592F76 /* CreateTransactionView.swift */,
F9C165B92740403600592F76 /* TransactionConfirmationView.swift */,
F9C165BD2740403600592F76 /* TransactionSentView.swift */,
9E5BF640281FD7B600BA3F17 /* TransactionFailedView.swift */,
);
path = Views;
sourceTree = "<group>";
@ -1216,6 +1230,7 @@
2EB1C5E827D77F6100BC43D7 /* TextFieldStore.swift in Sources */,
9EAFEB8A2806F48100199FC9 /* ZCashSDKEnvironment.swift in Sources */,
0D8A43C4272AEEDE005A6414 /* SecantTextStyles.swift in Sources */,
9E5BF641281FD7B600BA3F17 /* TransactionFailedView.swift in Sources */,
9E4DC6E027C409A100E657F4 /* NeumorphicDesignModifier.swift in Sources */,
0DACFA7F27208CE00039EEA5 /* Clamped.swift in Sources */,
0DFE93E3272CA1AA000FCCA5 /* RecoveryPhraseValidation.swift in Sources */,
@ -1268,7 +1283,7 @@
663FAB9C271D874D00E495F8 /* ActiveButton.swift in Sources */,
9E2F1C842809B606004E65FE /* DebugMenu.swift in Sources */,
9E02B5C3280458D2005B809B /* WrappedDerivationTool.swift in Sources */,
F9C165C02740403600592F76 /* ApproveView.swift in Sources */,
F9C165C02740403600592F76 /* TransactionConfirmationView.swift in Sources */,
0DF2DC5427235E3E00FA31E2 /* View+InnerShadow.swift in Sources */,
9EAFEB84280597B700199FC9 /* WrappedSecItem.swift in Sources */,
9E2AC10327DA28200042AA47 /* WalletStorage.swift in Sources */,
@ -1281,7 +1296,7 @@
0D8A43C6272B129C005A6414 /* WordChipGrid.swift in Sources */,
66A0807B271993C500118B79 /* OnboardingProgressIndicator.swift in Sources */,
0D7DF08C271DCC0E00530046 /* ScreenBackground.swift in Sources */,
F9C165C22740403600592F76 /* CreateView.swift in Sources */,
F9C165C22740403600592F76 /* CreateTransactionView.swift in Sources */,
F9C165B4274031F600592F76 /* Bindings.swift in Sources */,
2E35F99A27B3E99C00EB79CD /* TextFieldTitleAccessoryButtonStyle.swift in Sources */,
9E2DF99C27CF704D00649636 /* ImportWalletStore.swift in Sources */,
@ -1301,7 +1316,7 @@
0D0781C9278776D20083ACD7 /* ZcashSymbol.swift in Sources */,
2E8719CB27FB09990082C926 /* TransactionTextField.swift in Sources */,
6654C7412715A47300901167 /* Onboarding.swift in Sources */,
F9C165C42740403600592F76 /* SentView.swift in Sources */,
F9C165C42740403600592F76 /* TransactionSentView.swift in Sources */,
F9971A5927680DDE00A2DB75 /* RequestStore.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -1313,6 +1328,7 @@
0DFE93DF272C6D4B000FCCA5 /* RecoveryPhraseBackupTests.swift in Sources */,
6654C7442715A4AC00901167 /* OnboardingStoreTests.swift in Sources */,
9EAFEB862805A23100199FC9 /* WrappedSecItemTests.swift in Sources */,
9E5BF644281FEC9900BA3F17 /* SendTests.swift in Sources */,
0D1C1AA327611EFD0004AF6A /* RecoveryPhraseDisplayReducerTests.swift in Sources */,
9EAFEB822805793200199FC9 /* AppReducerTests.swift in Sources */,
9E5BF63F2819542C00BA3F17 /* TransactionHistoryTests.swift in Sources */,

View File

@ -52,7 +52,7 @@ struct AppEnvironment {
extension AppEnvironment {
static let live = AppEnvironment(
wrappedSDKSynchronizer: MockWrappedSDKSynchronizer(),
wrappedSDKSynchronizer: LiveWrappedSDKSynchronizer(),
databaseFiles: .live(),
mnemonicSeedPhraseProvider: .live,
scheduler: DispatchQueue.main.eraseToAnyScheduler(),
@ -291,7 +291,10 @@ extension AppReducer {
action: /AppAction.home,
environment: { environment in
HomeEnvironment(
mnemonicSeedPhraseProvider: environment.mnemonicSeedPhraseProvider,
scheduler: environment.scheduler,
walletStorage: environment.walletStorage,
wrappedDerivationTool: environment.wrappedDerivationTool,
wrappedSDKSynchronizer: environment.wrappedSDKSynchronizer
)
}

View File

@ -38,7 +38,10 @@ enum HomeAction: Equatable {
}
struct HomeEnvironment {
let mnemonicSeedPhraseProvider: MnemonicSeedPhraseProvider
let scheduler: AnySchedulerOf<DispatchQueue>
let walletStorage: WalletStorageInteractor
let wrappedDerivationTool: WrappedDerivationTool
let wrappedSDKSynchronizer: WrappedSDKSynchronizer
}
@ -52,7 +55,8 @@ extension HomeReducer {
static let `default` = HomeReducer.combine(
[
homeReducer,
historyReducer
historyReducer,
sendReducer
]
)
.debug()
@ -111,9 +115,6 @@ extension HomeReducer {
case .request(let action):
return .none
case .send(let action):
return .none
case .scan(let action):
return .none
@ -125,6 +126,12 @@ extension HomeReducer {
case .transactionHistory(let historyAction):
return .none
case .send(.updateRoute(.done)):
return Effect(value: .updateRoute(nil))
case .send(let action):
return .none
}
}
@ -138,6 +145,20 @@ extension HomeReducer {
)
}
)
private static let sendReducer: HomeReducer = SendReducer.default.pullback(
state: \HomeState.sendState,
action: /HomeAction.send,
environment: { environment in
SendEnvironment(
mnemonicSeedPhraseProvider: environment.mnemonicSeedPhraseProvider,
scheduler: environment.scheduler,
walletStorage: environment.walletStorage,
wrappedDerivationTool: environment.wrappedDerivationTool,
wrappedSDKSynchronizer: environment.wrappedSDKSynchronizer
)
}
)
}
// MARK: - HomeViewStore

View File

@ -14,9 +14,6 @@ struct HomeView: View {
sendButton(viewStore)
requestButton(viewStore)
.padding(.top, 140)
VStack {
Text("balance: \(viewStore.totalBalance)")
.accessDebugMenuWithHiddenGesture {
@ -72,35 +69,6 @@ extension HomeView {
}
}
func requestButton(_ viewStore: HomeViewStore) -> some View {
VStack {
Spacer()
Text("home.request")
.shadow(color: Asset.Colors.Buttons.buttonsTitleShadow.color, radius: 2, x: 0, y: 2)
.frame(
minWidth: 0,
maxWidth: .infinity,
minHeight: 0,
maxHeight: .infinity
)
.foregroundColor(Asset.Colors.Text.secondaryButtonText.color)
.background(Asset.Colors.Buttons.secondaryButton.color)
.cornerRadius(12)
.frame(height: 60)
.padding(.horizontal, 50)
.neumorphicButton()
.navigationLink(
isActive: viewStore.bindingForRoute(.request),
destination: {
RequestView(store: store.requestStore())
}
)
Spacer()
}
}
func sendButton(_ viewStore: HomeViewStore) -> some View {
VStack {
Spacer()
@ -161,7 +129,10 @@ extension HomeStore {
initialState: .placeholder,
reducer: .default.debug(),
environment: HomeEnvironment(
mnemonicSeedPhraseProvider: .live,
scheduler: DispatchQueue.main.eraseToAnyScheduler(),
walletStorage: .live(),
wrappedDerivationTool: .live(),
wrappedSDKSynchronizer: LiveWrappedSDKSynchronizer()
)
)

View File

@ -19,6 +19,7 @@ struct SandboxView: View {
case .history:
TransactionHistoryView(store: store.historyStore())
case .send:
EmptyView()
SendView(
store: .init(
initialState: .placeholder,
@ -26,7 +27,13 @@ struct SandboxView: View {
whenDone: { SandboxViewStore(store).send(.updateRoute(nil)) }
)
.debug(),
environment: ()
environment: SendEnvironment(
mnemonicSeedPhraseProvider: .live,
scheduler: DispatchQueue.main.eraseToAnyScheduler(),
walletStorage: .live(),
wrappedDerivationTool: .live(),
wrappedSDKSynchronizer: LiveWrappedSDKSynchronizer()
)
)
)
case .recoveryPhraseDisplay:

View File

@ -1,41 +1,126 @@
import SwiftUI
import ComposableArchitecture
import ZcashLightClientKit
struct Transaction: Equatable {
var amount: Int64
var memo: String
var toAddress: String
var amountString: String {
get { amount == 0 ? "" : String(format: "%.7f", amount.asHumanReadableZecBalance()) }
set { amount = Int64((newValue as NSString).doubleValue * 100_000_000) }
}
}
extension Transaction {
static var placeholder: Self {
.init(
amount: 0,
memo: "",
toAddress: ""
)
}
}
struct SendState: Equatable {
enum Route: Equatable {
case showConfirmation
case showSent
case success
case failure
case done
}
var route: Route?
var isSendingTransaction = false
var transaction: Transaction
var route: SendView.Route?
}
enum SendAction: Equatable {
case sendConfirmationPressed
case sendTransactionResult(Result<TransactionState, NSError>)
case updateTransaction(Transaction)
case updateRoute(SendView.Route?)
case updateRoute(SendState.Route?)
}
struct SendEnvironment {
let mnemonicSeedPhraseProvider: MnemonicSeedPhraseProvider
let scheduler: AnySchedulerOf<DispatchQueue>
let walletStorage: WalletStorageInteractor
let wrappedDerivationTool: WrappedDerivationTool
let wrappedSDKSynchronizer: WrappedSDKSynchronizer
}
// MARK: - SendReducer
typealias SendReducer = Reducer<SendState, SendAction, Void>
typealias SendReducer = Reducer<SendState, SendAction, SendEnvironment>
extension SendReducer {
private struct SyncStatusUpdatesID: Hashable {}
static let `default` = Reducer<SendState, SendAction, Void> { state, action, _ in
static let `default` = Reducer<SendState, SendAction, SendEnvironment> { state, action, environment in
switch action {
case let .updateTransaction(transaction):
state.transaction = transaction
return .none
case .updateRoute(.failure):
state.route = .failure
state.isSendingTransaction = false
return .none
case let .updateRoute(route):
state.route = route
return .none
case .sendConfirmationPressed:
guard !state.isSendingTransaction else {
return .none
}
do {
let storedWallet = try environment.walletStorage.exportWallet()
let seedBytes = try environment.mnemonicSeedPhraseProvider.toSeed(storedWallet.seedPhrase)
guard let spendingKey = try environment.wrappedDerivationTool.deriveSpendingKeys(seedBytes, 1).first else {
return Effect(value: .updateRoute(.failure))
}
state.isSendingTransaction = true
return environment.wrappedSDKSynchronizer.sendTransaction(
with: spendingKey,
zatoshi: Int64(state.transaction.amount),
to: state.transaction.toAddress,
memo: state.transaction.memo,
from: 0
)
.receive(on: environment.scheduler)
.map(SendAction.sendTransactionResult)
.eraseToEffect()
} catch {
return Effect(value: .updateRoute(.failure))
}
case .sendTransactionResult(let result):
state.isSendingTransaction = false
do {
let transaction = try result.get()
return Effect(value: .updateRoute(.success))
} catch {
return Effect(value: .updateRoute(.failure))
}
}
}
static func `default`(whenDone: @escaping () -> Void) -> SendReducer {
SendReducer { state, action, _ in
SendReducer { state, action, environment in
switch action {
case let .updateRoute(route) where route == .done:
return Effect.fireAndForget(whenDone)
default:
return Self.default.run(&state, action, ())
return Self.default.run(&state, action, environment)
}
}
}
@ -57,31 +142,38 @@ extension SendViewStore {
)
}
var routeBinding: Binding<SendView.Route?> {
var routeBinding: Binding<SendState.Route?> {
self.binding(
get: \.route,
send: SendAction.updateRoute
)
}
var bindingForApprove: Binding<Bool> {
var bindingForConfirmation: Binding<Bool> {
self.routeBinding.map(
extract: { $0 == .showApprove || self.bindingForSent.wrappedValue },
embed: { $0 ? SendView.Route.showApprove : nil }
extract: { $0 == .showConfirmation || self.bindingForSuccess.wrappedValue || self.bindingForFailure.wrappedValue },
embed: { $0 ? SendState.Route.showConfirmation : nil }
)
}
var bindingForSent: Binding<Bool> {
var bindingForSuccess: Binding<Bool> {
self.routeBinding.map(
extract: { $0 == .showSent || self.bindingForDone.wrappedValue },
embed: { $0 ? SendView.Route.showSent : SendView.Route.showApprove }
extract: { $0 == .success || self.bindingForDone.wrappedValue },
embed: { $0 ? SendState.Route.success : SendState.Route.showConfirmation }
)
}
var bindingForFailure: Binding<Bool> {
self.routeBinding.map(
extract: { $0 == .failure || self.bindingForDone.wrappedValue },
embed: { $0 ? SendState.Route.failure : SendState.Route.showConfirmation }
)
}
var bindingForDone: Binding<Bool> {
self.routeBinding.map(
extract: { $0 == .done },
embed: { $0 ? SendView.Route.done : SendView.Route.showSent }
embed: { $0 ? SendState.Route.done : SendState.Route.showConfirmation }
)
}
}
@ -90,6 +182,17 @@ extension SendViewStore {
extension SendState {
static var placeholder: Self {
.init(transaction: .placeholder, route: nil)
.init(route: nil, transaction: .placeholder)
}
static var emptyPlaceholder: Self {
.init(
route: nil,
transaction: .init(
amount: 0,
memo: "",
toAddress: ""
)
)
}
}

View File

@ -1,38 +0,0 @@
import SwiftUI
import ComposableArchitecture
struct Approve: View {
let transaction: Transaction
@Binding var isComplete: Bool
var body: some View {
VStack {
Button(
action: { isComplete = true },
label: { Text("Go to sent") }
)
.primaryButtonStyle
.frame(height: 50)
.padding()
Text("\(String(dumping: transaction))")
Text("\(String(dumping: isComplete))")
Spacer()
}
.navigationTitle(Text("2. Approve"))
}
}
struct Approve_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
StateContainer(initialState: (Transaction.placeholder, false)) {
Approve(
transaction: $0.0.wrappedValue,
isComplete: $0.1
)
}
}
}
}

View File

@ -0,0 +1,104 @@
import SwiftUI
import ComposableArchitecture
struct CreateTransaction: View {
@Binding var transaction: Transaction
@Binding var isComplete: Bool
var body: some View {
UITextView.appearance().backgroundColor = .clear
return VStack {
VStack {
Text("ZEC Amount")
TextField(
"ZEC Amount",
text: $transaction.amountString
)
.padding()
.background(Color.white)
.foregroundColor(Asset.Colors.Text.importSeedEditor.color)
}
.padding()
VStack {
Text("To Address")
TextField(
"Address",
text: $transaction.toAddress
)
.font(.system(size: 14))
.padding()
.background(Color.white)
.foregroundColor(Asset.Colors.Text.importSeedEditor.color)
}
.padding()
VStack {
Text("Memo")
TextEditor(text: $transaction.memo)
.frame(maxWidth: .infinity, maxHeight: 150, alignment: .center)
.importSeedEditorModifier()
}
.padding()
Button(
action: { isComplete = true },
label: { Text("Send") }
)
.activeButtonStyle
.frame(height: 50)
.padding()
Spacer()
}
.padding()
.applyScreenBackground()
}
}
// MARK: - Previews
struct Create_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
StateContainer(
initialState: (
Transaction.placeholder,
false
)
) {
CreateTransaction(
transaction: $0.0,
isComplete: $0.1
)
}
.navigationBarTitleDisplayMode(.inline)
.preferredColorScheme(.dark)
}
}
}
#if DEBUG
extension SendStore {
static var placeholder: SendStore {
return SendStore(
initialState: .init(
route: nil,
transaction: .placeholder
),
reducer: .default,
environment: SendEnvironment(
mnemonicSeedPhraseProvider: .live,
scheduler: DispatchQueue.main.eraseToAnyScheduler(),
walletStorage: .live(),
wrappedDerivationTool: .live(),
wrappedSDKSynchronizer: LiveWrappedSDKSynchronizer()
)
)
}
}
#endif

View File

@ -1,73 +0,0 @@
import SwiftUI
import ComposableArchitecture
struct Create: View {
@Binding var transaction: Transaction
@Binding var isComplete: Bool
var body: some View {
VStack {
Button(
action: { isComplete = true },
label: { Text("Go To Approve") }
)
.primaryButtonStyle
.frame(height: 50)
.padding()
TextField(
"Amount",
text: $transaction
.amount
.compactMap(
extract: String.init,
embed: UInt.init
)
)
.padding()
TextField(
"Address",
text: $transaction.toAddress
)
Text("\(String(dumping: transaction))")
Text("\(String(dumping: isComplete))")
Spacer()
}
.padding()
.navigationTitle(Text("1. Create"))
}
}
// MARK: - Previews
struct Create_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
StateContainer(initialState: (Transaction.placeholder, false)) {
Create(
transaction: $0.0,
isComplete: $0.1
)
}
.navigationBarTitleDisplayMode(.inline)
}
}
}
#if DEBUG
extension SendStore {
static var placeholder: SendStore {
return SendStore(
initialState: .init(
transaction: .placeholder,
route: nil
),
reducer: .default,
environment: ()
)
}
}
#endif

View File

@ -1,57 +1,27 @@
import SwiftUI
import ComposableArchitecture
struct Transaction: Identifiable, Equatable, Hashable {
var id: Int
var amount: UInt
var memo: String
var toAddress: String
var fromAddress: String
}
extension Transaction {
static var placeholder: Self {
.init(
id: 2,
amount: 123,
memo: "defaultMemo",
toAddress: "ToAddress",
fromAddress: "FromAddress"
)
}
}
struct SendView: View {
enum Route: Equatable {
case showApprove
case showSent
case done
}
let store: Store<SendState, SendAction>
var body: some View {
WithViewStore(store) { viewStore in
Create(
CreateTransaction(
transaction: viewStore.bindingForTransaction,
isComplete: viewStore.bindingForApprove
isComplete: viewStore.bindingForConfirmation
)
.navigationLinkEmpty(
isActive: viewStore.bindingForApprove,
isActive: viewStore.bindingForConfirmation,
destination: {
Approve(
transaction: viewStore.transaction,
isComplete: viewStore.bindingForSent
)
.navigationLinkEmpty(
isActive: viewStore.bindingForSent,
destination: {
Sent(
transaction: viewStore.transaction,
isComplete: viewStore.bindingForDone
)
}
)
TransactionConfirmation(viewStore: viewStore)
.navigationLinkEmpty(
isActive: viewStore.bindingForSuccess,
destination: { TransactionSent(viewStore: viewStore) }
)
.navigationLinkEmpty(
isActive: viewStore.bindingForFailure,
destination: { TransactionFailed(viewStore: viewStore) }
)
}
)
}
@ -64,11 +34,17 @@ struct SendView_Previews: PreviewProvider {
SendView(
store: .init(
initialState: .init(
transaction: .placeholder,
route: nil
route: nil,
transaction: .placeholder
),
reducer: .default,
environment: ()
environment: SendEnvironment(
mnemonicSeedPhraseProvider: .live,
scheduler: DispatchQueue.main.eraseToAnyScheduler(),
walletStorage: .live(),
wrappedDerivationTool: .live(),
wrappedSDKSynchronizer: LiveWrappedSDKSynchronizer()
)
)
)
.navigationBarTitleDisplayMode(.inline)

View File

@ -1,33 +0,0 @@
import SwiftUI
import ComposableArchitecture
struct Sent: View {
var transaction: Transaction
@Binding var isComplete: Bool
var body: some View {
VStack {
Button(
action: {
isComplete = true
},
label: { Text("Done") }
)
.primaryButtonStyle
.frame(height: 50)
.padding()
Text("\(String(dumping: transaction))")
Text("\(String(dumping: isComplete))")
Spacer()
}
.navigationTitle(Text("3. Sent"))
}
}
struct Done_Previews: PreviewProvider {
static var previews: some View {
Sent(transaction: .placeholder, isComplete: .constant(false))
}
}

View File

@ -0,0 +1,47 @@
import SwiftUI
import ComposableArchitecture
struct TransactionConfirmation: View {
let viewStore: SendViewStore
var body: some View {
VStack {
Text("Send \(String(format: "%.7f", Int64(viewStore.transaction.amount).asHumanReadableZecBalance())) ZEC")
.padding()
Text("To \(viewStore.transaction.toAddress)")
.padding()
Spacer()
Button(
action: { viewStore.send(.sendConfirmationPressed) },
label: { Text("Confirm") }
)
.activeButtonStyle
.frame(height: 50)
.padding()
Spacer()
}
.applyScreenBackground()
}
}
struct Confirmation_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
StateContainer(
initialState: (
Transaction.placeholder,
false
)
) { _ in
TransactionConfirmation(
viewStore: ViewStore(.placeholder)
)
}
}
.preferredColorScheme(.dark)
}
}

View File

@ -0,0 +1,32 @@
import SwiftUI
import ComposableArchitecture
struct TransactionFailed: View {
let viewStore: SendViewStore
var body: some View {
VStack {
Text("Sending transaction failed")
Button(
action: {
viewStore.send(.updateRoute(.done))
},
label: { Text("Close") }
)
.primaryButtonStyle
.frame(height: 50)
.padding()
Spacer()
}
.applyScreenBackground()
.navigationBarHidden(true)
}
}
struct TransactionFailed_Previews: PreviewProvider {
static var previews: some View {
TransactionFailed(viewStore: ViewStore(.placeholder))
}
}

View File

@ -0,0 +1,34 @@
import SwiftUI
import ComposableArchitecture
struct TransactionSent: View {
let viewStore: SendViewStore
var body: some View {
VStack {
Text("Sending transaction succeeded")
Button(
action: {
viewStore.send(.updateRoute(.done))
},
label: { Text("Close") }
)
.primaryButtonStyle
.frame(height: 50)
.padding()
Text("\(String(dumping: viewStore.transaction))")
Spacer()
}
.applyScreenBackground()
.navigationBarHidden(true)
}
}
struct TransactionSent_Previews: PreviewProvider {
static var previews: some View {
TransactionSent(viewStore: ViewStore(.placeholder))
}
}

View File

@ -53,6 +53,14 @@ protocol WrappedSDKSynchronizer {
func getTransparentAddress(account: Int) -> TransparentAddress?
func getShieldedAddress(account: Int) -> SaplingShieldedAddress?
func sendTransaction(
with spendingKey: String,
zatoshi: Int64,
to recipientAddress: String,
memo: String?,
from account: Int
) -> Effect<Result<TransactionState, NSError>, Never>
}
extension WrappedSDKSynchronizer {
@ -139,13 +147,31 @@ class LiveWrappedSDKSynchronizer: WrappedSDKSynchronizer {
}
func getAllTransactions() -> Effect<[TransactionState], Never> {
return .merge(
getAllClearedTransactions(),
getAllPendingTransactions()
)
.flatMap(Publishers.Sequence.init(sequence:))
.collect()
.eraseToEffect()
if let pendingTransactions = try? synchronizer?.allPendingTransactions(),
let clearedTransactions = try? synchronizer?.allClearedTransactions(),
let syncedBlockHeight = synchronizer?.latestScannedHeight {
let clearedTxs = clearedTransactions.map {
TransactionState.init(confirmedTransaction: $0, sent: ($0.toAddress != nil))
}
let pendingTxs = pendingTransactions.map {
TransactionState.init(pendingTransaction: $0, latestBlockHeight: syncedBlockHeight)
}
let txs = clearedTxs.filter { cleared in
pendingTxs.first { pending in
pending.id == cleared.id
} == nil
}
return .merge(
Effect(value: txs),
Effect(value: pendingTxs)
)
.flatMap(Publishers.Sequence.init(sequence:))
.collect()
.eraseToEffect()
}
return .none
}
func getTransparentAddress(account: Int) -> TransparentAddress? {
@ -155,6 +181,34 @@ class LiveWrappedSDKSynchronizer: WrappedSDKSynchronizer {
func getShieldedAddress(account: Int) -> SaplingShieldedAddress? {
synchronizer?.getShieldedAddress(accountIndex: account)
}
func sendTransaction(
with spendingKey: String,
zatoshi: Int64,
to recipientAddress: String,
memo: String?,
from account: Int
) -> Effect<Result<TransactionState, NSError>, Never> {
Deferred {
Future { [weak self] promise in
self?.synchronizer?.sendToAddress(
spendingKey: spendingKey,
zatoshi: zatoshi,
toAddress: recipientAddress,
memo: memo,
from: account) { result in
switch result {
case .failure(let error as NSError):
promise(.failure(error))
case .success(let pendingTx):
promise(.success(TransactionState(pendingTransaction: pendingTx)))
}
}
}
}
.mapError { $0 as NSError }
.catchToEffect()
}
}
class MockWrappedSDKSynchronizer: WrappedSDKSynchronizer {
@ -257,6 +311,29 @@ class MockWrappedSDKSynchronizer: WrappedSDKSynchronizer {
func getTransparentAddress(account: Int) -> TransparentAddress? { nil }
func getShieldedAddress(account: Int) -> SaplingShieldedAddress? { nil }
func sendTransaction(
with spendingKey: String,
zatoshi: Int64,
to recipientAddress: String,
memo: String?,
from account: Int
) -> Effect<Result<TransactionState, NSError>, Never> {
let transactionState = TransactionState(
expirationHeight: 40,
memo: "test",
minedHeight: 50,
shielded: true,
zAddress: "tteafadlamnelkqe",
date: Date.init(timeIntervalSince1970: 1234567),
id: "id",
status: .paid(success: true),
subtitle: "sub",
zecAmount: 10
)
return Effect(value: Result.success(transactionState))
}
}
class TestWrappedSDKSynchronizer: WrappedSDKSynchronizer {
@ -339,4 +416,14 @@ class TestWrappedSDKSynchronizer: WrappedSDKSynchronizer {
func getTransparentAddress(account: Int) -> TransparentAddress? { nil }
func getShieldedAddress(account: Int) -> SaplingShieldedAddress? { nil }
func sendTransaction(
with spendingKey: String,
zatoshi: Int64,
to recipientAddress: String,
memo: String?,
from account: Int
) -> Effect<Result<TransactionState, NSError>, Never> {
return Effect(value: Result.failure(SynchronizerError.criticalError as NSError))
}
}

View File

@ -0,0 +1,120 @@
//
// SendTests.swift
// secantTests
//
// Created by Lukáš Korba on 02.05.2022.
//
import XCTest
@testable import secant_testnet
import ComposableArchitecture
import ZcashLightClientKit
class SendTests: XCTestCase {
var storage = WalletStorage(secItem: .live)
override func setUp() {
super.setUp()
storage.zcashStoredWalletPrefix = "test_"
storage.deleteData(forKey: WalletStorage.Constants.zcashStoredWallet)
}
func testSendSucceeded() throws {
// the test needs to pass the exportWallet() so we simulate some in the keychain
try storage.importWallet(bip39: "one two three", birthday: nil)
// setup the store and environment to be fully mocked
let testScheduler = DispatchQueue.test
let testEnvironment = SendEnvironment(
mnemonicSeedPhraseProvider: .mock,
scheduler: testScheduler.eraseToAnyScheduler(),
walletStorage: .live(walletStorage: storage),
wrappedDerivationTool: .live(),
wrappedSDKSynchronizer: MockWrappedSDKSynchronizer()
)
let store = TestStore(
initialState: .placeholder,
reducer: SendReducer.default,
environment: testEnvironment
)
// simulate the sending confirmation button to be pressed
store.send(.sendConfirmationPressed) { state in
// once sending is confirmed, the attemts to try to send again by pressing the button
// needs to be eliminated, indicated by the flag `isSendingTransaction`, need to be true
state.isSendingTransaction = true
}
testScheduler.advance(by: 0.01)
let transactionState = TransactionState(
expirationHeight: 40,
memo: "test",
minedHeight: 50,
shielded: true,
zAddress: "tteafadlamnelkqe",
date: Date.init(timeIntervalSince1970: 1234567),
id: "id",
status: .paid(success: true),
subtitle: "sub",
zecAmount: 10
)
// check the success transaction to be received back
store.receive(.sendTransactionResult(Result.success(transactionState))) { state in
// from this moment on the sending next transaction is allowed again
// the 'isSendingTransaction' needs to be false again
state.isSendingTransaction = false
}
// all went well, the success screen is triggered
store.receive(.updateRoute(.success)) { state in
state.route = .success
}
}
func testSendFailed() throws {
// the test needs to pass the exportWallet() so we simulate some in the keychain
try storage.importWallet(bip39: "one two three", birthday: nil)
// setup the store and environment to be fully mocked
let testScheduler = DispatchQueue.test
let testEnvironment = SendEnvironment(
mnemonicSeedPhraseProvider: .mock,
scheduler: testScheduler.eraseToAnyScheduler(),
walletStorage: .live(walletStorage: storage),
wrappedDerivationTool: .live(),
wrappedSDKSynchronizer: TestWrappedSDKSynchronizer()
)
let store = TestStore(
initialState: .placeholder,
reducer: SendReducer.default,
environment: testEnvironment
)
// simulate the sending confirmation button to be pressed
store.send(.sendConfirmationPressed) { state in
// once sending is confirmed, the attemts to try to send again by pressing the button
// needs to be eliminated, indicated by the flag `isSendingTransaction`, need to be true
state.isSendingTransaction = true
}
testScheduler.advance(by: 0.01)
// check the failure transaction to be received back
store.receive(.sendTransactionResult(Result.failure(SynchronizerError.criticalError as NSError))) { state in
// from this moment on the sending next transaction is allowed again
// the 'isSendingTransaction' needs to be false again
state.isSendingTransaction = false
}
// the failure screen is triggered as expected
store.receive(.updateRoute(.failure)) { state in
state.route = .failure
}
}
}