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 0000000000000000000000000000000000000000..60c61fd3b79f6c748a322027d0ca61e8f4e8df28 --- /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/MyIdentityFeature.xcscheme b/Example/example-app/.swiftpm/xcode/xcshareddata/xcschemes/MyIdentityFeature.xcscheme new file mode 100644 index 0000000000000000000000000000000000000000..e69b1d44aa7ba292ef94bb8db602faa9111304f6 --- /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 ac213623f3e728760923364f7769a3ffd075fbd1..c6edd5dda410050c40e6df64bd48b668c357afde 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,34 @@ 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" + 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 +129,26 @@ ReferencedContainer = "container:"> </BuildableReference> </TestableReference> + <TestableReference + skipped = "NO"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "MyContactFeatureTests" + BuildableName = "MyContactFeatureTests" + BlueprintName = "MyContactFeatureTests" + 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 55d546265e9cafd943d11e006d1d3ddcb298cad3..6aa4531627631a16dff789a4acbef611711dd361 100644 --- a/Example/example-app/Package.swift +++ b/Example/example-app/Package.swift @@ -32,6 +32,14 @@ let package = Package( name: "LandingFeature", targets: ["LandingFeature"] ), + .library( + name: "MyContactFeature", + targets: ["MyContactFeature"] + ), + .library( + name: "MyIdentityFeature", + targets: ["MyIdentityFeature"] + ), .library( name: "SessionFeature", targets: ["SessionFeature"] @@ -58,6 +66,8 @@ let package = Package( dependencies: [ .target(name: "ErrorFeature"), .target(name: "LandingFeature"), + .target(name: "MyContactFeature"), + .target(name: "MyIdentityFeature"), .target(name: "SessionFeature"), .product( name: "ElixxirDAppsSDK", @@ -132,10 +142,64 @@ let package = Package( ], swiftSettings: swiftSettings ), + .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 + ), + .testTarget( + name: "MyContactFeatureTests", + dependencies: [ + .target(name: "MyContactFeature"), + ], + swiftSettings: swiftSettings + ), + .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" + ), + ], + swiftSettings: swiftSettings + ), + .testTarget( + name: "MyIdentityFeatureTests", + dependencies: [ + .target(name: "MyIdentityFeature"), + ], + swiftSettings: swiftSettings + ), .target( name: "SessionFeature", dependencies: [ .target(name: "ErrorFeature"), + .target(name: "MyContactFeature"), + .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 341cb46c6bbc7515e222c9e4157fab6f42c82160..65f4728edaf4f763712e3e9ef425e923135ba2ef 100644 --- a/Example/example-app/Sources/AppFeature/App.swift +++ b/Example/example-app/Sources/AppFeature/App.swift @@ -3,6 +3,8 @@ import ComposableArchitecture import ElixxirDAppsSDK import ErrorFeature import LandingFeature +import MyContactFeature +import MyIdentityFeature import SessionFeature import SwiftUI @@ -22,6 +24,8 @@ struct App: SwiftUI.App { 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", @@ -44,7 +48,26 @@ extension AppEnvironment { session: SessionEnvironment( getClient: { clientSubject.value }, bgScheduler: bgScheduler, - mainScheduler: mainScheduler + mainScheduler: mainScheduler, + makeId: UUID.init, + error: ErrorEnvironment(), + myIdentity: MyIdentityEnvironment( + getClient: { clientSubject.value }, + observeIdentity: { identitySubject.eraseToAnyPublisher() }, + updateIdentity: { identitySubject.value = $0 }, + bgScheduler: bgScheduler, + mainScheduler: mainScheduler, + error: ErrorEnvironment() + ), + 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 new file mode 100644 index 0000000000000000000000000000000000000000..317b646f1f7b5897f1cd4b4829f41cd8e67fd259 --- /dev/null +++ b/Example/example-app/Sources/MyContactFeature/MyContactFeature.swift @@ -0,0 +1,138 @@ +import Combine +import ComposableArchitecture +import ComposablePresentation +import ElixxirDAppsSDK +import ErrorFeature + +public struct MyContactState: Equatable { + public init( + 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( + 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( + 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 new file mode 100644 index 0000000000000000000000000000000000000000..88f9d4b8fa84272d7ef3695fed506410d4010aa1 --- /dev/null +++ b/Example/example-app/Sources/MyContactFeature/MyContactView.swift @@ -0,0 +1,90 @@ +import ComposableArchitecture +import ComposablePresentation +import ElixxirDAppsSDK +import ErrorFeature +import SwiftUI + +public struct MyContactView: View { + public init(store: Store<MyContactState, MyContactAction>) { + self.store = store + } + + let store: Store<MyContactState, MyContactAction> + + struct ViewState: Equatable { + 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 + 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" + } +} + +#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/Sources/MyIdentityFeature/MyIdentityFeature.swift b/Example/example-app/Sources/MyIdentityFeature/MyIdentityFeature.swift new file mode 100644 index 0000000000000000000000000000000000000000..df4559d5cb56cb3f47c0ad79c57bbe5c0296a3bb --- /dev/null +++ b/Example/example-app/Sources/MyIdentityFeature/MyIdentityFeature.swift @@ -0,0 +1,129 @@ +import Combine +import ComposableArchitecture +import ComposablePresentation +import ElixxirDAppsSDK +import ErrorFeature + +public struct MyIdentityState: Equatable { + public init( + id: UUID, + identity: Identity? = nil, + 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? +} + +public enum MyIdentityAction: Equatable { + case viewDidLoad + case observeMyIdentity + case didUpdateMyIdentity(Identity?) + case makeIdentity + case didFinishMakingIdentity(NSError?) + case didDismissError + case error(ErrorAction) +} + +public struct MyIdentityEnvironment { + public init( + getClient: @escaping () -> Client?, + observeIdentity: @escaping () -> AnyPublisher<Identity?, Never>, + updateIdentity: @escaping (Identity?) -> Void, + bgScheduler: 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? + public var observeIdentity: () -> AnyPublisher<Identity?, Never> + 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> +{ 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 + + case .makeIdentity: + state.isMakingIdentity = true + return Effect.future { fulfill in + do { + env.updateIdentity(try env.getClient()?.makeIdentity()) + fulfill(.success(.didFinishMakingIdentity(nil))) + } catch { + fulfill(.success(.didFinishMakingIdentity(error as NSError))) + } + } + .subscribe(on: env.bgScheduler) + .receive(on: env.mainScheduler) + .eraseToEffect() + + case .didDismissError: + state.error = nil + return .none + + case .didFinishMakingIdentity(let error): + state.isMakingIdentity = false + if let error = 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 { + public static let failing = MyIdentityEnvironment( + getClient: { fatalError() }, + observeIdentity: { fatalError() }, + updateIdentity: { _ in fatalError() }, + bgScheduler: .failing, + mainScheduler: .failing, + error: .failing + ) +} +#endif diff --git a/Example/example-app/Sources/MyIdentityFeature/MyIdentityView.swift b/Example/example-app/Sources/MyIdentityFeature/MyIdentityView.swift new file mode 100644 index 0000000000000000000000000000000000000000..61e09c1355755a7f1955e91ad7054d78f3689f5a --- /dev/null +++ b/Example/example-app/Sources/MyIdentityFeature/MyIdentityView.swift @@ -0,0 +1,97 @@ +import ComposableArchitecture +import ComposablePresentation +import ElixxirDAppsSDK +import ErrorFeature +import SwiftUI + +public struct MyIdentityView: View { + public init(store: Store<MyIdentityState, MyIdentityAction>) { + self.store = store + } + + let store: Store<MyIdentityState, MyIdentityAction> + + struct ViewState: Equatable { + 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 + 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)" + } + } +} + +#if DEBUG +public struct MyIdentityView_Previews: PreviewProvider { + public static var previews: some View { + NavigationView { + MyIdentityView(store: .init( + initialState: .init(id: UUID()), + reducer: .empty, + environment: () + )) + } + .navigationViewStyle(.stack) + } +} +#endif diff --git a/Example/example-app/Sources/SessionFeature/SessionFeature.swift b/Example/example-app/Sources/SessionFeature/SessionFeature.swift index 0756f738008e1ea1870ffd1a11a600fccab5e0ca..5b1005a9a632e62978cc8a66294566e2f1ba4069 100644 --- a/Example/example-app/Sources/SessionFeature/SessionFeature.swift +++ b/Example/example-app/Sources/SessionFeature/SessionFeature.swift @@ -2,24 +2,32 @@ import Combine import ComposableArchitecture import ElixxirDAppsSDK import ErrorFeature +import MyContactFeature +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, + 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 public var networkFollowerStatus: NetworkFollowerStatus? public var isNetworkHealthy: Bool? public var error: ErrorState? + public var myIdentity: MyIdentityState? + public var myContact: MyContactState? } public enum SessionAction: Equatable { @@ -31,23 +39,41 @@ public enum SessionAction: Equatable { case monitorNetworkHealth(Bool) case didUpdateNetworkHealth(Bool?) case didDismissError + case presentMyIdentity + case didDismissMyIdentity + case presentMyContact + case didDismissMyContact case error(ErrorAction) + case myIdentity(MyIdentityAction) + case myContact(MyContactAction) } public struct SessionEnvironment { public init( getClient: @escaping () -> Client?, bgScheduler: AnySchedulerOf<DispatchQueue>, - mainScheduler: AnySchedulerOf<DispatchQueue> + mainScheduler: AnySchedulerOf<DispatchQueue>, + makeId: @escaping () -> UUID, + error: ErrorEnvironment, + myIdentity: MyIdentityEnvironment, + myContact: MyContactEnvironment ) { self.getClient = getClient self.bgScheduler = bgScheduler self.mainScheduler = mainScheduler + self.makeId = makeId + self.error = error + self.myIdentity = myIdentity + self.myContact = myContact } public var getClient: () -> Client? public var bgScheduler: AnySchedulerOf<DispatchQueue> public var mainScheduler: AnySchedulerOf<DispatchQueue> + public var makeId: () -> UUID + public var error: ErrorEnvironment + public var myIdentity: MyIdentityEnvironment + public var myContact: MyContactEnvironment } public let sessionReducer = Reducer<SessionState, SessionAction, SessionEnvironment> @@ -129,17 +155,62 @@ 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 .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 } } +.presenting( + errorReducer, + state: .keyPath(\.error), + id: .keyPath(\.?.error), + action: /SessionAction.error, + environment: \.error +) +.presenting( + myIdentityReducer, + state: .keyPath(\.myIdentity), + id: .keyPath(\.?.id), + action: /SessionAction.myIdentity, + environment: \.myIdentity +) +.presenting( + myContactReducer, + state: .keyPath(\.myContact), + id: .keyPath(\.?.id), + action: /SessionAction.myContact, + environment: \.myContact +) #if DEBUG extension SessionEnvironment { public static let failing = SessionEnvironment( getClient: { .failing }, bgScheduler: .failing, - mainScheduler: .failing + mainScheduler: .failing, + makeId: { fatalError() }, + error: .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 395cfcb160067fedc31ec3ddd5200aa6960864e2..ea14d91ff94358572a9462647044186b3c47ab6a 100644 --- a/Example/example-app/Sources/SessionFeature/SessionView.swift +++ b/Example/example-app/Sources/SessionFeature/SessionView.swift @@ -2,6 +2,8 @@ import ComposableArchitecture import ComposablePresentation import ElixxirDAppsSDK import ErrorFeature +import MyContactFeature +import MyIdentityFeature import SwiftUI public struct SessionView: View { @@ -49,6 +51,28 @@ public struct SessionView: View { } header: { Text("Network health") } + + Section { + Button { + viewStore.send(.presentMyIdentity) + } label: { + HStack { + Text("My identity") + Spacer() + Image(systemName: "chevron.forward") + } + } + + Button { + viewStore.send(.presentMyContact) + } label: { + HStack { + Text("My contact") + Spacer() + Image(systemName: "chevron.forward") + } + } + } } .navigationTitle("Session") .task { @@ -64,6 +88,30 @@ 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:) + ) + ) + .background( + NavigationLinkWithStore( + store.scope( + state: \.myContact, + action: SessionAction.myContact + ), + onDeactivate: { + viewStore.send(.didDismissMyContact) + }, + destination: MyContactView.init(store:) + ) + ) } } } diff --git a/Example/example-app/Tests/MyContactFeatureTests/MyContactFeatureTests.swift b/Example/example-app/Tests/MyContactFeatureTests/MyContactFeatureTests.swift new file mode 100644 index 0000000000000000000000000000000000000000..862be08da221c884a14875b3ceb86010788073f4 --- /dev/null +++ b/Example/example-app/Tests/MyContactFeatureTests/MyContactFeatureTests.swift @@ -0,0 +1,174 @@ +import Combine +import ComposableArchitecture +import CustomDump +import ElixxirDAppsSDK +import ErrorFeature +import XCTest +@testable import MyContactFeature + +final class MyContactFeatureTests: XCTestCase { + 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)! + ) + } +} diff --git a/Example/example-app/Tests/MyIdentityFeatureTests/MyIdentityFeatureTests.swift b/Example/example-app/Tests/MyIdentityFeatureTests/MyIdentityFeatureTests.swift new file mode 100644 index 0000000000000000000000000000000000000000..b426cc0fbd26c49bc14b462db938b1eb27afb712 --- /dev/null +++ b/Example/example-app/Tests/MyIdentityFeatureTests/MyIdentityFeatureTests.swift @@ -0,0 +1,133 @@ +import Combine +import ComposableArchitecture +import CustomDump +import ElixxirDAppsSDK +import ErrorFeature +import XCTest +@testable import MyIdentityFeature + +final class MyIdentityFeatureTests: XCTestCase { + 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() + } + + 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) { + $0.isMakingIdentity = true + } + + bgScheduler.advance() + + XCTAssertNoDifference(didUpdateIdentity, [newIdentity]) + + mainScheduler.advance() + + store.receive(.didFinishMakingIdentity(nil)) { + $0.isMakingIdentity = false + } + } + + 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) { + $0.isMakingIdentity = true + } + + bgScheduler.advance() + mainScheduler.advance() + + store.receive(.didFinishMakingIdentity(error)) { + $0.isMakingIdentity = 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)! + ) + } +} diff --git a/Example/example-app/Tests/SessionFeatureTests/SessionFeatureTests.swift b/Example/example-app/Tests/SessionFeatureTests/SessionFeatureTests.swift index 2ada840d7d9d02bdb1c8732836502ad15d15be33..5c1aa3babd922cf042c0df855604dceeae7c36f7 100644 --- a/Example/example-app/Tests/SessionFeatureTests/SessionFeatureTests.swift +++ b/Example/example-app/Tests/SessionFeatureTests/SessionFeatureTests.swift @@ -1,6 +1,8 @@ import ComposableArchitecture import ElixxirDAppsSDK import ErrorFeature +import MyContactFeature +import MyIdentityFeature import XCTest @testable import SessionFeature @@ -155,4 +157,46 @@ 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 + } + } + + 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 + } + } } diff --git a/Sources/ElixxirDAppsSDK/Client.swift b/Sources/ElixxirDAppsSDK/Client.swift index c47d98c4009674a9fd144a0ce3b323f4838ff681..621270e4d1896e956f2cee89798ba2bc9e21f576 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