From c506cef20c8511607dd423150a8ebbfbbf1faf52 Mon Sep 17 00:00:00 2001 From: Dariusz Rybicki <dariusz@elixxir.io> Date: Wed, 8 Jun 2022 12:40:21 +0200 Subject: [PATCH 01/13] Add MyIdentityFeature library to example app --- .../xcschemes/MyIdentityFeature.xcscheme | 78 +++++++++++++++++++ .../xcschemes/example-app.xcscheme | 24 ++++++ Example/example-app/Package.swift | 21 +++++ .../MyIdentityFeature/MyIdentityFeature.swift | 19 +++++ .../MyIdentityFeature/MyIdentityView.swift | 35 +++++++++ .../MyIdentityFeatureTests.swift | 9 +++ 6 files changed, 186 insertions(+) create mode 100644 Example/example-app/.swiftpm/xcode/xcshareddata/xcschemes/MyIdentityFeature.xcscheme create mode 100644 Example/example-app/Sources/MyIdentityFeature/MyIdentityFeature.swift create mode 100644 Example/example-app/Sources/MyIdentityFeature/MyIdentityView.swift create mode 100644 Example/example-app/Tests/MyIdentityFeatureTests/MyIdentityFeatureTests.swift diff --git a/Example/example-app/.swiftpm/xcode/xcshareddata/xcschemes/MyIdentityFeature.xcscheme b/Example/example-app/.swiftpm/xcode/xcshareddata/xcschemes/MyIdentityFeature.xcscheme new file mode 100644 index 00000000..e69b1d44 --- /dev/null +++ b/Example/example-app/.swiftpm/xcode/xcshareddata/xcschemes/MyIdentityFeature.xcscheme @@ -0,0 +1,78 @@ +<?xml version="1.0" encoding="UTF-8"?> +<Scheme + LastUpgradeVersion = "1340" + version = "1.3"> + <BuildAction + parallelizeBuildables = "YES" + buildImplicitDependencies = "YES"> + <BuildActionEntries> + <BuildActionEntry + buildForTesting = "YES" + buildForRunning = "YES" + buildForProfiling = "YES" + buildForArchiving = "YES" + buildForAnalyzing = "YES"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "MyIdentityFeature" + BuildableName = "MyIdentityFeature" + BlueprintName = "MyIdentityFeature" + ReferencedContainer = "container:"> + </BuildableReference> + </BuildActionEntry> + </BuildActionEntries> + </BuildAction> + <TestAction + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + shouldUseLaunchSchemeArgsEnv = "YES" + codeCoverageEnabled = "YES"> + <Testables> + <TestableReference + skipped = "NO"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "MyIdentityFeatureTests" + BuildableName = "MyIdentityFeatureTests" + BlueprintName = "MyIdentityFeatureTests" + ReferencedContainer = "container:"> + </BuildableReference> + </TestableReference> + </Testables> + </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 = "MyIdentityFeature" + BuildableName = "MyIdentityFeature" + BlueprintName = "MyIdentityFeature" + ReferencedContainer = "container:"> + </BuildableReference> + </MacroExpansion> + </ProfileAction> + <AnalyzeAction + buildConfiguration = "Debug"> + </AnalyzeAction> + <ArchiveAction + buildConfiguration = "Release" + revealArchiveInOrganizer = "YES"> + </ArchiveAction> +</Scheme> diff --git a/Example/example-app/.swiftpm/xcode/xcshareddata/xcschemes/example-app.xcscheme b/Example/example-app/.swiftpm/xcode/xcshareddata/xcschemes/example-app.xcscheme index ac213623..1a4d13ff 100644 --- a/Example/example-app/.swiftpm/xcode/xcshareddata/xcschemes/example-app.xcscheme +++ b/Example/example-app/.swiftpm/xcode/xcshareddata/xcschemes/example-app.xcscheme @@ -48,6 +48,20 @@ ReferencedContainer = "container:"> </BuildableReference> </BuildActionEntry> + <BuildActionEntry + buildForTesting = "YES" + buildForRunning = "YES" + buildForProfiling = "YES" + buildForArchiving = "YES" + buildForAnalyzing = "YES"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "MyIdentityFeature" + BuildableName = "MyIdentityFeature" + BlueprintName = "MyIdentityFeature" + ReferencedContainer = "container:"> + </BuildableReference> + </BuildActionEntry> <BuildActionEntry buildForTesting = "YES" buildForRunning = "YES" @@ -101,6 +115,16 @@ ReferencedContainer = "container:"> </BuildableReference> </TestableReference> + <TestableReference + skipped = "NO"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "MyIdentityFeatureTests" + BuildableName = "MyIdentityFeatureTests" + BlueprintName = "MyIdentityFeatureTests" + ReferencedContainer = "container:"> + </BuildableReference> + </TestableReference> <TestableReference skipped = "NO"> <BuildableReference diff --git a/Example/example-app/Package.swift b/Example/example-app/Package.swift index 55d54626..cb8aead7 100644 --- a/Example/example-app/Package.swift +++ b/Example/example-app/Package.swift @@ -32,6 +32,10 @@ let package = Package( name: "LandingFeature", targets: ["LandingFeature"] ), + .library( + name: "MyIdentityFeature", + targets: ["MyIdentityFeature"] + ), .library( name: "SessionFeature", targets: ["SessionFeature"] @@ -132,6 +136,23 @@ let package = Package( ], swiftSettings: swiftSettings ), + .target( + name: "MyIdentityFeature", + dependencies: [ + .product( + name: "ComposableArchitecture", + package: "swift-composable-architecture" + ), + ], + swiftSettings: swiftSettings + ), + .testTarget( + name: "MyIdentityFeatureTests", + dependencies: [ + .target(name: "MyIdentityFeature"), + ], + swiftSettings: swiftSettings + ), .target( name: "SessionFeature", dependencies: [ diff --git a/Example/example-app/Sources/MyIdentityFeature/MyIdentityFeature.swift b/Example/example-app/Sources/MyIdentityFeature/MyIdentityFeature.swift new file mode 100644 index 00000000..a9b97361 --- /dev/null +++ b/Example/example-app/Sources/MyIdentityFeature/MyIdentityFeature.swift @@ -0,0 +1,19 @@ +import ComposableArchitecture + +public struct MyIdentityState: Equatable { + public init() {} +} + +public enum MyIdentityAction: Equatable {} + +public struct MyIdentityEnvironment { + public init() {} +} + +public let myIdentityReducer = Reducer<MyIdentityState, MyIdentityAction, MyIdentityEnvironment>.empty + +#if DEBUG +extension MyIdentityEnvironment { + public static let failing = MyIdentityEnvironment() +} +#endif diff --git a/Example/example-app/Sources/MyIdentityFeature/MyIdentityView.swift b/Example/example-app/Sources/MyIdentityFeature/MyIdentityView.swift new file mode 100644 index 00000000..4061024b --- /dev/null +++ b/Example/example-app/Sources/MyIdentityFeature/MyIdentityView.swift @@ -0,0 +1,35 @@ +import ComposableArchitecture +import SwiftUI + +public struct MyIdentityView: View { + public init(store: Store<MyIdentityState, MyIdentityAction>) { + self.store = store + } + + let store: Store<MyIdentityState, MyIdentityAction> + + struct ViewState: Equatable { + init(state: MyIdentityState) {} + } + + public var body: some View { + WithViewStore(store.scope(state: ViewState.init)) { viewStore in + Text("MyIdentityView") + } + } +} + +#if DEBUG +public struct MyIdentityView_Previews: PreviewProvider { + public static var previews: some View { + NavigationView { + MyIdentityView(store: .init( + initialState: .init(), + reducer: .empty, + environment: () + )) + } + .navigationViewStyle(.stack) + } +} +#endif diff --git a/Example/example-app/Tests/MyIdentityFeatureTests/MyIdentityFeatureTests.swift b/Example/example-app/Tests/MyIdentityFeatureTests/MyIdentityFeatureTests.swift new file mode 100644 index 00000000..f4a6a410 --- /dev/null +++ b/Example/example-app/Tests/MyIdentityFeatureTests/MyIdentityFeatureTests.swift @@ -0,0 +1,9 @@ +import ComposableArchitecture +import XCTest +@testable import MyIdentityFeature + +final class MyIdentityFeatureTests: XCTestCase { + func testExample() { + XCTAssert(true) + } +} -- GitLab From 1f170097b2f6621ea6b1df04762d780a36282f3d Mon Sep 17 00:00:00 2001 From: Dariusz Rybicki <dariusz@elixxir.io> Date: Wed, 8 Jun 2022 12:57:11 +0200 Subject: [PATCH 02/13] Present MyIdentityView from SessionView --- Example/example-app/Package.swift | 2 + .../example-app/Sources/AppFeature/App.swift | 5 ++- .../MyIdentityFeature/MyIdentityFeature.swift | 8 +++- .../MyIdentityFeature/MyIdentityView.swift | 2 +- .../SessionFeature/SessionFeature.swift | 40 +++++++++++++++++-- .../Sources/SessionFeature/SessionView.swift | 25 ++++++++++++ .../SessionFeatureTests.swift | 22 ++++++++++ 7 files changed, 97 insertions(+), 7 deletions(-) diff --git a/Example/example-app/Package.swift b/Example/example-app/Package.swift index cb8aead7..0c0ef222 100644 --- a/Example/example-app/Package.swift +++ b/Example/example-app/Package.swift @@ -62,6 +62,7 @@ let package = Package( dependencies: [ .target(name: "ErrorFeature"), .target(name: "LandingFeature"), + .target(name: "MyIdentityFeature"), .target(name: "SessionFeature"), .product( name: "ElixxirDAppsSDK", @@ -157,6 +158,7 @@ let package = Package( name: "SessionFeature", dependencies: [ .target(name: "ErrorFeature"), + .target(name: "MyIdentityFeature"), .product( name: "ComposableArchitecture", package: "swift-composable-architecture" diff --git a/Example/example-app/Sources/AppFeature/App.swift b/Example/example-app/Sources/AppFeature/App.swift index 341cb46c..06477b12 100644 --- a/Example/example-app/Sources/AppFeature/App.swift +++ b/Example/example-app/Sources/AppFeature/App.swift @@ -3,6 +3,7 @@ import ComposableArchitecture import ElixxirDAppsSDK import ErrorFeature import LandingFeature +import MyIdentityFeature import SessionFeature import SwiftUI @@ -44,7 +45,9 @@ extension AppEnvironment { session: SessionEnvironment( getClient: { clientSubject.value }, bgScheduler: bgScheduler, - mainScheduler: mainScheduler + mainScheduler: mainScheduler, + makeId: UUID.init, + myIdentity: MyIdentityEnvironment() ) ) } diff --git a/Example/example-app/Sources/MyIdentityFeature/MyIdentityFeature.swift b/Example/example-app/Sources/MyIdentityFeature/MyIdentityFeature.swift index a9b97361..fb4c7200 100644 --- a/Example/example-app/Sources/MyIdentityFeature/MyIdentityFeature.swift +++ b/Example/example-app/Sources/MyIdentityFeature/MyIdentityFeature.swift @@ -1,7 +1,13 @@ import ComposableArchitecture public struct MyIdentityState: Equatable { - public init() {} + public init( + id: UUID + ) { + self.id = id + } + + public var id: UUID } public enum MyIdentityAction: Equatable {} diff --git a/Example/example-app/Sources/MyIdentityFeature/MyIdentityView.swift b/Example/example-app/Sources/MyIdentityFeature/MyIdentityView.swift index 4061024b..c50941b6 100644 --- a/Example/example-app/Sources/MyIdentityFeature/MyIdentityView.swift +++ b/Example/example-app/Sources/MyIdentityFeature/MyIdentityView.swift @@ -24,7 +24,7 @@ public struct MyIdentityView_Previews: PreviewProvider { public static var previews: some View { NavigationView { MyIdentityView(store: .init( - initialState: .init(), + initialState: .init(id: UUID()), reducer: .empty, environment: () )) diff --git a/Example/example-app/Sources/SessionFeature/SessionFeature.swift b/Example/example-app/Sources/SessionFeature/SessionFeature.swift index 0756f738..f3ba9326 100644 --- a/Example/example-app/Sources/SessionFeature/SessionFeature.swift +++ b/Example/example-app/Sources/SessionFeature/SessionFeature.swift @@ -2,24 +2,28 @@ import Combine import ComposableArchitecture import ElixxirDAppsSDK import ErrorFeature +import MyIdentityFeature public struct SessionState: Equatable { public init( id: UUID, networkFollowerStatus: NetworkFollowerStatus? = nil, isNetworkHealthy: Bool? = nil, - error: ErrorState? = nil + error: ErrorState? = nil, + myIdentity: MyIdentityState? = nil ) { self.id = id self.networkFollowerStatus = networkFollowerStatus self.isNetworkHealthy = isNetworkHealthy self.error = error + self.myIdentity = myIdentity } public var id: UUID public var networkFollowerStatus: NetworkFollowerStatus? public var isNetworkHealthy: Bool? public var error: ErrorState? + public var myIdentity: MyIdentityState? } public enum SessionAction: Equatable { @@ -31,23 +35,32 @@ public enum SessionAction: Equatable { case monitorNetworkHealth(Bool) case didUpdateNetworkHealth(Bool?) case didDismissError + case presentMyIdentity + case didDismissMyIdentity case error(ErrorAction) + case myIdentity(MyIdentityAction) } public struct SessionEnvironment { public init( getClient: @escaping () -> Client?, bgScheduler: AnySchedulerOf<DispatchQueue>, - mainScheduler: AnySchedulerOf<DispatchQueue> + mainScheduler: AnySchedulerOf<DispatchQueue>, + makeId: @escaping () -> UUID, + myIdentity: MyIdentityEnvironment ) { self.getClient = getClient self.bgScheduler = bgScheduler self.mainScheduler = mainScheduler + self.makeId = makeId + self.myIdentity = myIdentity } public var getClient: () -> Client? public var bgScheduler: AnySchedulerOf<DispatchQueue> public var mainScheduler: AnySchedulerOf<DispatchQueue> + public var makeId: () -> UUID + public var myIdentity: MyIdentityEnvironment } public let sessionReducer = Reducer<SessionState, SessionAction, SessionEnvironment> @@ -129,17 +142,36 @@ public let sessionReducer = Reducer<SessionState, SessionAction, SessionEnvironm state.error = nil return .none - case .error(_): + case .presentMyIdentity: + if state.myIdentity == nil { + state.myIdentity = MyIdentityState(id: env.makeId()) + } + return .none + + case .didDismissMyIdentity: + state.myIdentity = nil + return .none + + case .error(_), .myIdentity(_): return .none } } +.presenting( + myIdentityReducer, + state: .keyPath(\.myIdentity), + id: .keyPath(\.?.id), + action: /SessionAction.myIdentity, + environment: \.myIdentity +) #if DEBUG extension SessionEnvironment { public static let failing = SessionEnvironment( getClient: { .failing }, bgScheduler: .failing, - mainScheduler: .failing + mainScheduler: .failing, + makeId: { fatalError() }, + myIdentity: .failing ) } #endif diff --git a/Example/example-app/Sources/SessionFeature/SessionView.swift b/Example/example-app/Sources/SessionFeature/SessionView.swift index 395cfcb1..9f3c419b 100644 --- a/Example/example-app/Sources/SessionFeature/SessionView.swift +++ b/Example/example-app/Sources/SessionFeature/SessionView.swift @@ -2,6 +2,7 @@ import ComposableArchitecture import ComposablePresentation import ElixxirDAppsSDK import ErrorFeature +import MyIdentityFeature import SwiftUI public struct SessionView: View { @@ -49,6 +50,18 @@ public struct SessionView: View { } header: { Text("Network health") } + + Section { + Button { + viewStore.send(.presentMyIdentity) + } label: { + HStack { + Text("My identity") + Spacer() + Image(systemName: "chevron.forward") + } + } + } } .navigationTitle("Session") .task { @@ -64,6 +77,18 @@ public struct SessionView: View { }, content: ErrorView.init(store:) ) + .background( + NavigationLinkWithStore( + store.scope( + state: \.myIdentity, + action: SessionAction.myIdentity + ), + onDeactivate: { + viewStore.send(.didDismissMyIdentity) + }, + destination: MyIdentityView.init(store:) + ) + ) } } } diff --git a/Example/example-app/Tests/SessionFeatureTests/SessionFeatureTests.swift b/Example/example-app/Tests/SessionFeatureTests/SessionFeatureTests.swift index 2ada840d..02039168 100644 --- a/Example/example-app/Tests/SessionFeatureTests/SessionFeatureTests.swift +++ b/Example/example-app/Tests/SessionFeatureTests/SessionFeatureTests.swift @@ -1,6 +1,7 @@ import ComposableArchitecture import ElixxirDAppsSDK import ErrorFeature +import MyIdentityFeature import XCTest @testable import SessionFeature @@ -155,4 +156,25 @@ final class SessionFeatureTests: XCTestCase { $0.error = nil } } + + func testPresentingMyIdentity() { + let newId = UUID() + + var env = SessionEnvironment.failing + env.makeId = { newId } + + let store = TestStore( + initialState: SessionState(id: UUID()), + reducer: sessionReducer, + environment: env + ) + + store.send(.presentMyIdentity) { + $0.myIdentity = MyIdentityState(id: newId) + } + + store.send(.didDismissMyIdentity) { + $0.myIdentity = nil + } + } } -- GitLab From b16ba0e8333e460a91d849eff024309ee15ce9af Mon Sep 17 00:00:00 2001 From: Dariusz Rybicki <dariusz@elixxir.io> Date: Wed, 8 Jun 2022 12:59:07 +0200 Subject: [PATCH 03/13] Combine errorReducer with sessionReducer --- Example/example-app/Sources/AppFeature/App.swift | 1 + .../Sources/SessionFeature/SessionFeature.swift | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/Example/example-app/Sources/AppFeature/App.swift b/Example/example-app/Sources/AppFeature/App.swift index 06477b12..16d9b87a 100644 --- a/Example/example-app/Sources/AppFeature/App.swift +++ b/Example/example-app/Sources/AppFeature/App.swift @@ -47,6 +47,7 @@ extension AppEnvironment { bgScheduler: bgScheduler, mainScheduler: mainScheduler, makeId: UUID.init, + error: ErrorEnvironment(), myIdentity: MyIdentityEnvironment() ) ) diff --git a/Example/example-app/Sources/SessionFeature/SessionFeature.swift b/Example/example-app/Sources/SessionFeature/SessionFeature.swift index f3ba9326..ae1afd73 100644 --- a/Example/example-app/Sources/SessionFeature/SessionFeature.swift +++ b/Example/example-app/Sources/SessionFeature/SessionFeature.swift @@ -47,12 +47,14 @@ public struct SessionEnvironment { bgScheduler: AnySchedulerOf<DispatchQueue>, mainScheduler: AnySchedulerOf<DispatchQueue>, makeId: @escaping () -> UUID, + error: ErrorEnvironment, myIdentity: MyIdentityEnvironment ) { self.getClient = getClient self.bgScheduler = bgScheduler self.mainScheduler = mainScheduler self.makeId = makeId + self.error = error self.myIdentity = myIdentity } @@ -60,6 +62,7 @@ public struct SessionEnvironment { public var bgScheduler: AnySchedulerOf<DispatchQueue> public var mainScheduler: AnySchedulerOf<DispatchQueue> public var makeId: () -> UUID + public var error: ErrorEnvironment public var myIdentity: MyIdentityEnvironment } @@ -156,6 +159,13 @@ public let sessionReducer = Reducer<SessionState, SessionAction, SessionEnvironm return .none } } +.presenting( + errorReducer, + state: .keyPath(\.error), + id: .keyPath(\.?.error), + action: /SessionAction.error, + environment: \.error +) .presenting( myIdentityReducer, state: .keyPath(\.myIdentity), @@ -171,6 +181,7 @@ extension SessionEnvironment { bgScheduler: .failing, mainScheduler: .failing, makeId: { fatalError() }, + error: .failing, myIdentity: .failing ) } -- GitLab From deb1f482459758298a506a0ff6c737284c764b99 Mon Sep 17 00:00:00 2001 From: Dariusz Rybicki <dariusz@elixxir.io> Date: Wed, 8 Jun 2022 13:18:05 +0200 Subject: [PATCH 04/13] Observe identity in MyIdentityFeature --- Example/example-app/Package.swift | 4 ++ .../example-app/Sources/AppFeature/App.swift | 8 ++- .../MyIdentityFeature/MyIdentityFeature.swift | 59 +++++++++++++++++-- .../MyIdentityFeature/MyIdentityView.swift | 3 + .../MyIdentityFeatureTests.swift | 52 +++++++++++++++- 5 files changed, 119 insertions(+), 7 deletions(-) diff --git a/Example/example-app/Package.swift b/Example/example-app/Package.swift index 0c0ef222..72fd8b73 100644 --- a/Example/example-app/Package.swift +++ b/Example/example-app/Package.swift @@ -144,6 +144,10 @@ let package = Package( name: "ComposableArchitecture", package: "swift-composable-architecture" ), + .product( + name: "ElixxirDAppsSDK", + package: "elixxir-dapps-sdk-swift" + ), ], swiftSettings: swiftSettings ), diff --git a/Example/example-app/Sources/AppFeature/App.swift b/Example/example-app/Sources/AppFeature/App.swift index 16d9b87a..37007c70 100644 --- a/Example/example-app/Sources/AppFeature/App.swift +++ b/Example/example-app/Sources/AppFeature/App.swift @@ -23,6 +23,7 @@ struct App: SwiftUI.App { extension AppEnvironment { static func live() -> AppEnvironment { let clientSubject = CurrentValueSubject<Client?, Never>(nil) + let identitySubject = CurrentValueSubject<Identity?, Never>(nil) let mainScheduler = DispatchQueue.main.eraseToAnyScheduler() let bgScheduler = DispatchQueue( label: "xx.network.dApps.ExampleApp.bg", @@ -48,7 +49,12 @@ extension AppEnvironment { mainScheduler: mainScheduler, makeId: UUID.init, error: ErrorEnvironment(), - myIdentity: MyIdentityEnvironment() + myIdentity: MyIdentityEnvironment( + getClient: { clientSubject.value }, + observeIdentity: { identitySubject.eraseToAnyPublisher() }, + bgScheduler: bgScheduler, + mainScheduler: mainScheduler + ) ) ) } diff --git a/Example/example-app/Sources/MyIdentityFeature/MyIdentityFeature.swift b/Example/example-app/Sources/MyIdentityFeature/MyIdentityFeature.swift index fb4c7200..4fe79ffd 100644 --- a/Example/example-app/Sources/MyIdentityFeature/MyIdentityFeature.swift +++ b/Example/example-app/Sources/MyIdentityFeature/MyIdentityFeature.swift @@ -1,4 +1,6 @@ +import Combine import ComposableArchitecture +import ElixxirDAppsSDK public struct MyIdentityState: Equatable { public init( @@ -8,18 +10,67 @@ public struct MyIdentityState: Equatable { } public var id: UUID + public var identity: Identity? } -public enum MyIdentityAction: Equatable {} +public enum MyIdentityAction: Equatable { + case viewDidLoad + case observeMyIdentity + case didUpdateMyIdentity(Identity?) +} public struct MyIdentityEnvironment { - public init() {} + public init( + getClient: @escaping () -> Client?, + observeIdentity: @escaping () -> AnyPublisher<Identity?, Never>, + bgScheduler: AnySchedulerOf<DispatchQueue>, + mainScheduler: AnySchedulerOf<DispatchQueue> + ) { + self.getClient = getClient + self.observeIdentity = observeIdentity + self.bgScheduler = bgScheduler + self.mainScheduler = mainScheduler + } + + public var getClient: () -> Client? + public var observeIdentity: () -> AnyPublisher<Identity?, Never> + public var bgScheduler: AnySchedulerOf<DispatchQueue> + public var mainScheduler: AnySchedulerOf<DispatchQueue> } -public let myIdentityReducer = Reducer<MyIdentityState, MyIdentityAction, MyIdentityEnvironment>.empty +public let myIdentityReducer = Reducer<MyIdentityState, MyIdentityAction, MyIdentityEnvironment> +{ state, action, env in + switch action { + case .viewDidLoad: + return .merge([ + .init(value: .observeMyIdentity), + ]) + + case .observeMyIdentity: + struct EffectId: Hashable { + let id: UUID + } + return env.observeIdentity() + .removeDuplicates() + .map(MyIdentityAction.didUpdateMyIdentity) + .subscribe(on: env.bgScheduler) + .receive(on: env.mainScheduler) + .eraseToEffect() + .cancellable(id: EffectId(id: state.id), cancelInFlight: true) + + case .didUpdateMyIdentity(let identity): + state.identity = identity + return .none + } +} #if DEBUG extension MyIdentityEnvironment { - public static let failing = MyIdentityEnvironment() + public static let failing = MyIdentityEnvironment( + getClient: { fatalError() }, + observeIdentity: { fatalError() }, + bgScheduler: .failing, + mainScheduler: .failing + ) } #endif diff --git a/Example/example-app/Sources/MyIdentityFeature/MyIdentityView.swift b/Example/example-app/Sources/MyIdentityFeature/MyIdentityView.swift index c50941b6..62cea2bb 100644 --- a/Example/example-app/Sources/MyIdentityFeature/MyIdentityView.swift +++ b/Example/example-app/Sources/MyIdentityFeature/MyIdentityView.swift @@ -15,6 +15,9 @@ public struct MyIdentityView: View { public var body: some View { WithViewStore(store.scope(state: ViewState.init)) { viewStore in Text("MyIdentityView") + .task { + viewStore.send(.viewDidLoad) + } } } } diff --git a/Example/example-app/Tests/MyIdentityFeatureTests/MyIdentityFeatureTests.swift b/Example/example-app/Tests/MyIdentityFeatureTests/MyIdentityFeatureTests.swift index f4a6a410..d3fcdc08 100644 --- a/Example/example-app/Tests/MyIdentityFeatureTests/MyIdentityFeatureTests.swift +++ b/Example/example-app/Tests/MyIdentityFeatureTests/MyIdentityFeatureTests.swift @@ -1,9 +1,57 @@ +import Combine import ComposableArchitecture +import ElixxirDAppsSDK import XCTest @testable import MyIdentityFeature final class MyIdentityFeatureTests: XCTestCase { - func testExample() { - XCTAssert(true) + func testViewDidLoad() { + let myIdentitySubject = PassthroughSubject<Identity?, Never>() + let bgScheduler = DispatchQueue.test + let mainScheduler = DispatchQueue.test + + var env = MyIdentityEnvironment.failing + env.observeIdentity = { myIdentitySubject.eraseToAnyPublisher() } + env.bgScheduler = bgScheduler.eraseToAnyScheduler() + env.mainScheduler = mainScheduler.eraseToAnyScheduler() + + let store = TestStore( + initialState: MyIdentityState(id: UUID()), + reducer: myIdentityReducer, + environment: env + ) + + store.send(.viewDidLoad) + store.receive(.observeMyIdentity) + + bgScheduler.advance() + let identity = Identity.stub() + myIdentitySubject.send(identity) + mainScheduler.advance() + + store.receive(.didUpdateMyIdentity(identity)) { + $0.identity = identity + } + + myIdentitySubject.send(nil) + mainScheduler.advance() + + store.receive(.didUpdateMyIdentity(nil)) { + $0.identity = nil + } + + myIdentitySubject.send(completion: .finished) + mainScheduler.advance() + } +} + +private extension Identity { + static func stub() -> Identity { + Identity( + id: "\(Int.random(in: 100...999))".data(using: .utf8)!, + rsaPrivatePem: "\(Int.random(in: 100...999))".data(using: .utf8)!, + salt: "\(Int.random(in: 100...999))".data(using: .utf8)!, + dhKeyPrivate: "\(Int.random(in: 100...999))".data(using: .utf8)! + ) } } -- GitLab From 7f21e82a6b452b413b7127eda9b4dca04c1f8e7b Mon Sep 17 00:00:00 2001 From: Dariusz Rybicki <dariusz@elixxir.io> Date: Wed, 8 Jun 2022 13:27:36 +0200 Subject: [PATCH 05/13] Make new identity in MyIdentityFeature --- .../example-app/Sources/AppFeature/App.swift | 1 + .../MyIdentityFeature/MyIdentityFeature.swift | 23 +++++++++++++ .../MyIdentityFeatureTests.swift | 32 +++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/Example/example-app/Sources/AppFeature/App.swift b/Example/example-app/Sources/AppFeature/App.swift index 37007c70..7a4d3ca9 100644 --- a/Example/example-app/Sources/AppFeature/App.swift +++ b/Example/example-app/Sources/AppFeature/App.swift @@ -52,6 +52,7 @@ extension AppEnvironment { myIdentity: MyIdentityEnvironment( getClient: { clientSubject.value }, observeIdentity: { identitySubject.eraseToAnyPublisher() }, + updateIdentity: { identitySubject.value = $0 }, bgScheduler: bgScheduler, mainScheduler: mainScheduler ) diff --git a/Example/example-app/Sources/MyIdentityFeature/MyIdentityFeature.swift b/Example/example-app/Sources/MyIdentityFeature/MyIdentityFeature.swift index 4fe79ffd..bb58876e 100644 --- a/Example/example-app/Sources/MyIdentityFeature/MyIdentityFeature.swift +++ b/Example/example-app/Sources/MyIdentityFeature/MyIdentityFeature.swift @@ -17,23 +17,28 @@ public enum MyIdentityAction: Equatable { case viewDidLoad case observeMyIdentity case didUpdateMyIdentity(Identity?) + case makeIdentity + case didFailMakingIdentity(NSError) } public struct MyIdentityEnvironment { public init( getClient: @escaping () -> Client?, observeIdentity: @escaping () -> AnyPublisher<Identity?, Never>, + updateIdentity: @escaping (Identity?) -> Void, bgScheduler: AnySchedulerOf<DispatchQueue>, mainScheduler: AnySchedulerOf<DispatchQueue> ) { self.getClient = getClient self.observeIdentity = observeIdentity + self.updateIdentity = updateIdentity self.bgScheduler = bgScheduler self.mainScheduler = mainScheduler } public var getClient: () -> Client? public var observeIdentity: () -> AnyPublisher<Identity?, Never> + public var updateIdentity: (Identity?) -> Void public var bgScheduler: AnySchedulerOf<DispatchQueue> public var mainScheduler: AnySchedulerOf<DispatchQueue> } @@ -61,6 +66,23 @@ public let myIdentityReducer = Reducer<MyIdentityState, MyIdentityAction, MyIden case .didUpdateMyIdentity(let identity): state.identity = identity return .none + + case .makeIdentity: + return Effect.run { subscriber in + do { + env.updateIdentity(try env.getClient()?.makeIdentity()) + } catch { + subscriber.send(.didFailMakingIdentity(error as NSError)) + } + subscriber.send(completion: .finished) + return AnyCancellable {} + } + .subscribe(on: env.bgScheduler) + .receive(on: env.mainScheduler) + .eraseToEffect() + + case .didFailMakingIdentity(let error): + return .none } } @@ -69,6 +91,7 @@ extension MyIdentityEnvironment { public static let failing = MyIdentityEnvironment( getClient: { fatalError() }, observeIdentity: { fatalError() }, + updateIdentity: { _ in fatalError() }, bgScheduler: .failing, mainScheduler: .failing ) diff --git a/Example/example-app/Tests/MyIdentityFeatureTests/MyIdentityFeatureTests.swift b/Example/example-app/Tests/MyIdentityFeatureTests/MyIdentityFeatureTests.swift index d3fcdc08..325c1631 100644 --- a/Example/example-app/Tests/MyIdentityFeatureTests/MyIdentityFeatureTests.swift +++ b/Example/example-app/Tests/MyIdentityFeatureTests/MyIdentityFeatureTests.swift @@ -1,5 +1,6 @@ import Combine import ComposableArchitecture +import CustomDump import ElixxirDAppsSDK import XCTest @testable import MyIdentityFeature @@ -43,6 +44,37 @@ final class MyIdentityFeatureTests: XCTestCase { myIdentitySubject.send(completion: .finished) mainScheduler.advance() } + + func testMakeIdentity() { + let newIdentity = Identity.stub() + var didUpdateIdentity = [Identity?]() + let bgScheduler = DispatchQueue.test + let mainScheduler = DispatchQueue.test + + var env = MyIdentityEnvironment.failing + env.getClient = { + var client = Client.failing + client.makeIdentity.make = { newIdentity } + return client + } + env.updateIdentity = { didUpdateIdentity.append($0) } + env.bgScheduler = bgScheduler.eraseToAnyScheduler() + env.mainScheduler = mainScheduler.eraseToAnyScheduler() + + let store = TestStore( + initialState: MyIdentityState(id: UUID()), + reducer: myIdentityReducer, + environment: env + ) + + store.send(.makeIdentity) + + bgScheduler.advance() + + XCTAssertNoDifference(didUpdateIdentity, [newIdentity]) + + mainScheduler.advance() + } } private extension Identity { -- GitLab From 6853eb53e229f05a7e7c3f6438284c72fa4387d8 Mon Sep 17 00:00:00 2001 From: Dariusz Rybicki <dariusz@elixxir.io> Date: Wed, 8 Jun 2022 13:33:28 +0200 Subject: [PATCH 06/13] Handle failures when making identity --- Example/example-app/Package.swift | 5 +++ .../example-app/Sources/AppFeature/App.swift | 3 +- .../MyIdentityFeature/MyIdentityFeature.swift | 29 +++++++++++++-- .../MyIdentityFeatureTests.swift | 35 +++++++++++++++++++ 4 files changed, 68 insertions(+), 4 deletions(-) diff --git a/Example/example-app/Package.swift b/Example/example-app/Package.swift index 72fd8b73..f42e9866 100644 --- a/Example/example-app/Package.swift +++ b/Example/example-app/Package.swift @@ -140,10 +140,15 @@ let package = Package( .target( name: "MyIdentityFeature", dependencies: [ + .target(name: "ErrorFeature"), .product( name: "ComposableArchitecture", package: "swift-composable-architecture" ), + .product( + name: "ComposablePresentation", + package: "swift-composable-presentation" + ), .product( name: "ElixxirDAppsSDK", package: "elixxir-dapps-sdk-swift" diff --git a/Example/example-app/Sources/AppFeature/App.swift b/Example/example-app/Sources/AppFeature/App.swift index 7a4d3ca9..a8b27266 100644 --- a/Example/example-app/Sources/AppFeature/App.swift +++ b/Example/example-app/Sources/AppFeature/App.swift @@ -54,7 +54,8 @@ extension AppEnvironment { observeIdentity: { identitySubject.eraseToAnyPublisher() }, updateIdentity: { identitySubject.value = $0 }, bgScheduler: bgScheduler, - mainScheduler: mainScheduler + mainScheduler: mainScheduler, + error: ErrorEnvironment() ) ) ) diff --git a/Example/example-app/Sources/MyIdentityFeature/MyIdentityFeature.swift b/Example/example-app/Sources/MyIdentityFeature/MyIdentityFeature.swift index bb58876e..0b7bb5b7 100644 --- a/Example/example-app/Sources/MyIdentityFeature/MyIdentityFeature.swift +++ b/Example/example-app/Sources/MyIdentityFeature/MyIdentityFeature.swift @@ -1,16 +1,21 @@ import Combine import ComposableArchitecture +import ComposablePresentation import ElixxirDAppsSDK +import ErrorFeature public struct MyIdentityState: Equatable { public init( - id: UUID + id: UUID, + error: ErrorState? = nil ) { self.id = id + self.error = error } public var id: UUID public var identity: Identity? + public var error: ErrorState? } public enum MyIdentityAction: Equatable { @@ -19,6 +24,8 @@ public enum MyIdentityAction: Equatable { case didUpdateMyIdentity(Identity?) case makeIdentity case didFailMakingIdentity(NSError) + case didDismissError + case error(ErrorAction) } public struct MyIdentityEnvironment { @@ -27,13 +34,15 @@ public struct MyIdentityEnvironment { observeIdentity: @escaping () -> AnyPublisher<Identity?, Never>, updateIdentity: @escaping (Identity?) -> Void, bgScheduler: AnySchedulerOf<DispatchQueue>, - mainScheduler: AnySchedulerOf<DispatchQueue> + mainScheduler: AnySchedulerOf<DispatchQueue>, + error: ErrorEnvironment ) { self.getClient = getClient self.observeIdentity = observeIdentity self.updateIdentity = updateIdentity self.bgScheduler = bgScheduler self.mainScheduler = mainScheduler + self.error = error } public var getClient: () -> Client? @@ -41,6 +50,7 @@ public struct MyIdentityEnvironment { public var updateIdentity: (Identity?) -> Void public var bgScheduler: AnySchedulerOf<DispatchQueue> public var mainScheduler: AnySchedulerOf<DispatchQueue> + public var error: ErrorEnvironment } public let myIdentityReducer = Reducer<MyIdentityState, MyIdentityAction, MyIdentityEnvironment> @@ -81,10 +91,22 @@ public let myIdentityReducer = Reducer<MyIdentityState, MyIdentityAction, MyIden .receive(on: env.mainScheduler) .eraseToEffect() + case .didDismissError: + state.error = nil + return .none + case .didFailMakingIdentity(let error): + state.error = ErrorState(error: error) return .none } } +.presenting( + errorReducer, + state: .keyPath(\.error), + id: .keyPath(\.?.error), + action: /MyIdentityAction.error, + environment: \.error +) #if DEBUG extension MyIdentityEnvironment { @@ -93,7 +115,8 @@ extension MyIdentityEnvironment { observeIdentity: { fatalError() }, updateIdentity: { _ in fatalError() }, bgScheduler: .failing, - mainScheduler: .failing + mainScheduler: .failing, + error: .failing ) } #endif diff --git a/Example/example-app/Tests/MyIdentityFeatureTests/MyIdentityFeatureTests.swift b/Example/example-app/Tests/MyIdentityFeatureTests/MyIdentityFeatureTests.swift index 325c1631..64bb06d2 100644 --- a/Example/example-app/Tests/MyIdentityFeatureTests/MyIdentityFeatureTests.swift +++ b/Example/example-app/Tests/MyIdentityFeatureTests/MyIdentityFeatureTests.swift @@ -2,6 +2,7 @@ import Combine import ComposableArchitecture import CustomDump import ElixxirDAppsSDK +import ErrorFeature import XCTest @testable import MyIdentityFeature @@ -75,6 +76,40 @@ final class MyIdentityFeatureTests: XCTestCase { mainScheduler.advance() } + + func testMakeIdentityFailure() { + let error = NSError(domain: "test", code: 1234) + let bgScheduler = DispatchQueue.test + let mainScheduler = DispatchQueue.test + + var env = MyIdentityEnvironment.failing + env.getClient = { + var client = Client.failing + client.makeIdentity.make = { throw error } + return client + } + env.bgScheduler = bgScheduler.eraseToAnyScheduler() + env.mainScheduler = mainScheduler.eraseToAnyScheduler() + + let store = TestStore( + initialState: MyIdentityState(id: UUID()), + reducer: myIdentityReducer, + environment: env + ) + + store.send(.makeIdentity) + + bgScheduler.advance() + mainScheduler.advance() + + store.receive(.didFailMakingIdentity(error)) { + $0.error = ErrorState(error: error) + } + + store.send(.didDismissError) { + $0.error = nil + } + } } private extension Identity { -- GitLab From 1d4953ff425f8aea76c35541343d1e85dc1f6f47 Mon Sep 17 00:00:00 2001 From: Dariusz Rybicki <dariusz@elixxir.io> Date: Wed, 8 Jun 2022 13:53:08 +0200 Subject: [PATCH 07/13] Add MyIdentityState.isMakingIdentity property --- .../MyIdentityFeature/MyIdentityFeature.swift | 20 ++++++++++++------- .../MyIdentityFeatureTests.swift | 15 +++++++++++--- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/Example/example-app/Sources/MyIdentityFeature/MyIdentityFeature.swift b/Example/example-app/Sources/MyIdentityFeature/MyIdentityFeature.swift index 0b7bb5b7..8bc65764 100644 --- a/Example/example-app/Sources/MyIdentityFeature/MyIdentityFeature.swift +++ b/Example/example-app/Sources/MyIdentityFeature/MyIdentityFeature.swift @@ -7,14 +7,17 @@ import ErrorFeature public struct MyIdentityState: Equatable { public init( id: UUID, + isMakingIdentity: Bool = false, error: ErrorState? = nil ) { self.id = id + self.isMakingIdentity = isMakingIdentity self.error = error } public var id: UUID public var identity: Identity? + public var isMakingIdentity: Bool public var error: ErrorState? } @@ -23,7 +26,7 @@ public enum MyIdentityAction: Equatable { case observeMyIdentity case didUpdateMyIdentity(Identity?) case makeIdentity - case didFailMakingIdentity(NSError) + case didFinishMakingIdentity(NSError?) case didDismissError case error(ErrorAction) } @@ -78,14 +81,14 @@ public let myIdentityReducer = Reducer<MyIdentityState, MyIdentityAction, MyIden return .none case .makeIdentity: - return Effect.run { subscriber in + state.isMakingIdentity = true + return Effect.future { fulfill in do { env.updateIdentity(try env.getClient()?.makeIdentity()) + fulfill(.success(.didFinishMakingIdentity(nil))) } catch { - subscriber.send(.didFailMakingIdentity(error as NSError)) + fulfill(.success(.didFinishMakingIdentity(error as NSError))) } - subscriber.send(completion: .finished) - return AnyCancellable {} } .subscribe(on: env.bgScheduler) .receive(on: env.mainScheduler) @@ -95,8 +98,11 @@ public let myIdentityReducer = Reducer<MyIdentityState, MyIdentityAction, MyIden state.error = nil return .none - case .didFailMakingIdentity(let error): - state.error = ErrorState(error: error) + case .didFinishMakingIdentity(let error): + state.isMakingIdentity = false + if let error = error { + state.error = ErrorState(error: error) + } return .none } } diff --git a/Example/example-app/Tests/MyIdentityFeatureTests/MyIdentityFeatureTests.swift b/Example/example-app/Tests/MyIdentityFeatureTests/MyIdentityFeatureTests.swift index 64bb06d2..b426cc0f 100644 --- a/Example/example-app/Tests/MyIdentityFeatureTests/MyIdentityFeatureTests.swift +++ b/Example/example-app/Tests/MyIdentityFeatureTests/MyIdentityFeatureTests.swift @@ -68,13 +68,19 @@ final class MyIdentityFeatureTests: XCTestCase { environment: env ) - store.send(.makeIdentity) + store.send(.makeIdentity) { + $0.isMakingIdentity = true + } bgScheduler.advance() XCTAssertNoDifference(didUpdateIdentity, [newIdentity]) mainScheduler.advance() + + store.receive(.didFinishMakingIdentity(nil)) { + $0.isMakingIdentity = false + } } func testMakeIdentityFailure() { @@ -97,12 +103,15 @@ final class MyIdentityFeatureTests: XCTestCase { environment: env ) - store.send(.makeIdentity) + store.send(.makeIdentity) { + $0.isMakingIdentity = true + } bgScheduler.advance() mainScheduler.advance() - store.receive(.didFailMakingIdentity(error)) { + store.receive(.didFinishMakingIdentity(error)) { + $0.isMakingIdentity = false $0.error = ErrorState(error: error) } -- GitLab From f87c132fd00203811d852ba396044f36f79758de Mon Sep 17 00:00:00 2001 From: Dariusz Rybicki <dariusz@elixxir.io> Date: Wed, 8 Jun 2022 14:01:22 +0200 Subject: [PATCH 08/13] Implement MyIdentityView --- .../MyIdentityFeature/MyIdentityView.swift | 67 +++++++++++++++++-- 1 file changed, 63 insertions(+), 4 deletions(-) diff --git a/Example/example-app/Sources/MyIdentityFeature/MyIdentityView.swift b/Example/example-app/Sources/MyIdentityFeature/MyIdentityView.swift index 62cea2bb..61e09c13 100644 --- a/Example/example-app/Sources/MyIdentityFeature/MyIdentityView.swift +++ b/Example/example-app/Sources/MyIdentityFeature/MyIdentityView.swift @@ -1,4 +1,7 @@ import ComposableArchitecture +import ComposablePresentation +import ElixxirDAppsSDK +import ErrorFeature import SwiftUI public struct MyIdentityView: View { @@ -9,15 +12,71 @@ public struct MyIdentityView: View { let store: Store<MyIdentityState, MyIdentityAction> struct ViewState: Equatable { - init(state: MyIdentityState) {} + let identity: Identity? + let isMakingIdentity: Bool + + init(state: MyIdentityState) { + identity = state.identity + isMakingIdentity = state.isMakingIdentity + } + + var isLoading: Bool { + isMakingIdentity + } } public var body: some View { WithViewStore(store.scope(state: ViewState.init)) { viewStore in - Text("MyIdentityView") - .task { - viewStore.send(.viewDidLoad) + Form { + Section { + Text(string(for: viewStore.identity)) + .textSelection(.enabled) + } + + Section { + Button { + viewStore.send(.makeIdentity) + } label: { + HStack { + Text("Make new identity") + Spacer() + if viewStore.isMakingIdentity { + ProgressView() + } + } + } } + .disabled(viewStore.isLoading) + } + .navigationTitle("My identity") + .navigationBarBackButtonHidden(viewStore.isLoading) + .task { + viewStore.send(.viewDidLoad) + } + .sheet( + store.scope( + state: \.error, + action: MyIdentityAction.error + ), + onDismiss: { + viewStore.send(.didDismissError) + }, + content: ErrorView.init(store:) + ) + } + } + + func string(for identity: Identity?) -> String { + guard let identity = identity else { + return "No identity" + } + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + do { + let data = try encoder.encode(identity) + return String(data: data, encoding: .utf8) ?? "Decoding error" + } catch { + return "Decoding error: \(error)" } } } -- GitLab From a047d57ff9e554af09a5fb6b070fef3a52e128b0 Mon Sep 17 00:00:00 2001 From: Dariusz Rybicki <dariusz@elixxir.io> Date: Wed, 8 Jun 2022 14:08:41 +0200 Subject: [PATCH 09/13] Add MyContactFeature library to example app --- .../xcschemes/MyContactFeature.xcscheme | 78 +++++++++++++++++++ .../xcschemes/example-app.xcscheme | 24 ++++++ Example/example-app/Package.swift | 21 +++++ .../MyContactFeature/MyContactFeature.swift | 33 ++++++++ .../MyContactFeature/MyContactView.swift | 39 ++++++++++ .../MyContactFeatureTests.swift | 8 ++ 6 files changed, 203 insertions(+) create mode 100644 Example/example-app/.swiftpm/xcode/xcshareddata/xcschemes/MyContactFeature.xcscheme create mode 100644 Example/example-app/Sources/MyContactFeature/MyContactFeature.swift create mode 100644 Example/example-app/Sources/MyContactFeature/MyContactView.swift create mode 100644 Example/example-app/Tests/MyContactFeatureTests/MyContactFeatureTests.swift diff --git a/Example/example-app/.swiftpm/xcode/xcshareddata/xcschemes/MyContactFeature.xcscheme b/Example/example-app/.swiftpm/xcode/xcshareddata/xcschemes/MyContactFeature.xcscheme new file mode 100644 index 00000000..60c61fd3 --- /dev/null +++ b/Example/example-app/.swiftpm/xcode/xcshareddata/xcschemes/MyContactFeature.xcscheme @@ -0,0 +1,78 @@ +<?xml version="1.0" encoding="UTF-8"?> +<Scheme + LastUpgradeVersion = "1340" + version = "1.3"> + <BuildAction + parallelizeBuildables = "YES" + buildImplicitDependencies = "YES"> + <BuildActionEntries> + <BuildActionEntry + buildForTesting = "YES" + buildForRunning = "YES" + buildForProfiling = "YES" + buildForArchiving = "YES" + buildForAnalyzing = "YES"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "MyContactFeature" + BuildableName = "MyContactFeature" + BlueprintName = "MyContactFeature" + ReferencedContainer = "container:"> + </BuildableReference> + </BuildActionEntry> + </BuildActionEntries> + </BuildAction> + <TestAction + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + shouldUseLaunchSchemeArgsEnv = "YES" + codeCoverageEnabled = "YES"> + <Testables> + <TestableReference + skipped = "NO"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "MyContactFeatureTests" + BuildableName = "MyContactFeatureTests" + BlueprintName = "MyContactFeatureTests" + ReferencedContainer = "container:"> + </BuildableReference> + </TestableReference> + </Testables> + </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 = "MyContactFeature" + BuildableName = "MyContactFeature" + BlueprintName = "MyContactFeature" + ReferencedContainer = "container:"> + </BuildableReference> + </MacroExpansion> + </ProfileAction> + <AnalyzeAction + buildConfiguration = "Debug"> + </AnalyzeAction> + <ArchiveAction + buildConfiguration = "Release" + revealArchiveInOrganizer = "YES"> + </ArchiveAction> +</Scheme> diff --git a/Example/example-app/.swiftpm/xcode/xcshareddata/xcschemes/example-app.xcscheme b/Example/example-app/.swiftpm/xcode/xcshareddata/xcschemes/example-app.xcscheme index 1a4d13ff..c6edd5dd 100644 --- a/Example/example-app/.swiftpm/xcode/xcshareddata/xcschemes/example-app.xcscheme +++ b/Example/example-app/.swiftpm/xcode/xcshareddata/xcschemes/example-app.xcscheme @@ -48,6 +48,20 @@ ReferencedContainer = "container:"> </BuildableReference> </BuildActionEntry> + <BuildActionEntry + buildForTesting = "YES" + buildForRunning = "YES" + buildForProfiling = "YES" + buildForArchiving = "YES" + buildForAnalyzing = "YES"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "MyContactFeature" + BuildableName = "MyContactFeature" + BlueprintName = "MyContactFeature" + ReferencedContainer = "container:"> + </BuildableReference> + </BuildActionEntry> <BuildActionEntry buildForTesting = "YES" buildForRunning = "YES" @@ -115,6 +129,16 @@ ReferencedContainer = "container:"> </BuildableReference> </TestableReference> + <TestableReference + skipped = "NO"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "MyContactFeatureTests" + BuildableName = "MyContactFeatureTests" + BlueprintName = "MyContactFeatureTests" + ReferencedContainer = "container:"> + </BuildableReference> + </TestableReference> <TestableReference skipped = "NO"> <BuildableReference diff --git a/Example/example-app/Package.swift b/Example/example-app/Package.swift index f42e9866..d7a464f9 100644 --- a/Example/example-app/Package.swift +++ b/Example/example-app/Package.swift @@ -32,6 +32,10 @@ let package = Package( name: "LandingFeature", targets: ["LandingFeature"] ), + .library( + name: "MyContactFeature", + targets: ["MyContactFeature"] + ), .library( name: "MyIdentityFeature", targets: ["MyIdentityFeature"] @@ -137,6 +141,23 @@ let package = Package( ], swiftSettings: swiftSettings ), + .target( + name: "MyContactFeature", + dependencies: [ + .product( + name: "ComposableArchitecture", + package: "swift-composable-architecture" + ), + ], + swiftSettings: swiftSettings + ), + .testTarget( + name: "MyContactFeatureTests", + dependencies: [ + .target(name: "MyContactFeature"), + ], + swiftSettings: swiftSettings + ), .target( name: "MyIdentityFeature", dependencies: [ diff --git a/Example/example-app/Sources/MyContactFeature/MyContactFeature.swift b/Example/example-app/Sources/MyContactFeature/MyContactFeature.swift new file mode 100644 index 00000000..bd520703 --- /dev/null +++ b/Example/example-app/Sources/MyContactFeature/MyContactFeature.swift @@ -0,0 +1,33 @@ +import ComposableArchitecture + +public struct MyContactState: Equatable { + public init( + id: UUID + ) { + self.id = id + } + + public var id: UUID +} + +public enum MyContactAction: Equatable { + case viewDidLoad +} + +public struct MyContactEnvironment { + public init() {} +} + +public let myContactReducer = Reducer<MyContactState, MyContactAction, MyContactEnvironment> +{ state, action, env in + switch action { + case .viewDidLoad: + return .none + } +} + +#if DEBUG +extension MyContactEnvironment { + public static let failing = MyContactEnvironment() +} +#endif diff --git a/Example/example-app/Sources/MyContactFeature/MyContactView.swift b/Example/example-app/Sources/MyContactFeature/MyContactView.swift new file mode 100644 index 00000000..746a5317 --- /dev/null +++ b/Example/example-app/Sources/MyContactFeature/MyContactView.swift @@ -0,0 +1,39 @@ +import ComposableArchitecture +import SwiftUI + +public struct MyContactView: View { + public init(store: Store<MyContactState, MyContactAction>) { + self.store = store + } + + let store: Store<MyContactState, MyContactAction> + + struct ViewState: Equatable { + init(state: MyContactState) {} + } + + public var body: some View { + WithViewStore(store.scope(state: ViewState.init)) { viewStore in + Text("MyContactView") + .navigationTitle("My contact") + .task { + viewStore.send(.viewDidLoad) + } + } + } +} + +#if DEBUG +public struct MyContactView_Previews: PreviewProvider { + public static var previews: some View { + NavigationView { + MyContactView(store: .init( + initialState: .init(id: UUID()), + reducer: .empty, + environment: () + )) + } + .navigationViewStyle(.stack) + } +} +#endif diff --git a/Example/example-app/Tests/MyContactFeatureTests/MyContactFeatureTests.swift b/Example/example-app/Tests/MyContactFeatureTests/MyContactFeatureTests.swift new file mode 100644 index 00000000..dd026572 --- /dev/null +++ b/Example/example-app/Tests/MyContactFeatureTests/MyContactFeatureTests.swift @@ -0,0 +1,8 @@ +import XCTest +@testable import MyContactFeature + +final class MyContactFeatureTests: XCTestCase { + func testExample() { + XCTAssert(true) + } +} -- GitLab From b29d338e0fdf0b1bf2f152d09e9cf5ec9dc45b8f Mon Sep 17 00:00:00 2001 From: Dariusz Rybicki <dariusz@elixxir.io> Date: Wed, 8 Jun 2022 14:15:48 +0200 Subject: [PATCH 10/13] Present MyContactView from SessionView --- Example/example-app/Package.swift | 2 ++ .../example-app/Sources/AppFeature/App.swift | 4 ++- .../SessionFeature/SessionFeature.swift | 36 ++++++++++++++++--- .../Sources/SessionFeature/SessionView.swift | 23 ++++++++++++ .../SessionFeatureTests.swift | 22 ++++++++++++ 5 files changed, 82 insertions(+), 5 deletions(-) diff --git a/Example/example-app/Package.swift b/Example/example-app/Package.swift index d7a464f9..faa8a155 100644 --- a/Example/example-app/Package.swift +++ b/Example/example-app/Package.swift @@ -66,6 +66,7 @@ let package = Package( dependencies: [ .target(name: "ErrorFeature"), .target(name: "LandingFeature"), + .target(name: "MyContactFeature"), .target(name: "MyIdentityFeature"), .target(name: "SessionFeature"), .product( @@ -188,6 +189,7 @@ let package = Package( name: "SessionFeature", dependencies: [ .target(name: "ErrorFeature"), + .target(name: "MyContactFeature"), .target(name: "MyIdentityFeature"), .product( name: "ComposableArchitecture", diff --git a/Example/example-app/Sources/AppFeature/App.swift b/Example/example-app/Sources/AppFeature/App.swift index a8b27266..b106e28a 100644 --- a/Example/example-app/Sources/AppFeature/App.swift +++ b/Example/example-app/Sources/AppFeature/App.swift @@ -3,6 +3,7 @@ import ComposableArchitecture import ElixxirDAppsSDK import ErrorFeature import LandingFeature +import MyContactFeature import MyIdentityFeature import SessionFeature import SwiftUI @@ -56,7 +57,8 @@ extension AppEnvironment { bgScheduler: bgScheduler, mainScheduler: mainScheduler, error: ErrorEnvironment() - ) + ), + myContact: MyContactEnvironment() ) ) } diff --git a/Example/example-app/Sources/SessionFeature/SessionFeature.swift b/Example/example-app/Sources/SessionFeature/SessionFeature.swift index ae1afd73..5b1005a9 100644 --- a/Example/example-app/Sources/SessionFeature/SessionFeature.swift +++ b/Example/example-app/Sources/SessionFeature/SessionFeature.swift @@ -2,6 +2,7 @@ import Combine import ComposableArchitecture import ElixxirDAppsSDK import ErrorFeature +import MyContactFeature import MyIdentityFeature public struct SessionState: Equatable { @@ -10,13 +11,15 @@ public struct SessionState: Equatable { networkFollowerStatus: NetworkFollowerStatus? = nil, isNetworkHealthy: Bool? = nil, error: ErrorState? = nil, - myIdentity: MyIdentityState? = nil + myIdentity: MyIdentityState? = nil, + myContact: MyContactState? = nil ) { self.id = id self.networkFollowerStatus = networkFollowerStatus self.isNetworkHealthy = isNetworkHealthy self.error = error self.myIdentity = myIdentity + self.myContact = myContact } public var id: UUID @@ -24,6 +27,7 @@ public struct SessionState: Equatable { public var isNetworkHealthy: Bool? public var error: ErrorState? public var myIdentity: MyIdentityState? + public var myContact: MyContactState? } public enum SessionAction: Equatable { @@ -37,8 +41,11 @@ public enum SessionAction: Equatable { case didDismissError case presentMyIdentity case didDismissMyIdentity + case presentMyContact + case didDismissMyContact case error(ErrorAction) case myIdentity(MyIdentityAction) + case myContact(MyContactAction) } public struct SessionEnvironment { @@ -48,7 +55,8 @@ public struct SessionEnvironment { mainScheduler: AnySchedulerOf<DispatchQueue>, makeId: @escaping () -> UUID, error: ErrorEnvironment, - myIdentity: MyIdentityEnvironment + myIdentity: MyIdentityEnvironment, + myContact: MyContactEnvironment ) { self.getClient = getClient self.bgScheduler = bgScheduler @@ -56,6 +64,7 @@ public struct SessionEnvironment { self.makeId = makeId self.error = error self.myIdentity = myIdentity + self.myContact = myContact } public var getClient: () -> Client? @@ -64,6 +73,7 @@ public struct SessionEnvironment { public var makeId: () -> UUID public var error: ErrorEnvironment public var myIdentity: MyIdentityEnvironment + public var myContact: MyContactEnvironment } public let sessionReducer = Reducer<SessionState, SessionAction, SessionEnvironment> @@ -155,7 +165,17 @@ public let sessionReducer = Reducer<SessionState, SessionAction, SessionEnvironm state.myIdentity = nil return .none - case .error(_), .myIdentity(_): + case .presentMyContact: + if state.myContact == nil { + state.myContact = MyContactState(id: env.makeId()) + } + return .none + + case .didDismissMyContact: + state.myContact = nil + return .none + + case .error(_), .myIdentity(_), .myContact(_): return .none } } @@ -173,6 +193,13 @@ public let sessionReducer = Reducer<SessionState, SessionAction, SessionEnvironm action: /SessionAction.myIdentity, environment: \.myIdentity ) +.presenting( + myContactReducer, + state: .keyPath(\.myContact), + id: .keyPath(\.?.id), + action: /SessionAction.myContact, + environment: \.myContact +) #if DEBUG extension SessionEnvironment { @@ -182,7 +209,8 @@ extension SessionEnvironment { mainScheduler: .failing, makeId: { fatalError() }, error: .failing, - myIdentity: .failing + myIdentity: .failing, + myContact: .failing ) } #endif diff --git a/Example/example-app/Sources/SessionFeature/SessionView.swift b/Example/example-app/Sources/SessionFeature/SessionView.swift index 9f3c419b..ea14d91f 100644 --- a/Example/example-app/Sources/SessionFeature/SessionView.swift +++ b/Example/example-app/Sources/SessionFeature/SessionView.swift @@ -2,6 +2,7 @@ import ComposableArchitecture import ComposablePresentation import ElixxirDAppsSDK import ErrorFeature +import MyContactFeature import MyIdentityFeature import SwiftUI @@ -61,6 +62,16 @@ public struct SessionView: View { Image(systemName: "chevron.forward") } } + + Button { + viewStore.send(.presentMyContact) + } label: { + HStack { + Text("My contact") + Spacer() + Image(systemName: "chevron.forward") + } + } } } .navigationTitle("Session") @@ -89,6 +100,18 @@ public struct SessionView: View { destination: MyIdentityView.init(store:) ) ) + .background( + NavigationLinkWithStore( + store.scope( + state: \.myContact, + action: SessionAction.myContact + ), + onDeactivate: { + viewStore.send(.didDismissMyContact) + }, + destination: MyContactView.init(store:) + ) + ) } } } diff --git a/Example/example-app/Tests/SessionFeatureTests/SessionFeatureTests.swift b/Example/example-app/Tests/SessionFeatureTests/SessionFeatureTests.swift index 02039168..5c1aa3ba 100644 --- a/Example/example-app/Tests/SessionFeatureTests/SessionFeatureTests.swift +++ b/Example/example-app/Tests/SessionFeatureTests/SessionFeatureTests.swift @@ -1,6 +1,7 @@ import ComposableArchitecture import ElixxirDAppsSDK import ErrorFeature +import MyContactFeature import MyIdentityFeature import XCTest @testable import SessionFeature @@ -177,4 +178,25 @@ final class SessionFeatureTests: XCTestCase { $0.myIdentity = nil } } + + func testPresentingMyContact() { + let newId = UUID() + + var env = SessionEnvironment.failing + env.makeId = { newId } + + let store = TestStore( + initialState: SessionState(id: UUID()), + reducer: sessionReducer, + environment: env + ) + + store.send(.presentMyContact) { + $0.myContact = MyContactState(id: newId) + } + + store.send(.didDismissMyContact) { + $0.myContact = nil + } + } } -- GitLab From c1908713a1807a43c7b76a231abeb3bca3b3c0e7 Mon Sep 17 00:00:00 2001 From: Dariusz Rybicki <dariusz@elixxir.io> Date: Wed, 8 Jun 2022 14:34:33 +0200 Subject: [PATCH 11/13] Add makeContactFromIdentity to Client --- Sources/ElixxirDAppsSDK/Client.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/ElixxirDAppsSDK/Client.swift b/Sources/ElixxirDAppsSDK/Client.swift index c47d98c4..621270e4 100644 --- a/Sources/ElixxirDAppsSDK/Client.swift +++ b/Sources/ElixxirDAppsSDK/Client.swift @@ -9,6 +9,7 @@ public struct Client { public var monitorNetworkHealth: NetworkHealthListener public var listenErrors: ClientErrorListener public var makeIdentity: IdentityMaker + public var makeContactFromIdentity: ContactFromIdentityProvider public var connect: ConnectionMaker public var getContactFromIdentity: ContactFromIdentityProvider public var waitForDelivery: MessageDeliveryWaiter @@ -25,6 +26,7 @@ extension Client { monitorNetworkHealth: .live(bindingsClient: bindingsClient), listenErrors: .live(bindingsClient: bindingsClient), makeIdentity: .live(bindingsClient: bindingsClient), + makeContactFromIdentity: .live(bindingsClient: bindingsClient), connect: .live(bindingsClient: bindingsClient), getContactFromIdentity: .live(bindingsClient: bindingsClient), waitForDelivery: .live(bindingsClient: bindingsClient) @@ -43,6 +45,7 @@ extension Client { monitorNetworkHealth: .failing, listenErrors: .failing, makeIdentity: .failing, + makeContactFromIdentity: .failing, connect: .failing, getContactFromIdentity: .failing, waitForDelivery: .failing -- GitLab From e42c1a97b82bf42f4bd793890352876d19bf801c Mon Sep 17 00:00:00 2001 From: Dariusz Rybicki <dariusz@elixxir.io> Date: Wed, 8 Jun 2022 14:35:33 +0200 Subject: [PATCH 12/13] Update MyIdentityState.init --- .../Sources/MyIdentityFeature/MyIdentityFeature.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Example/example-app/Sources/MyIdentityFeature/MyIdentityFeature.swift b/Example/example-app/Sources/MyIdentityFeature/MyIdentityFeature.swift index 8bc65764..df4559d5 100644 --- a/Example/example-app/Sources/MyIdentityFeature/MyIdentityFeature.swift +++ b/Example/example-app/Sources/MyIdentityFeature/MyIdentityFeature.swift @@ -7,6 +7,7 @@ import ErrorFeature public struct MyIdentityState: Equatable { public init( id: UUID, + identity: Identity? = nil, isMakingIdentity: Bool = false, error: ErrorState? = nil ) { -- GitLab From b782cff26d1966a4b35114defc549b5e8619b639 Mon Sep 17 00:00:00 2001 From: Dariusz Rybicki <dariusz@elixxir.io> Date: Wed, 8 Jun 2022 14:52:37 +0200 Subject: [PATCH 13/13] Implement MyContactFeature --- Example/example-app/Package.swift | 9 + .../example-app/Sources/AppFeature/App.swift | 11 +- .../MyContactFeature/MyContactFeature.swift | 111 +++++++++++- .../MyContactFeature/MyContactView.swift | 61 ++++++- .../MyContactFeatureTests.swift | 170 +++++++++++++++++- 5 files changed, 351 insertions(+), 11 deletions(-) diff --git a/Example/example-app/Package.swift b/Example/example-app/Package.swift index faa8a155..6aa45316 100644 --- a/Example/example-app/Package.swift +++ b/Example/example-app/Package.swift @@ -145,10 +145,19 @@ let package = Package( .target( name: "MyContactFeature", dependencies: [ + .target(name: "ErrorFeature"), .product( name: "ComposableArchitecture", package: "swift-composable-architecture" ), + .product( + name: "ComposablePresentation", + package: "swift-composable-presentation" + ), + .product( + name: "ElixxirDAppsSDK", + package: "elixxir-dapps-sdk-swift" + ), ], swiftSettings: swiftSettings ), diff --git a/Example/example-app/Sources/AppFeature/App.swift b/Example/example-app/Sources/AppFeature/App.swift index b106e28a..65f4728e 100644 --- a/Example/example-app/Sources/AppFeature/App.swift +++ b/Example/example-app/Sources/AppFeature/App.swift @@ -25,6 +25,7 @@ extension AppEnvironment { static func live() -> AppEnvironment { let clientSubject = CurrentValueSubject<Client?, Never>(nil) let identitySubject = CurrentValueSubject<Identity?, Never>(nil) + let contactSubject = CurrentValueSubject<Data?, Never>(nil) let mainScheduler = DispatchQueue.main.eraseToAnyScheduler() let bgScheduler = DispatchQueue( label: "xx.network.dApps.ExampleApp.bg", @@ -58,7 +59,15 @@ extension AppEnvironment { mainScheduler: mainScheduler, error: ErrorEnvironment() ), - myContact: MyContactEnvironment() + myContact: MyContactEnvironment( + getClient: { clientSubject.value }, + getIdentity: { identitySubject.value }, + observeContact: { contactSubject.eraseToAnyPublisher() }, + updateContact: { contactSubject.value = $0 }, + bgScheduler: bgScheduler, + mainScheduler: mainScheduler, + error: ErrorEnvironment() + ) ) ) } diff --git a/Example/example-app/Sources/MyContactFeature/MyContactFeature.swift b/Example/example-app/Sources/MyContactFeature/MyContactFeature.swift index bd520703..317b646f 100644 --- a/Example/example-app/Sources/MyContactFeature/MyContactFeature.swift +++ b/Example/example-app/Sources/MyContactFeature/MyContactFeature.swift @@ -1,33 +1,138 @@ +import Combine import ComposableArchitecture +import ComposablePresentation +import ElixxirDAppsSDK +import ErrorFeature public struct MyContactState: Equatable { public init( - id: UUID + id: UUID, + contact: Data? = nil, + isMakingContact: Bool = false, + error: ErrorState? = nil ) { self.id = id + self.contact = contact + self.isMakingContact = isMakingContact + self.error = error } public var id: UUID + public var contact: Data? + public var isMakingContact: Bool + public var error: ErrorState? } public enum MyContactAction: Equatable { case viewDidLoad + case observeMyContact + case didUpdateMyContact(Data?) + case makeContact + case didFinishMakingContact(NSError?) + case didDismissError + case error(ErrorAction) } public struct MyContactEnvironment { - public init() {} + public init( + getClient: @escaping () -> Client?, + getIdentity: @escaping () -> Identity?, + observeContact: @escaping () -> AnyPublisher<Data?, Never>, + updateContact: @escaping (Data?) -> Void, + bgScheduler: AnySchedulerOf<DispatchQueue>, + mainScheduler: AnySchedulerOf<DispatchQueue>, + error: ErrorEnvironment + ) { + self.getClient = getClient + self.getIdentity = getIdentity + self.observeContact = observeContact + self.updateContact = updateContact + self.bgScheduler = bgScheduler + self.mainScheduler = mainScheduler + self.error = error + } + + public var getClient: () -> Client? + public var getIdentity: () -> Identity? + public var observeContact: () -> AnyPublisher<Data?, Never> + public var updateContact: (Data?) -> Void + public var bgScheduler: AnySchedulerOf<DispatchQueue> + public var mainScheduler: AnySchedulerOf<DispatchQueue> + public var error: ErrorEnvironment } public let myContactReducer = Reducer<MyContactState, MyContactAction, MyContactEnvironment> { state, action, env in switch action { case .viewDidLoad: + return .merge([ + .init(value: .observeMyContact), + ]) + + case .observeMyContact: + struct EffectId: Hashable { + let id: UUID + } + return env.observeContact() + .removeDuplicates() + .map(MyContactAction.didUpdateMyContact) + .subscribe(on: env.bgScheduler) + .receive(on: env.mainScheduler) + .eraseToEffect() + .cancellable(id: EffectId(id: state.id), cancelInFlight: true) + + case .didUpdateMyContact(let contact): + state.contact = contact + return .none + + case .makeContact: + state.isMakingContact = true + return Effect.future { fulfill in + guard let identity = env.getIdentity() else { + fulfill(.success(.didFinishMakingContact(NoIdentityError() as NSError))) + return + } + do { + env.updateContact(try env.getClient()?.makeContactFromIdentity(identity: identity)) + fulfill(.success(.didFinishMakingContact(nil))) + } catch { + fulfill(.success(.didFinishMakingContact(error as NSError))) + } + } + .subscribe(on: env.bgScheduler) + .receive(on: env.mainScheduler) + .eraseToEffect() + + case .didFinishMakingContact(let error): + state.isMakingContact = false + if let error = error { + state.error = ErrorState(error: error) + } + return .none + + case .didDismissError: + state.error = nil + return .none + + case .error(_): return .none } } +public struct NoIdentityError: Error, LocalizedError { + public init() {} +} + #if DEBUG extension MyContactEnvironment { - public static let failing = MyContactEnvironment() + public static let failing = MyContactEnvironment( + getClient: { fatalError() }, + getIdentity: { fatalError() }, + observeContact: { fatalError() }, + updateContact: { _ in fatalError() }, + bgScheduler: .failing, + mainScheduler: .failing, + error: .failing + ) } #endif diff --git a/Example/example-app/Sources/MyContactFeature/MyContactView.swift b/Example/example-app/Sources/MyContactFeature/MyContactView.swift index 746a5317..88f9d4b8 100644 --- a/Example/example-app/Sources/MyContactFeature/MyContactView.swift +++ b/Example/example-app/Sources/MyContactFeature/MyContactView.swift @@ -1,4 +1,7 @@ import ComposableArchitecture +import ComposablePresentation +import ElixxirDAppsSDK +import ErrorFeature import SwiftUI public struct MyContactView: View { @@ -9,17 +12,65 @@ public struct MyContactView: View { let store: Store<MyContactState, MyContactAction> struct ViewState: Equatable { - init(state: MyContactState) {} + let contact: Data? + let isMakingContact: Bool + + init(state: MyContactState) { + contact = state.contact + isMakingContact = state.isMakingContact + } + + var isLoading: Bool { + isMakingContact + } } public var body: some View { WithViewStore(store.scope(state: ViewState.init)) { viewStore in - Text("MyContactView") - .navigationTitle("My contact") - .task { - viewStore.send(.viewDidLoad) + Form { + Section { + Text(string(for: viewStore.contact)) + .textSelection(.enabled) } + + Section { + Button { + viewStore.send(.makeContact) + } label: { + HStack { + Text("Make contact from identity") + Spacer() + if viewStore.isMakingContact { + ProgressView() + } + } + } + } + .disabled(viewStore.isLoading) + } + .navigationTitle("My contact") + .navigationBarBackButtonHidden(viewStore.isLoading) + .task { + viewStore.send(.viewDidLoad) + } + .sheet( + store.scope( + state: \.error, + action: MyContactAction.error + ), + onDismiss: { + viewStore.send(.didDismissError) + }, + content: ErrorView.init(store:) + ) + } + } + + func string(for contact: Data?) -> String { + guard let contact = contact else { + return "No contact" } + return String(data: contact, encoding: .utf8) ?? "Decoding error" } } diff --git a/Example/example-app/Tests/MyContactFeatureTests/MyContactFeatureTests.swift b/Example/example-app/Tests/MyContactFeatureTests/MyContactFeatureTests.swift index dd026572..862be08d 100644 --- a/Example/example-app/Tests/MyContactFeatureTests/MyContactFeatureTests.swift +++ b/Example/example-app/Tests/MyContactFeatureTests/MyContactFeatureTests.swift @@ -1,8 +1,174 @@ +import Combine +import ComposableArchitecture +import CustomDump +import ElixxirDAppsSDK +import ErrorFeature import XCTest @testable import MyContactFeature final class MyContactFeatureTests: XCTestCase { - func testExample() { - XCTAssert(true) + func testViewDidLoad() { + let myContactSubject = PassthroughSubject<Data?, Never>() + let bgScheduler = DispatchQueue.test + let mainScheduler = DispatchQueue.test + + var env = MyContactEnvironment.failing + env.observeContact = { myContactSubject.eraseToAnyPublisher() } + env.bgScheduler = bgScheduler.eraseToAnyScheduler() + env.mainScheduler = mainScheduler.eraseToAnyScheduler() + + let store = TestStore( + initialState: MyContactState(id: UUID()), + reducer: myContactReducer, + environment: env + ) + + store.send(.viewDidLoad) + store.receive(.observeMyContact) + + bgScheduler.advance() + let contact = "\(Int.random(in: 100...999))".data(using: .utf8)! + myContactSubject.send(contact) + mainScheduler.advance() + + store.receive(.didUpdateMyContact(contact)) { + $0.contact = contact + } + + myContactSubject.send(nil) + mainScheduler.advance() + + store.receive(.didUpdateMyContact(nil)) { + $0.contact = nil + } + + myContactSubject.send(completion: .finished) + mainScheduler.advance() + } + + func testMakeContact() { + let identity = Identity.stub() + let newContact = "\(Int.random(in: 100...999))".data(using: .utf8)! + var didMakeContactFromIdentity = [Identity]() + var didUpdateContact = [Data?]() + let bgScheduler = DispatchQueue.test + let mainScheduler = DispatchQueue.test + + var env = MyContactEnvironment.failing + env.getClient = { + var client = Client.failing + client.makeContactFromIdentity.get = { identity in + didMakeContactFromIdentity.append(identity) + return newContact + } + return client + } + env.updateContact = { didUpdateContact.append($0) } + env.getIdentity = { identity } + env.bgScheduler = bgScheduler.eraseToAnyScheduler() + env.mainScheduler = mainScheduler.eraseToAnyScheduler() + + let store = TestStore( + initialState: MyContactState(id: UUID()), + reducer: myContactReducer, + environment: env + ) + + store.send(.makeContact) { + $0.isMakingContact = true + } + + bgScheduler.advance() + + XCTAssertNoDifference(didMakeContactFromIdentity, [identity]) + XCTAssertNoDifference(didUpdateContact, [newContact]) + + mainScheduler.advance() + + store.receive(.didFinishMakingContact(nil)) { + $0.isMakingContact = false + } + } + + func testMakeContactWithoutIdentity() { + let error = NoIdentityError() as NSError + let bgScheduler = DispatchQueue.test + let mainScheduler = DispatchQueue.test + + var env = MyContactEnvironment.failing + env.getIdentity = { nil } + env.bgScheduler = bgScheduler.eraseToAnyScheduler() + env.mainScheduler = mainScheduler.eraseToAnyScheduler() + + let store = TestStore( + initialState: MyContactState(id: UUID()), + reducer: myContactReducer, + environment: env + ) + + store.send(.makeContact) { + $0.isMakingContact = true + } + + bgScheduler.advance() + mainScheduler.advance() + + store.receive(.didFinishMakingContact(error)) { + $0.isMakingContact = false + $0.error = ErrorState(error: error) + } + + store.send(.didDismissError) { + $0.error = nil + } + } + + func testMakeContactFailure() { + let error = NSError(domain: "test", code: 1234) + let bgScheduler = DispatchQueue.test + let mainScheduler = DispatchQueue.test + + var env = MyContactEnvironment.failing + env.getClient = { + var client = Client.failing + client.makeContactFromIdentity.get = { _ in throw error } + return client + } + env.getIdentity = { .stub() } + env.bgScheduler = bgScheduler.eraseToAnyScheduler() + env.mainScheduler = mainScheduler.eraseToAnyScheduler() + + let store = TestStore( + initialState: MyContactState(id: UUID()), + reducer: myContactReducer, + environment: env + ) + + store.send(.makeContact) { + $0.isMakingContact = true + } + + bgScheduler.advance() + mainScheduler.advance() + + store.receive(.didFinishMakingContact(error)) { + $0.isMakingContact = false + $0.error = ErrorState(error: error) + } + + store.send(.didDismissError) { + $0.error = nil + } + } +} + +private extension Identity { + static func stub() -> Identity { + Identity( + id: "\(Int.random(in: 100...999))".data(using: .utf8)!, + rsaPrivatePem: "\(Int.random(in: 100...999))".data(using: .utf8)!, + salt: "\(Int.random(in: 100...999))".data(using: .utf8)!, + dhKeyPrivate: "\(Int.random(in: 100...999))".data(using: .utf8)! + ) } } -- GitLab