diff --git a/Examples/xx-messenger/.swiftpm/xcode/xcshareddata/xcschemes/ContactFeature.xcscheme b/Examples/xx-messenger/.swiftpm/xcode/xcshareddata/xcschemes/ContactFeature.xcscheme new file mode 100644 index 0000000000000000000000000000000000000000..f4e87fe12e26e32585ff4eb739a425467ad1d237 --- /dev/null +++ b/Examples/xx-messenger/.swiftpm/xcode/xcshareddata/xcschemes/ContactFeature.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 = "ContactFeature" + BuildableName = "ContactFeature" + BlueprintName = "ContactFeature" + 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 = "ContactFeatureTests" + BuildableName = "ContactFeatureTests" + BlueprintName = "ContactFeatureTests" + 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 = "ContactFeature" + BuildableName = "ContactFeature" + BlueprintName = "ContactFeature" + ReferencedContainer = "container:"> + </BuildableReference> + </MacroExpansion> + </ProfileAction> + <AnalyzeAction + buildConfiguration = "Debug"> + </AnalyzeAction> + <ArchiveAction + buildConfiguration = "Release" + revealArchiveInOrganizer = "YES"> + </ArchiveAction> +</Scheme> diff --git a/Examples/xx-messenger/.swiftpm/xcode/xcshareddata/xcschemes/SendRequestFeature.xcscheme b/Examples/xx-messenger/.swiftpm/xcode/xcshareddata/xcschemes/SendRequestFeature.xcscheme new file mode 100644 index 0000000000000000000000000000000000000000..2f70b385a82f17839c21022e2b22b2a67a715333 --- /dev/null +++ b/Examples/xx-messenger/.swiftpm/xcode/xcshareddata/xcschemes/SendRequestFeature.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 = "SendRequestFeature" + BuildableName = "SendRequestFeature" + BlueprintName = "SendRequestFeature" + 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 = "SendRequestFeatureTests" + BuildableName = "SendRequestFeatureTests" + BlueprintName = "SendRequestFeatureTests" + 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 = "SendRequestFeature" + BuildableName = "SendRequestFeature" + BlueprintName = "SendRequestFeature" + ReferencedContainer = "container:"> + </BuildableReference> + </MacroExpansion> + </ProfileAction> + <AnalyzeAction + buildConfiguration = "Debug"> + </AnalyzeAction> + <ArchiveAction + buildConfiguration = "Release" + revealArchiveInOrganizer = "YES"> + </ArchiveAction> +</Scheme> diff --git a/Examples/xx-messenger/Package.swift b/Examples/xx-messenger/Package.swift index f9ceb75fdf29afbda5362c20139003d77ebb131f..77b97a0f38f4516e0ae9b60feeb18364dbb02247 100644 --- a/Examples/xx-messenger/Package.swift +++ b/Examples/xx-messenger/Package.swift @@ -20,9 +20,11 @@ let package = Package( products: [ .library(name: "AppCore", targets: ["AppCore"]), .library(name: "AppFeature", targets: ["AppFeature"]), + .library(name: "ContactFeature", targets: ["ContactFeature"]), .library(name: "HomeFeature", targets: ["HomeFeature"]), .library(name: "RegisterFeature", targets: ["RegisterFeature"]), .library(name: "RestoreFeature", targets: ["RestoreFeature"]), + .library(name: "SendRequestFeature", targets: ["SendRequestFeature"]), .library(name: "UserSearchFeature", targets: ["UserSearchFeature"]), .library(name: "WelcomeFeature", targets: ["WelcomeFeature"]), ], @@ -52,6 +54,7 @@ let package = Package( name: "AppCore", dependencies: [ .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), + .product(name: "XXClient", package: "elixxir-dapps-sdk-swift"), .product(name: "XXDatabase", package: "client-ios-db"), .product(name: "XXModels", package: "client-ios-db"), ], @@ -68,9 +71,11 @@ let package = Package( name: "AppFeature", dependencies: [ .target(name: "AppCore"), + .target(name: "ContactFeature"), .target(name: "HomeFeature"), .target(name: "RegisterFeature"), .target(name: "RestoreFeature"), + .target(name: "SendRequestFeature"), .target(name: "UserSearchFeature"), .target(name: "WelcomeFeature"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), @@ -87,6 +92,25 @@ let package = Package( ], swiftSettings: swiftSettings ), + .target( + name: "ContactFeature", + dependencies: [ + .target(name: "AppCore"), + .target(name: "SendRequestFeature"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + .product(name: "ComposablePresentation", package: "swift-composable-presentation"), + .product(name: "XXMessengerClient", package: "elixxir-dapps-sdk-swift"), + .product(name: "XXModels", package: "client-ios-db"), + ], + swiftSettings: swiftSettings + ), + .testTarget( + name: "ContactFeatureTests", + dependencies: [ + .target(name: "ContactFeature"), + ], + swiftSettings: swiftSettings + ), .target( name: "HomeFeature", dependencies: [ @@ -111,6 +135,7 @@ let package = Package( dependencies: [ .target(name: "AppCore"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + .product(name: "XXClient", package: "elixxir-dapps-sdk-swift"), .product(name: "XXMessengerClient", package: "elixxir-dapps-sdk-swift"), .product(name: "XXModels", package: "client-ios-db"), ], @@ -137,12 +162,33 @@ let package = Package( ], swiftSettings: swiftSettings ), + .target( + name: "SendRequestFeature", + dependencies: [ + .target(name: "AppCore"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + .product(name: "XXClient", package: "elixxir-dapps-sdk-swift"), + .product(name: "XXMessengerClient", package: "elixxir-dapps-sdk-swift"), + .product(name: "XXModels", package: "client-ios-db"), + ], + swiftSettings: swiftSettings + ), + .testTarget( + name: "SendRequestFeatureTests", + dependencies: [ + .target(name: "SendRequestFeature"), + ], + swiftSettings: swiftSettings + ), .target( name: "UserSearchFeature", dependencies: [ + .target(name: "ContactFeature"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + .product(name: "ComposablePresentation", package: "swift-composable-presentation"), .product(name: "XXClient", package: "elixxir-dapps-sdk-swift"), .product(name: "XXMessengerClient", package: "elixxir-dapps-sdk-swift"), + .product(name: "XXModels", package: "client-ios-db"), ], swiftSettings: swiftSettings ), diff --git a/Examples/xx-messenger/Project/XXMessenger.xcodeproj/xcshareddata/xcschemes/XXMessenger.xcscheme b/Examples/xx-messenger/Project/XXMessenger.xcodeproj/xcshareddata/xcschemes/XXMessenger.xcscheme index 041cf3f70c88c188af478b1c3ec2bb88d5a7e4f1..0d7cb106a1c962bb00f93bd1668ae0e80dbcc173 100644 --- a/Examples/xx-messenger/Project/XXMessenger.xcodeproj/xcshareddata/xcschemes/XXMessenger.xcscheme +++ b/Examples/xx-messenger/Project/XXMessenger.xcodeproj/xcshareddata/xcschemes/XXMessenger.xcscheme @@ -49,6 +49,16 @@ ReferencedContainer = "container:.."> </BuildableReference> </TestableReference> + <TestableReference + skipped = "NO"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "ContactFeatureTests" + BuildableName = "ContactFeatureTests" + BlueprintName = "ContactFeatureTests" + ReferencedContainer = "container:.."> + </BuildableReference> + </TestableReference> <TestableReference skipped = "NO"> <BuildableReference @@ -79,6 +89,16 @@ ReferencedContainer = "container:.."> </BuildableReference> </TestableReference> + <TestableReference + skipped = "NO"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "SendRequestFeatureTests" + BuildableName = "SendRequestFeatureTests" + BlueprintName = "SendRequestFeatureTests" + ReferencedContainer = "container:.."> + </BuildableReference> + </TestableReference> <TestableReference skipped = "NO"> <BuildableReference diff --git a/Examples/xx-messenger/Sources/AppCore/DBManager/DBManager.swift b/Examples/xx-messenger/Sources/AppCore/DBManager/DBManager.swift index 178e1078e56aaddaf6ef8170834ea830b944aac5..f591dc1c2d37c3f14cf1a47905ccee2e3379fad1 100644 --- a/Examples/xx-messenger/Sources/AppCore/DBManager/DBManager.swift +++ b/Examples/xx-messenger/Sources/AppCore/DBManager/DBManager.swift @@ -4,6 +4,7 @@ public struct DBManager { public var hasDB: DBManagerHasDB public var makeDB: DBManagerMakeDB public var getDB: DBManagerGetDB + public var removeDB: DBManagerRemoveDB } extension DBManager { @@ -17,7 +18,8 @@ extension DBManager { return DBManager( hasDB: .init { container.db != nil }, makeDB: .live(setDB: { container.db = $0 }), - getDB: .live(getDB: { container.db }) + getDB: .live(getDB: { container.db }), + removeDB: .live(getDB: { container.db }, unsetDB: { container.db = nil }) ) } } @@ -26,6 +28,7 @@ extension DBManager { public static let unimplemented = DBManager( hasDB: .unimplemented, makeDB: .unimplemented, - getDB: .unimplemented + getDB: .unimplemented, + removeDB: .unimplemented ) } diff --git a/Examples/xx-messenger/Sources/AppCore/DBManager/DBManagerRemoveDB.swift b/Examples/xx-messenger/Sources/AppCore/DBManager/DBManagerRemoveDB.swift new file mode 100644 index 0000000000000000000000000000000000000000..69ab6e020d1ae782b048c82d75fb667eb3d7b985 --- /dev/null +++ b/Examples/xx-messenger/Sources/AppCore/DBManager/DBManagerRemoveDB.swift @@ -0,0 +1,30 @@ +import Foundation +import XCTestDynamicOverlay +import XXDatabase +import XXModels + +public struct DBManagerRemoveDB { + public var run: () throws -> Void + + public func callAsFunction() throws -> Void { + try run() + } +} + +extension DBManagerRemoveDB { + public static func live( + getDB: @escaping () -> Database?, + unsetDB: @escaping () -> Void + ) -> DBManagerRemoveDB { + DBManagerRemoveDB { + try getDB()?.drop() + unsetDB() + } + } +} + +extension DBManagerRemoveDB { + public static let unimplemented = DBManagerRemoveDB( + run: XCTUnimplemented("\(Self.self)") + ) +} diff --git a/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift b/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift index 5d7777802ed09dcfb2e36b14ba2a5b55f1fe906e..298cb9f6e78b7d3bea1c0e5ec35f1fac235519ea 100644 --- a/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift +++ b/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift @@ -1,8 +1,10 @@ import AppCore +import ContactFeature import Foundation import HomeFeature import RegisterFeature import RestoreFeature +import SendRequestFeature import UserSearchFeature import WelcomeFeature import XXMessengerClient @@ -34,7 +36,7 @@ extension AppEnvironment { home: { HomeEnvironment( messenger: messenger, - db: dbManager.getDB, + dbManager: dbManager, mainQueue: mainQueue, bgQueue: bgQueue, register: { @@ -50,7 +52,26 @@ extension AppEnvironment { UserSearchEnvironment( messenger: messenger, mainQueue: mainQueue, - bgQueue: bgQueue + bgQueue: bgQueue, + result: { + UserSearchResultEnvironment() + }, + contact: { + ContactEnvironment( + messenger: messenger, + db: dbManager.getDB, + mainQueue: mainQueue, + bgQueue: bgQueue, + sendRequest: { + SendRequestEnvironment( + messenger: messenger, + db: dbManager.getDB, + mainQueue: mainQueue, + bgQueue: bgQueue + ) + } + ) + } ) } ) diff --git a/Examples/xx-messenger/Sources/ContactFeature/ContactFeature.swift b/Examples/xx-messenger/Sources/ContactFeature/ContactFeature.swift new file mode 100644 index 0000000000000000000000000000000000000000..8126b792074a9a82050228cc0d0edbe8d26742ce --- /dev/null +++ b/Examples/xx-messenger/Sources/ContactFeature/ContactFeature.swift @@ -0,0 +1,152 @@ +import AppCore +import ComposableArchitecture +import ComposablePresentation +import Foundation +import SendRequestFeature +import XCTestDynamicOverlay +import XXClient +import XXMessengerClient +import XXModels + +public struct ContactState: Equatable { + public init( + id: Data, + dbContact: XXModels.Contact? = nil, + xxContact: XXClient.Contact? = nil, + importUsername: Bool = true, + importEmail: Bool = true, + importPhone: Bool = true, + sendRequest: SendRequestState? = nil + ) { + self.id = id + self.dbContact = dbContact + self.xxContact = xxContact + self.importUsername = importUsername + self.importEmail = importEmail + self.importPhone = importPhone + self.sendRequest = sendRequest + } + + public var id: Data + public var dbContact: XXModels.Contact? + public var xxContact: XXClient.Contact? + @BindableState public var importUsername: Bool + @BindableState public var importEmail: Bool + @BindableState public var importPhone: Bool + public var sendRequest: SendRequestState? +} + +public enum ContactAction: Equatable, BindableAction { + case start + case dbContactFetched(XXModels.Contact?) + case importFactsTapped + case sendRequestTapped + case sendRequestDismissed + case sendRequest(SendRequestAction) + case binding(BindingAction<ContactState>) +} + +public struct ContactEnvironment { + public init( + messenger: Messenger, + db: DBManagerGetDB, + mainQueue: AnySchedulerOf<DispatchQueue>, + bgQueue: AnySchedulerOf<DispatchQueue>, + sendRequest: @escaping () -> SendRequestEnvironment + ) { + self.messenger = messenger + self.db = db + self.mainQueue = mainQueue + self.bgQueue = bgQueue + self.sendRequest = sendRequest + } + + public var messenger: Messenger + public var db: DBManagerGetDB + public var mainQueue: AnySchedulerOf<DispatchQueue> + public var bgQueue: AnySchedulerOf<DispatchQueue> + public var sendRequest: () -> SendRequestEnvironment +} + +#if DEBUG +extension ContactEnvironment { + public static let unimplemented = ContactEnvironment( + messenger: .unimplemented, + db: .unimplemented, + mainQueue: .unimplemented, + bgQueue: .unimplemented, + sendRequest: { .unimplemented } + ) +} +#endif + +public let contactReducer = Reducer<ContactState, ContactAction, ContactEnvironment> +{ state, action, env in + enum DBFetchEffectID {} + + switch action { + case .start: + return try! env.db().fetchContactsPublisher(.init(id: [state.id])) + .assertNoFailure() + .map(\.first) + .map(ContactAction.dbContactFetched) + .subscribe(on: env.bgQueue) + .receive(on: env.mainQueue) + .eraseToEffect() + .cancellable(id: DBFetchEffectID.self, cancelInFlight: true) + + case .dbContactFetched(let contact): + state.dbContact = contact + return .none + + case .importFactsTapped: + guard let xxContact = state.xxContact else { return .none } + return .fireAndForget { [state] in + var dbContact = state.dbContact ?? XXModels.Contact(id: state.id) + dbContact.marshaled = xxContact.data + if state.importUsername { + dbContact.username = try? xxContact.getFact(.username)?.fact + } + if state.importEmail { + dbContact.email = try? xxContact.getFact(.email)?.fact + } + if state.importPhone { + dbContact.phone = try? xxContact.getFact(.phone)?.fact + } + _ = try! env.db().saveContact(dbContact) + } + .subscribe(on: env.bgQueue) + .receive(on: env.mainQueue) + .eraseToEffect() + + case .sendRequestTapped: + if let xxContact = state.xxContact { + state.sendRequest = SendRequestState(contact: xxContact) + } else if let marshaled = state.dbContact?.marshaled { + state.sendRequest = SendRequestState(contact: .live(marshaled)) + } + return .none + + case .sendRequestDismissed: + state.sendRequest = nil + return .none + + case .sendRequest(.sendSucceeded): + state.sendRequest = nil + return .none + + case .sendRequest(_): + return .none + + case .binding(_): + return .none + } +} +.binding() +.presenting( + sendRequestReducer, + state: .keyPath(\.sendRequest), + id: .notNil(), + action: /ContactAction.sendRequest, + environment: { $0.sendRequest() } +) diff --git a/Examples/xx-messenger/Sources/ContactFeature/ContactView.swift b/Examples/xx-messenger/Sources/ContactFeature/ContactView.swift new file mode 100644 index 0000000000000000000000000000000000000000..48743b07af69b695c6f6ec34159d2b0c75400458 --- /dev/null +++ b/Examples/xx-messenger/Sources/ContactFeature/ContactView.swift @@ -0,0 +1,226 @@ +import AppCore +import ComposableArchitecture +import ComposablePresentation +import SendRequestFeature +import SwiftUI +import XXClient +import XXModels + +public struct ContactView: View { + public init(store: Store<ContactState, ContactAction>) { + self.store = store + } + + let store: Store<ContactState, ContactAction> + + struct ViewState: Equatable { + var dbContact: XXModels.Contact? + var xxContactIsSet: Bool + var xxContactUsername: String? + var xxContactEmail: String? + var xxContactPhone: String? + var importUsername: Bool + var importEmail: Bool + var importPhone: Bool + + init(state: ContactState) { + dbContact = state.dbContact + xxContactIsSet = state.xxContact != nil + xxContactUsername = try? state.xxContact?.getFact(.username)?.fact + xxContactEmail = try? state.xxContact?.getFact(.email)?.fact + xxContactPhone = try? state.xxContact?.getFact(.phone)?.fact + importUsername = state.importUsername + importEmail = state.importEmail + importPhone = state.importPhone + } + } + + public var body: some View { + WithViewStore(store.scope(state: ViewState.init)) { viewStore in + Form { + if viewStore.xxContactIsSet { + Section { + Button { + viewStore.send(.set(\.$importUsername, !viewStore.importUsername)) + } label: { + HStack { + Label(viewStore.xxContactUsername ?? "", systemImage: "person") + .tint(Color.primary) + Spacer() + Image(systemName: viewStore.importUsername ? "checkmark.circle.fill" : "circle") + .foregroundColor(.accentColor) + } + } + + Button { + viewStore.send(.set(\.$importEmail, !viewStore.importEmail)) + } label: { + HStack { + Label(viewStore.xxContactEmail ?? "", systemImage: "envelope") + .tint(Color.primary) + Spacer() + Image(systemName: viewStore.importEmail ? "checkmark.circle.fill" : "circle") + .foregroundColor(.accentColor) + } + } + + Button { + viewStore.send(.set(\.$importPhone, !viewStore.importPhone)) + } label: { + HStack { + Label(viewStore.xxContactPhone ?? "", systemImage: "phone") + .tint(Color.primary) + Spacer() + Image(systemName: viewStore.importPhone ? "checkmark.circle.fill" : "circle") + .foregroundColor(.accentColor) + } + } + + Button { + viewStore.send(.importFactsTapped) + } label: { + if viewStore.dbContact == nil { + Text("Save contact") + } else { + Text("Update contact") + } + } + } header: { + Text("Facts") + } + } + + if let dbContact = viewStore.dbContact { + Section { + Label(dbContact.username ?? "", systemImage: "person") + Label(dbContact.email ?? "", systemImage: "envelope") + Label(dbContact.phone ?? "", systemImage: "phone") + } header: { + Text("Contact") + } + + Section { + switch dbContact.authStatus { + case .stranger: + HStack { + Text("Stranger") + Spacer() + Image(systemName: "person.fill.questionmark") + } + + case .requesting: + HStack { + Text("Sending auth request") + Spacer() + ProgressView() + } + + case .requested: + HStack { + Text("Request sent") + Spacer() + Image(systemName: "paperplane") + } + + case .requestFailed: + HStack { + Text("Sending request failed") + Spacer() + Image(systemName: "xmark.diamond.fill") + .foregroundColor(.red) + } + + case .verificationInProgress: + HStack { + Text("Verification is progress") + Spacer() + ProgressView() + } + + case .verified: + HStack { + Text("Verified") + Spacer() + Image(systemName: "person.fill.checkmark") + } + + case .verificationFailed: + HStack { + Text("Verification failed") + Spacer() + Image(systemName: "xmark.diamond.fill") + .foregroundColor(.red) + } + + case .confirming: + HStack { + Text("Confirming auth request") + Spacer() + ProgressView() + } + + case .confirmationFailed: + HStack { + Text("Confirmation failed") + Spacer() + Image(systemName: "xmark.diamond.fill") + .foregroundColor(.red) + } + + case .friend: + HStack { + Text("Friend") + Spacer() + Image(systemName: "person.fill.checkmark") + } + + case .hidden: + HStack { + Text("Hidden") + Spacer() + Image(systemName: "eye.slash") + } + } + Button { + viewStore.send(.sendRequestTapped) + } label: { + HStack { + Text("Send request") + Spacer() + Image(systemName: "chevron.forward") + } + } + } header: { + Text("Auth") + } + .animation(.default, value: viewStore.dbContact?.authStatus) + } + } + .navigationTitle("Contact") + .task { viewStore.send(.start) } + .background(NavigationLinkWithStore( + store.scope( + state: \.sendRequest, + action: ContactAction.sendRequest + ), + mapState: replayNonNil(), + onDeactivate: { viewStore.send(.sendRequestDismissed) }, + destination: SendRequestView.init(store:) + )) + } + } +} + +#if DEBUG +public struct ContactView_Previews: PreviewProvider { + public static var previews: some View { + ContactView(store: Store( + initialState: ContactState( + id: "contact-id".data(using: .utf8)! + ), + reducer: .empty, + environment: () + )) + } +} +#endif diff --git a/Examples/xx-messenger/Sources/HomeFeature/HomeFeature.swift b/Examples/xx-messenger/Sources/HomeFeature/HomeFeature.swift index 51083ea0ccea39c7bf13abdd0ff0566dac623529..712855ba1ba4aecfe467679b4fcc87090de4ee43 100644 --- a/Examples/xx-messenger/Sources/HomeFeature/HomeFeature.swift +++ b/Examples/xx-messenger/Sources/HomeFeature/HomeFeature.swift @@ -71,14 +71,14 @@ public enum HomeAction: Equatable { public struct HomeEnvironment { public init( messenger: Messenger, - db: DBManagerGetDB, + dbManager: DBManager, mainQueue: AnySchedulerOf<DispatchQueue>, bgQueue: AnySchedulerOf<DispatchQueue>, register: @escaping () -> RegisterEnvironment, userSearch: @escaping () -> UserSearchEnvironment ) { self.messenger = messenger - self.db = db + self.dbManager = dbManager self.mainQueue = mainQueue self.bgQueue = bgQueue self.register = register @@ -86,7 +86,7 @@ public struct HomeEnvironment { } public var messenger: Messenger - public var db: DBManagerGetDB + public var dbManager: DBManager public var mainQueue: AnySchedulerOf<DispatchQueue> public var bgQueue: AnySchedulerOf<DispatchQueue> public var register: () -> RegisterEnvironment @@ -96,7 +96,7 @@ public struct HomeEnvironment { extension HomeEnvironment { public static let unimplemented = HomeEnvironment( messenger: .unimplemented, - db: .unimplemented, + dbManager: .unimplemented, mainQueue: .unimplemented, bgQueue: .unimplemented, register: { .unimplemented }, @@ -197,13 +197,13 @@ public let homeReducer = Reducer<HomeState, HomeAction, HomeEnvironment> return .result { do { let contactId = try env.messenger.e2e.tryGet().getContact().getId() - let contact = try env.db().fetchContacts(.init(id: [contactId])).first + let contact = try env.dbManager.getDB().fetchContacts(.init(id: [contactId])).first if let username = contact?.username { let ud = try env.messenger.ud.tryGet() try ud.permanentDeleteAccount(username: Fact(fact: username, type: 0)) } try env.messenger.destroy() - try env.db().drop() + try env.dbManager.removeDB() return .success(.deleteAccount(.success)) } catch { return .success(.deleteAccount(.failure(error as NSError))) diff --git a/Examples/xx-messenger/Sources/RegisterFeature/RegisterFeature.swift b/Examples/xx-messenger/Sources/RegisterFeature/RegisterFeature.swift index 8e1f35411fdd0ec6d952f556420f9c72073c1e1f..cb43c43056f6d3e53c2b73b4bdfc10189f052b21 100644 --- a/Examples/xx-messenger/Sources/RegisterFeature/RegisterFeature.swift +++ b/Examples/xx-messenger/Sources/RegisterFeature/RegisterFeature.swift @@ -2,6 +2,7 @@ import AppCore import ComposableArchitecture import Foundation import XCTestDynamicOverlay +import XXClient import XXMessengerClient import XXModels @@ -81,7 +82,8 @@ public let registerReducer = Reducer<RegisterState, RegisterAction, RegisterEnvi do { let db = try env.db() try env.messenger.register(username: username) - let contact = env.messenger.e2e()!.getContact() + var contact = try env.messenger.e2e.tryGet().getContact() + try contact.setFact(.username, username) try db.saveContact(Contact( id: try contact.getId(), marshaled: contact.data, diff --git a/Examples/xx-messenger/Sources/SendRequestFeature/SendRequestFeature.swift b/Examples/xx-messenger/Sources/SendRequestFeature/SendRequestFeature.swift new file mode 100644 index 0000000000000000000000000000000000000000..f2625b91e6a4042624ad68141515ab34ace7c90c --- /dev/null +++ b/Examples/xx-messenger/Sources/SendRequestFeature/SendRequestFeature.swift @@ -0,0 +1,151 @@ +import AppCore +import ComposableArchitecture +import Foundation +import XCTestDynamicOverlay +import XXClient +import XXMessengerClient +import XXModels + +public struct SendRequestState: Equatable { + public init( + contact: XXClient.Contact, + myContact: XXClient.Contact? = nil, + sendUsername: Bool = true, + sendEmail: Bool = true, + sendPhone: Bool = true, + isSending: Bool = false, + failure: String? = nil + ) { + self.contact = contact + self.myContact = myContact + self.sendUsername = sendUsername + self.sendEmail = sendEmail + self.sendPhone = sendPhone + self.isSending = isSending + self.failure = failure + } + + public var contact: XXClient.Contact + public var myContact: XXClient.Contact? + @BindableState public var sendUsername: Bool + @BindableState public var sendEmail: Bool + @BindableState public var sendPhone: Bool + public var isSending: Bool + public var failure: String? +} + +public enum SendRequestAction: Equatable, BindableAction { + case start + case sendTapped + case sendSucceeded + case sendFailed(String) + case binding(BindingAction<SendRequestState>) + case myContactFetched(XXClient.Contact?) +} + +public struct SendRequestEnvironment { + public init( + messenger: Messenger, + db: DBManagerGetDB, + mainQueue: AnySchedulerOf<DispatchQueue>, + bgQueue: AnySchedulerOf<DispatchQueue> + ) { + self.messenger = messenger + self.db = db + self.mainQueue = mainQueue + self.bgQueue = bgQueue + } + + public var messenger: Messenger + public var db: DBManagerGetDB + public var mainQueue: AnySchedulerOf<DispatchQueue> + public var bgQueue: AnySchedulerOf<DispatchQueue> +} + +#if DEBUG +extension SendRequestEnvironment { + public static let unimplemented = SendRequestEnvironment( + messenger: .unimplemented, + db: .unimplemented, + mainQueue: .unimplemented, + bgQueue: .unimplemented + ) +} +#endif + +public let sendRequestReducer = Reducer<SendRequestState, SendRequestAction, SendRequestEnvironment> +{ state, action, env in + enum DBFetchEffectID {} + + switch action { + case .start: + return Effect + .catching { try env.messenger.e2e.tryGet().getContact().getId() } + .tryMap { try env.db().fetchContactsPublisher(.init(id: [$0])) } + .flatMap { $0 } + .assertNoFailure() + .map(\.first) + .map { $0?.marshaled.map { XXClient.Contact.live($0) } } + .map(SendRequestAction.myContactFetched) + .subscribe(on: env.bgQueue) + .receive(on: env.mainQueue) + .eraseToEffect() + .cancellable(id: DBFetchEffectID.self, cancelInFlight: true) + + case .myContactFetched(let contact): + state.myContact = contact + return .none + + case .sendTapped: + state.isSending = true + state.failure = nil + return .result { [state] in + func updateAuthStatus(_ authStatus: XXModels.Contact.AuthStatus) throws { + try env.db().bulkUpdateContacts( + .init(id: [try state.contact.getId()]), + .init(authStatus: authStatus) + ) + } + do { + try updateAuthStatus(.requesting) + let myFacts = try state.myContact?.getFacts() ?? [] + var includedFacts: [Fact] = [] + if state.sendUsername, let fact = myFacts.get(.username) { + includedFacts.append(fact) + } + if state.sendEmail, let fact = myFacts.get(.email) { + includedFacts.append(fact) + } + if state.sendPhone, let fact = myFacts.get(.phone) { + includedFacts.append(fact) + } + _ = try env.messenger.e2e.tryGet().requestAuthenticatedChannel( + partner: state.contact, + myFacts: includedFacts + ) + try updateAuthStatus(.requested) + return .success(.sendSucceeded) + } catch { + try? updateAuthStatus(.requestFailed) + return .success(.sendFailed(error.localizedDescription)) + } + } + .subscribe(on: env.bgQueue) + .receive(on: env.mainQueue) + .eraseToEffect() + + case .sendSucceeded: + state.isSending = false + state.failure = nil + return .none + + case .sendFailed(let failure): + state.isSending = false + state.failure = failure + return .none + + case .binding(_): + return .none + } +} +.binding() diff --git a/Examples/xx-messenger/Sources/SendRequestFeature/SendRequestView.swift b/Examples/xx-messenger/Sources/SendRequestFeature/SendRequestView.swift new file mode 100644 index 0000000000000000000000000000000000000000..5f1cd7d53e8300fc55ffc907a410fb66c2b92c77 --- /dev/null +++ b/Examples/xx-messenger/Sources/SendRequestFeature/SendRequestView.swift @@ -0,0 +1,167 @@ +import AppCore +import ComposableArchitecture +import SwiftUI +import XXClient + +public struct SendRequestView: View { + public init(store: Store<SendRequestState, SendRequestAction>) { + self.store = store + } + + let store: Store<SendRequestState, SendRequestAction> + + struct ViewState: Equatable { + var contactUsername: String? + var contactEmail: String? + var contactPhone: String? + var myUsername: String? + var myEmail: String? + var myPhone: String? + var sendUsername: Bool + var sendEmail: Bool + var sendPhone: Bool + var isSending: Bool + var failure: String? + + init(state: SendRequestState) { + contactUsername = try? state.contact.getFact(.username)?.fact + contactEmail = try? state.contact.getFact(.email)?.fact + contactPhone = try? state.contact.getFact(.phone)?.fact + myUsername = try? state.myContact?.getFact(.username)?.fact + myEmail = try? state.myContact?.getFact(.email)?.fact + myPhone = try? state.myContact?.getFact(.phone)?.fact + sendUsername = state.sendUsername + sendEmail = state.sendEmail + sendPhone = state.sendPhone + isSending = state.isSending + failure = state.failure + } + } + + public var body: some View { + WithViewStore(store.scope(state: ViewState.init)) { viewStore in + Form { + Section { + Button { + viewStore.send(.set(\.$sendUsername, !viewStore.sendUsername)) + } label: { + HStack { + Label(viewStore.myUsername ?? "", systemImage: "person") + .tint(Color.primary) + Spacer() + Image(systemName: viewStore.sendUsername ? "checkmark.circle.fill" : "circle") + .foregroundColor(.accentColor) + } + } + .animation(.default, value: viewStore.sendUsername) + + Button { + viewStore.send(.set(\.$sendEmail, !viewStore.sendEmail)) + } label: { + HStack { + Label(viewStore.myEmail ?? "", systemImage: "envelope") + .tint(Color.primary) + Spacer() + Image(systemName: viewStore.sendEmail ? "checkmark.circle.fill" : "circle") + .foregroundColor(.accentColor) + } + } + .animation(.default, value: viewStore.sendEmail) + + Button { + viewStore.send(.set(\.$sendPhone, !viewStore.sendPhone)) + } label: { + HStack { + Label(viewStore.myPhone ?? "", systemImage: "phone") + .tint(Color.primary) + Spacer() + Image(systemName: viewStore.sendPhone ? "checkmark.circle.fill" : "circle") + .foregroundColor(.accentColor) + } + } + .animation(.default, value: viewStore.sendPhone) + } header: { + Text("My facts") + } + .disabled(viewStore.isSending) + + Section { + Label(viewStore.contactUsername ?? "", systemImage: "person") + Label(viewStore.contactEmail ?? "", systemImage: "envelope") + Label(viewStore.contactPhone ?? "", systemImage: "phone") + } header: { + Text("Contact") + } + + Section { + Button { + viewStore.send(.sendTapped) + } label: { + HStack { + Text("Send request") + Spacer() + if viewStore.isSending { + ProgressView() + } else { + Image(systemName: "paperplane") + } + } + } + } + .disabled(viewStore.isSending) + + if let failure = viewStore.failure { + Section { + Text(failure) + } header: { + Text("Error") + } + } + } + .navigationTitle("Send Request") + .task { viewStore.send(.start) } + } + } +} + +#if DEBUG +public struct SendRequestView_Previews: PreviewProvider { + public static var previews: some View { + NavigationView { + SendRequestView(store: Store( + initialState: SendRequestState( + contact: { + var contact = XXClient.Contact.unimplemented("contact-data".data(using: .utf8)!) + contact.getFactsFromContact.run = { _ in + [ + Fact(fact: "contact-username", type: 0), + Fact(fact: "contact-email", type: 1), + Fact(fact: "contact-phone", type: 2), + ] + } + return contact + }(), + myContact: { + var contact = XXClient.Contact.unimplemented("my-data".data(using: .utf8)!) + contact.getFactsFromContact.run = { _ in + [ + Fact(fact: "my-username", type: 0), + Fact(fact: "my-email", type: 1), + Fact(fact: "my-phone", type: 2), + ] + } + return contact + }(), + sendUsername: true, + sendEmail: false, + sendPhone: true, + isSending: false, + failure: "Something went wrong" + ), + reducer: .empty, + environment: () + )) + } + } +} +#endif diff --git a/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchFeature.swift b/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchFeature.swift index 86a13c0ec97966619bea881f5ac0b8c9de962fed..1b10c1b3c416b6cac1ae579da1b71f186f83c583 100644 --- a/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchFeature.swift +++ b/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchFeature.swift @@ -1,4 +1,6 @@ import ComposableArchitecture +import ComposablePresentation +import ContactFeature import Foundation import XCTestDynamicOverlay import XXClient @@ -11,70 +13,60 @@ public struct UserSearchState: Equatable { case phone } - public struct Result: Equatable, Identifiable { - public init( - id: Data, - contact: Contact, - username: String? = nil, - email: String? = nil, - phone: String? = nil - ) { - self.id = id - self.contact = contact - self.username = username - self.email = email - self.phone = phone - } - - public var id: Data - public var contact: XXClient.Contact - public var username: String? - public var email: String? - public var phone: String? - } - public init( focusedField: Field? = nil, query: MessengerSearchUsers.Query = .init(), isSearching: Bool = false, failure: String? = nil, - results: IdentifiedArrayOf<Result> = [] + results: IdentifiedArrayOf<UserSearchResultState> = [], + contact: ContactState? = nil ) { self.focusedField = focusedField self.query = query self.isSearching = isSearching self.failure = failure self.results = results + self.contact = contact } @BindableState public var focusedField: Field? @BindableState public var query: MessengerSearchUsers.Query public var isSearching: Bool public var failure: String? - public var results: IdentifiedArrayOf<Result> + public var results: IdentifiedArrayOf<UserSearchResultState> + public var contact: ContactState? } public enum UserSearchAction: Equatable, BindableAction { case searchTapped case didFail(String) case didSucceed([Contact]) + case didDismissContact case binding(BindingAction<UserSearchState>) + case result(id: UserSearchResultState.ID, action: UserSearchResultAction) + case contact(ContactAction) } public struct UserSearchEnvironment { public init( messenger: Messenger, mainQueue: AnySchedulerOf<DispatchQueue>, - bgQueue: AnySchedulerOf<DispatchQueue> + bgQueue: AnySchedulerOf<DispatchQueue>, + result: @escaping () -> UserSearchResultEnvironment, + contact: @escaping () -> ContactEnvironment ) { self.messenger = messenger self.mainQueue = mainQueue self.bgQueue = bgQueue + self.result = result + self.contact = contact } public var messenger: Messenger public var mainQueue: AnySchedulerOf<DispatchQueue> public var bgQueue: AnySchedulerOf<DispatchQueue> + public var result: () -> UserSearchResultEnvironment + public var contact: () -> ContactEnvironment } #if DEBUG @@ -82,7 +74,9 @@ extension UserSearchEnvironment { public static let unimplemented = UserSearchEnvironment( messenger: .unimplemented, mainQueue: .unimplemented, - bgQueue: .unimplemented + bgQueue: .unimplemented, + result: { .unimplemented }, + contact: { .unimplemented } ) } #endif @@ -111,14 +105,7 @@ public let userSearchReducer = Reducer<UserSearchState, UserSearchAction, UserSe state.failure = nil state.results = IdentifiedArray(uniqueElements: contacts.compactMap { contact in guard let id = try? contact.getId() else { return nil } - let facts = (try? contact.getFacts()) ?? [] - return UserSearchState.Result( - id: id, - contact: contact, - username: facts.first(where: { $0.type == 0 })?.fact, - email: facts.first(where: { $0.type == 1 })?.fact, - phone: facts.first(where: { $0.type == 2 })?.fact - ) + return UserSearchResultState(id: id, xxContact: contact) }) return .none @@ -128,8 +115,32 @@ public let userSearchReducer = Reducer<UserSearchState, UserSearchAction, UserSe state.results = [] return .none - case .binding(_): + case .didDismissContact: + state.contact = nil + return .none + + case .result(let id, action: .tapped): + state.contact = ContactState( + id: id, + xxContact: state.results[id: id]?.xxContact + ) + return .none + + case .binding(_), .result(_, _), .contact(_): return .none } } .binding() +.presenting( + forEach: userSearchResultReducer, + state: \.results, + action: /UserSearchAction.result(id:action:), + environment: { $0.result() } +) +.presenting( + contactReducer, + state: .keyPath(\.contact), + id: .keyPath(\.?.id), + action: /UserSearchAction.contact, + environment: { $0.contact() } +) diff --git a/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchResultFeature.swift b/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchResultFeature.swift new file mode 100644 index 0000000000000000000000000000000000000000..839e6886236eec081b64616e64b8ff7c59811fbb --- /dev/null +++ b/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchResultFeature.swift @@ -0,0 +1,55 @@ +import ComposableArchitecture +import Foundation +import XCTestDynamicOverlay +import XXClient + +public struct UserSearchResultState: Equatable, Identifiable { + public init( + id: Data, + xxContact: XXClient.Contact, + username: String? = nil, + email: String? = nil, + phone: String? = nil + ) { + self.id = id + self.xxContact = xxContact + self.username = username + self.email = email + self.phone = phone + } + + public var id: Data + public var xxContact: XXClient.Contact + public var username: String? + public var email: String? + public var phone: String? +} + +public enum UserSearchResultAction: Equatable { + case start + case tapped +} + +public struct UserSearchResultEnvironment { + public init() {} +} + +#if DEBUG +extension UserSearchResultEnvironment { + public static let unimplemented = UserSearchResultEnvironment() +} +#endif + +public let userSearchResultReducer = Reducer<UserSearchResultState, UserSearchResultAction, UserSearchResultEnvironment> +{ state, action, env in + switch action { + case .start: + state.username = try? state.xxContact.getFact(.username)?.fact + state.email = try? state.xxContact.getFact(.email)?.fact + state.phone = try? state.xxContact.getFact(.phone)?.fact + return .none + + case .tapped: + return .none + } +} diff --git a/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchResultView.swift b/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchResultView.swift new file mode 100644 index 0000000000000000000000000000000000000000..fd29a84fb819761505b8b973c757f29a46597490 --- /dev/null +++ b/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchResultView.swift @@ -0,0 +1,74 @@ +import ComposableArchitecture +import SwiftUI +import XXModels + +public struct UserSearchResultView: View { + public init(store: Store<UserSearchResultState, UserSearchResultAction>) { + self.store = store + } + + let store: Store<UserSearchResultState, UserSearchResultAction> + + struct ViewState: Equatable { + var username: String? + var email: String? + var phone: String? + + init(state: UserSearchResultState) { + username = state.username + email = state.email + phone = state.phone + } + + var isEmpty: Bool { + username == nil && email == nil && phone == nil + } + } + + public var body: some View { + WithViewStore(store.scope(state: ViewState.init)) { viewStore in + Section { + Button { + viewStore.send(.tapped) + } label: { + HStack { + VStack { + if viewStore.isEmpty { + Image(systemName: "questionmark") + .frame(maxWidth: .infinity) + } else { + if let username = viewStore.username { + Text(username) + } + if let email = viewStore.email { + Text(email) + } + if let phone = viewStore.phone { + Text(phone) + } + } + } + Spacer() + Image(systemName: "chevron.forward") + } + } + } + .task { viewStore.send(.start) } + } + } +} + +#if DEBUG +public struct UserSearchResultView_Previews: PreviewProvider { + public static var previews: some View { + UserSearchResultView(store: Store( + initialState: UserSearchResultState( + id: "contact-id".data(using: .utf8)!, + xxContact: .unimplemented("contact-data".data(using: .utf8)!) + ), + reducer: .empty, + environment: () + )) + } +} +#endif diff --git a/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchView.swift b/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchView.swift index 266938a29b096d05f695c2f39f55f8c0fb993442..f0416b3a53542ef4195a66ca077617327d62737c 100644 --- a/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchView.swift +++ b/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchView.swift @@ -1,4 +1,6 @@ import ComposableArchitecture +import ComposablePresentation +import ContactFeature import SwiftUI import XXMessengerClient @@ -15,14 +17,12 @@ public struct UserSearchView: View { var query: MessengerSearchUsers.Query var isSearching: Bool var failure: String? - var results: IdentifiedArrayOf<UserSearchState.Result> init(state: UserSearchState) { focusedField = state.focusedField query = state.query isSearching = state.isSearching failure = state.failure - results = state.results } } @@ -87,27 +87,25 @@ public struct UserSearchView: View { } } - ForEach(viewStore.results) { result in - Section { - if let username = result.username { - Text(username) - } - if let email = result.email { - Text(email) - } - if let phone = result.phone { - Text(phone) - } - if result.username == nil, result.email == nil, result.phone == nil { - Image(systemName: "questionmark") - .frame(maxWidth: .infinity) - } - } - } + ForEachStore( + store.scope( + state: \.results, + action: UserSearchAction.result(id:action:) + ), + content: UserSearchResultView.init(store:) + ) } .onChange(of: viewStore.focusedField) { focusedField = $0 } .onChange(of: focusedField) { viewStore.send(.set(\.$focusedField, $0)) } .navigationTitle("User Search") + .background(NavigationLinkWithStore( + store.scope( + state: \.contact, + action: UserSearchAction.contact + ), + onDeactivate: { viewStore.send(.didDismissContact) }, + destination: ContactView.init(store:) + )) } } } diff --git a/Examples/xx-messenger/Tests/ContactFeatureTests/ContactFeatureTests.swift b/Examples/xx-messenger/Tests/ContactFeatureTests/ContactFeatureTests.swift new file mode 100644 index 0000000000000000000000000000000000000000..244cc90093c2267fd124a268982c5e65e5c57380 --- /dev/null +++ b/Examples/xx-messenger/Tests/ContactFeatureTests/ContactFeatureTests.swift @@ -0,0 +1,166 @@ +import Combine +import ComposableArchitecture +import CustomDump +import SendRequestFeature +import XCTest +import XXClient +import XXModels +@testable import ContactFeature + +final class ContactFeatureTests: XCTestCase { + func testStart() { + let store = TestStore( + initialState: ContactState( + id: "contact-id".data(using: .utf8)! + ), + reducer: contactReducer, + environment: .unimplemented + ) + + var dbDidFetchContacts: [XXModels.Contact.Query] = [] + let dbContactsPublisher = PassthroughSubject<[XXModels.Contact], Error>() + + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.db.run = { + var db: Database = .failing + db.fetchContactsPublisher.run = { query in + dbDidFetchContacts.append(query) + return dbContactsPublisher.eraseToAnyPublisher() + } + return db + } + + store.send(.start) + + XCTAssertNoDifference(dbDidFetchContacts, [ + .init(id: ["contact-id".data(using: .utf8)!]) + ]) + + let dbContact = XXModels.Contact(id: "contact-id".data(using: .utf8)!) + dbContactsPublisher.send([dbContact]) + + store.receive(.dbContactFetched(dbContact)) { + $0.dbContact = dbContact + } + + dbContactsPublisher.send(completion: .finished) + } + + func testImportFacts() { + let dbContact: XXModels.Contact = .init( + id: "contact-id".data(using: .utf8)! + ) + + var xxContact: XXClient.Contact = .unimplemented("contact-data".data(using: .utf8)!) + xxContact.getFactsFromContact.run = { _ in + [ + Fact(fact: "contact-username", type: 0), + Fact(fact: "contact-email", type: 1), + Fact(fact: "contact-phone", type: 2), + ] + } + + let store = TestStore( + initialState: ContactState( + id: "contact-id".data(using: .utf8)!, + dbContact: dbContact, + xxContact: xxContact + ), + reducer: contactReducer, + environment: .unimplemented + ) + + var dbDidSaveContact: [XXModels.Contact] = [] + + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.db.run = { + var db: Database = .failing + db.saveContact.run = { contact in + dbDidSaveContact.append(contact) + return contact + } + return db + } + + store.send(.importFactsTapped) + + var expectedSavedContact = dbContact + expectedSavedContact.marshaled = xxContact.data + expectedSavedContact.username = "contact-username" + expectedSavedContact.email = "contact-email" + expectedSavedContact.phone = "contact-phone" + + XCTAssertNoDifference(dbDidSaveContact, [expectedSavedContact]) + } + + func testSendRequestWithDBContact() { + var dbContact = XXModels.Contact(id: "contact-id".data(using: .utf8)!) + dbContact.marshaled = "contact-data".data(using: .utf8)! + + let store = TestStore( + initialState: ContactState( + id: dbContact.id, + dbContact: dbContact + ), + reducer: contactReducer, + environment: .unimplemented + ) + + store.send(.sendRequestTapped) { + $0.sendRequest = SendRequestState(contact: .live(dbContact.marshaled!)) + } + } + + func testSendRequestWithXXContact() { + let xxContact = XXClient.Contact.unimplemented("contact-id".data(using: .utf8)!) + + let store = TestStore( + initialState: ContactState( + id: "contact-id".data(using: .utf8)!, + xxContact: xxContact + ), + reducer: contactReducer, + environment: .unimplemented + ) + + store.send(.sendRequestTapped) { + $0.sendRequest = SendRequestState(contact: xxContact) + } + } + + func testSendRequestDismissed() { + let store = TestStore( + initialState: ContactState( + id: "contact-id".data(using: .utf8)!, + sendRequest: SendRequestState( + contact: .unimplemented("contact-id".data(using: .utf8)!) + ) + ), + reducer: contactReducer, + environment: .unimplemented + ) + + store.send(.sendRequestDismissed) { + $0.sendRequest = nil + } + } + + func testSendRequestSucceeded() { + let store = TestStore( + initialState: ContactState( + id: "contact-id".data(using: .utf8)!, + sendRequest: SendRequestState( + contact: .unimplemented("contact-id".data(using: .utf8)!) + ) + ), + reducer: contactReducer, + environment: .unimplemented + ) + + store.send(.sendRequest(.sendSucceeded)) { + $0.sendRequest = nil + } + } +} diff --git a/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift b/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift index aa309016c08c4a75e62c6ecd5cf47e32191832e1..cbc261cefd8f85153c3316d08bada0337cceea35 100644 --- a/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift +++ b/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift @@ -316,7 +316,7 @@ final class HomeFeatureTests: XCTestCase { var dbDidFetchContacts: [XXModels.Contact.Query] = [] var udDidPermanentDeleteAccount: [Fact] = [] var messengerDidDestroy = 0 - var dbDidDrop = 0 + var didRemoveDB = 0 store.environment.bgQueue = .immediate store.environment.mainQueue = .immediate @@ -329,7 +329,7 @@ final class HomeFeatureTests: XCTestCase { } return e2e } - store.environment.db.run = { + store.environment.dbManager.getDB.run = { var db: Database = .failing db.fetchContacts.run = { query in dbDidFetchContacts.append(query) @@ -341,11 +341,11 @@ final class HomeFeatureTests: XCTestCase { ) ] } - db.drop.run = { - dbDidDrop += 1 - } return db } + store.environment.dbManager.removeDB.run = { + didRemoveDB += 1 + } store.environment.messenger.ud.get = { var ud: UserDiscovery = .unimplemented ud.permanentDeleteAccount.run = { usernameFact in @@ -372,7 +372,7 @@ final class HomeFeatureTests: XCTestCase { XCTAssertNoDifference(dbDidFetchContacts, [.init(id: ["contact-id".data(using: .utf8)!])]) XCTAssertNoDifference(udDidPermanentDeleteAccount, [Fact(fact: "MyUsername", type: 0)]) XCTAssertNoDifference(messengerDidDestroy, 1) - XCTAssertNoDifference(dbDidDrop, 1) + XCTAssertNoDifference(didRemoveDB, 1) store.receive(.deleteAccount(.success)) { $0.isDeletingAccount = false diff --git a/Examples/xx-messenger/Tests/RegisterFeatureTests/RegisterFeatureTests.swift b/Examples/xx-messenger/Tests/RegisterFeatureTests/RegisterFeatureTests.swift index a2aad5bd7977dbd210a163d8cdac9001de239723..12addba7f1a5901fcf2d9424d44e791db8ab4904 100644 --- a/Examples/xx-messenger/Tests/RegisterFeatureTests/RegisterFeatureTests.swift +++ b/Examples/xx-messenger/Tests/RegisterFeatureTests/RegisterFeatureTests.swift @@ -16,6 +16,7 @@ final class RegisterFeatureTests: XCTestCase { let now = Date() let mainQueue = DispatchQueue.test let bgQueue = DispatchQueue.test + var didSetFactsOnContact: [[XXClient.Fact]] = [] var dbDidSaveContact: [XXModels.Contact] = [] var messengerDidRegisterUsername: [String] = [] @@ -30,6 +31,11 @@ final class RegisterFeatureTests: XCTestCase { e2e.getContact.run = { var contact = XXClient.Contact.unimplemented("contact-data".data(using: .utf8)!) contact.getIdFromContact.run = { _ in "contact-id".data(using: .utf8)! } + contact.getFactsFromContact.run = { _ in [] } + contact.setFactsOnContact.run = { data, facts in + didSetFactsOnContact.append(facts) + return data + } return contact } return e2e @@ -57,6 +63,7 @@ final class RegisterFeatureTests: XCTestCase { bgQueue.advance() XCTAssertNoDifference(messengerDidRegisterUsername, ["NewUser"]) + XCTAssertNoDifference(didSetFactsOnContact, [[Fact(fact: "NewUser", type: 0)]]) XCTAssertNoDifference(dbDidSaveContact, [ XXModels.Contact( id: "contact-id".data(using: .utf8)!, diff --git a/Examples/xx-messenger/Tests/SendRequestFeatureTests/SendRequestFeatureTests.swift b/Examples/xx-messenger/Tests/SendRequestFeatureTests/SendRequestFeatureTests.swift new file mode 100644 index 0000000000000000000000000000000000000000..1dbe26be1d99196abe4217b23f5daeba46fe15c7 --- /dev/null +++ b/Examples/xx-messenger/Tests/SendRequestFeatureTests/SendRequestFeatureTests.swift @@ -0,0 +1,185 @@ +import Combine +import ComposableArchitecture +import XCTest +import XXClient +import XXModels +@testable import SendRequestFeature + +final class SendRequestFeatureTests: XCTestCase { + func testStart() { + let store = TestStore( + initialState: SendRequestState( + contact: .unimplemented("contact-data".data(using: .utf8)!) + ), + reducer: sendRequestReducer, + environment: .unimplemented + ) + + var dbDidFetchContacts: [XXModels.Contact.Query] = [] + let dbContactsPublisher = PassthroughSubject<[XXModels.Contact], Error>() + + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.messenger.e2e.get = { + var e2e: E2E = .unimplemented + e2e.getContact.run = { + var contact: XXClient.Contact = .unimplemented("my-contact-data".data(using: .utf8)!) + contact.getIdFromContact.run = { _ in "my-contact-id".data(using: .utf8)! } + return contact + } + return e2e + } + store.environment.db.run = { + var db: Database = .failing + db.fetchContactsPublisher.run = { query in + dbDidFetchContacts.append(query) + return dbContactsPublisher.eraseToAnyPublisher() + } + return db + } + + store.send(.start) + + XCTAssertNoDifference(dbDidFetchContacts, [.init(id: ["my-contact-id".data(using: .utf8)!])]) + + dbContactsPublisher.send([]) + + store.receive(.myContactFetched(nil)) + + var myDbContact = XXModels.Contact(id: "my-contact-id".data(using: .utf8)!) + myDbContact.marshaled = "my-contact-data".data(using: .utf8)! + dbContactsPublisher.send([myDbContact]) + + store.receive(.myContactFetched(.live("my-contact-data".data(using: .utf8)!))) { + $0.myContact = .live("my-contact-data".data(using: .utf8)!) + } + + dbContactsPublisher.send(completion: .finished) + } + + func testSendRequest() { + var contact: XXClient.Contact = .unimplemented("contact-data".data(using: .utf8)!) + contact.getIdFromContact.run = { _ in "contact-id".data(using: .utf8)! } + + var myContact: XXClient.Contact = .unimplemented("my-contact-data".data(using: .utf8)!) + let myFacts = [ + Fact(fact: "my-username", type: 0), + Fact(fact: "my-email", type: 1), + Fact(fact: "my-phone", type: 2), + ] + myContact.getFactsFromContact.run = { _ in myFacts } + + let store = TestStore( + initialState: SendRequestState( + contact: contact, + myContact: myContact + ), + reducer: sendRequestReducer, + environment: .unimplemented + ) + + struct DidBulkUpdateContacts: Equatable { + var query: XXModels.Contact.Query + var assignments: XXModels.Contact.Assignments + } + struct DidRequestAuthChannel: Equatable { + var partner: XXClient.Contact + var myFacts: [XXClient.Fact] + } + + var didBulkUpdateContacts: [DidBulkUpdateContacts] = [] + var didRequestAuthChannel: [DidRequestAuthChannel] = [] + + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.db.run = { + var db: Database = .failing + db.bulkUpdateContacts.run = { query, assignments in + didBulkUpdateContacts.append(.init(query: query, assignments: assignments)) + return 0 + } + return db + } + store.environment.messenger.e2e.get = { + var e2e: E2E = .unimplemented + e2e.requestAuthenticatedChannel.run = { partner, myFacts in + didRequestAuthChannel.append(.init(partner: partner, myFacts: myFacts)) + return 0 + } + return e2e + } + + store.send(.sendTapped) { + $0.isSending = true + } + + XCTAssertNoDifference(didBulkUpdateContacts, [ + .init( + query: .init(id: ["contact-id".data(using: .utf8)!]), + assignments: .init(authStatus: .requesting) + ), + .init( + query: .init(id: ["contact-id".data(using: .utf8)!]), + assignments: .init(authStatus: .requested) + ) + ]) + + XCTAssertNoDifference(didRequestAuthChannel, [ + .init( + partner: contact, + myFacts: myFacts + ) + ]) + + store.receive(.sendSucceeded) { + $0.isSending = false + } + } + + func testSendRequestFailure() { + var contact: XXClient.Contact = .unimplemented("contact-data".data(using: .utf8)!) + contact.getIdFromContact.run = { _ in "contact-id".data(using: .utf8)! } + + var myContact: XXClient.Contact = .unimplemented("my-contact-data".data(using: .utf8)!) + let myFacts = [ + Fact(fact: "my-username", type: 0), + Fact(fact: "my-email", type: 1), + Fact(fact: "my-phone", type: 2), + ] + myContact.getFactsFromContact.run = { _ in myFacts } + + let store = TestStore( + initialState: SendRequestState( + contact: contact, + myContact: myContact + ), + reducer: sendRequestReducer, + environment: .unimplemented + ) + + struct Failure: Error {} + let failure = Failure() + + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.db.run = { + var db: Database = .failing + db.bulkUpdateContacts.run = { _, _ in return 0 } + return db + } + store.environment.messenger.e2e.get = { + var e2e: E2E = .unimplemented + e2e.requestAuthenticatedChannel.run = { _, _ in throw failure } + return e2e + } + + store.send(.sendTapped) { + $0.isSending = true + } + + store.receive(.sendFailed(failure.localizedDescription)) { + $0.isSending = false + $0.failure = failure.localizedDescription + } + } +} diff --git a/Examples/xx-messenger/Tests/UserSearchFeatureTests/UserSearchFeatureTests.swift b/Examples/xx-messenger/Tests/UserSearchFeatureTests/UserSearchFeatureTests.swift index 8d2f414a0a6c4fa130ea285863ae92ef80f70c21..c457327c86722f020ffe2ffc55c251c3261fcc8a 100644 --- a/Examples/xx-messenger/Tests/UserSearchFeatureTests/UserSearchFeatureTests.swift +++ b/Examples/xx-messenger/Tests/UserSearchFeatureTests/UserSearchFeatureTests.swift @@ -1,4 +1,5 @@ import ComposableArchitecture +import ContactFeature import XCTest import XXClient import XXMessengerClient @@ -19,23 +20,12 @@ final class UserSearchFeatureTests: XCTestCase { var contact1 = Contact.unimplemented("contact-1".data(using: .utf8)!) contact1.getIdFromContact.run = { _ in "contact-1-id".data(using: .utf8)! } - contact1.getFactsFromContact.run = { _ in - [Fact(fact: "contact-1-username", type: 0), - Fact(fact: "contact-1-email", type: 1), - Fact(fact: "contact-1-phone", type: 2)] - } var contact2 = Contact.unimplemented("contact-1".data(using: .utf8)!) contact2.getIdFromContact.run = { _ in "contact-2-id".data(using: .utf8)! } - contact2.getFactsFromContact.run = { _ in - [Fact(fact: "contact-2-username", type: 0), - Fact(fact: "contact-2-email", type: 1), - Fact(fact: "contact-2-phone", type: 2)] - } var contact3 = Contact.unimplemented("contact-3".data(using: .utf8)!) contact3.getIdFromContact.run = { _ in throw GetIdFromContactError() } var contact4 = Contact.unimplemented("contact-4".data(using: .utf8)!) contact4.getIdFromContact.run = { _ in "contact-4-id".data(using: .utf8)! } - contact4.getFactsFromContact.run = { _ in throw GetFactsFromContactError() } let contacts = [contact1, contact2, contact3, contact4] store.environment.bgQueue = .immediate @@ -64,27 +54,9 @@ final class UserSearchFeatureTests: XCTestCase { $0.isSearching = false $0.failure = nil $0.results = [ - .init( - id: "contact-1-id".data(using: .utf8)!, - contact: contact1, - username: "contact-1-username", - email: "contact-1-email", - phone: "contact-1-phone" - ), - .init( - id: "contact-2-id".data(using: .utf8)!, - contact: contact2, - username: "contact-2-username", - email: "contact-2-email", - phone: "contact-2-phone" - ), - .init( - id: "contact-4-id".data(using: .utf8)!, - contact: contact4, - username: nil, - email: nil, - phone: nil - ) + .init(id: "contact-1-id".data(using: .utf8)!, xxContact: contact1), + .init(id: "contact-2-id".data(using: .utf8)!, xxContact: contact2), + .init(id: "contact-4-id".data(using: .utf8)!, xxContact: contact4) ] } } @@ -116,4 +88,42 @@ final class UserSearchFeatureTests: XCTestCase { $0.results = [] } } + + func testResultTapped() { + let store = TestStore( + initialState: UserSearchState( + results: [ + .init( + id: "contact-id".data(using: .utf8)!, + xxContact: .unimplemented("contact-data".data(using: .utf8)!) + ) + ] + ), + reducer: userSearchReducer, + environment: .unimplemented + ) + + store.send(.result(id: "contact-id".data(using: .utf8)!, action: .tapped)) { + $0.contact = ContactState( + id: "contact-id".data(using: .utf8)!, + xxContact: .unimplemented("contact-data".data(using: .utf8)!) + ) + } + } + + func testDismissingContact() { + let store = TestStore( + initialState: UserSearchState( + contact: ContactState( + id: "contact-id".data(using: .utf8)! + ) + ), + reducer: userSearchReducer, + environment: .unimplemented + ) + + store.send(.didDismissContact) { + $0.contact = nil + } + } } diff --git a/Examples/xx-messenger/Tests/UserSearchFeatureTests/UserSearchResultFeatureTests.swift b/Examples/xx-messenger/Tests/UserSearchFeatureTests/UserSearchResultFeatureTests.swift new file mode 100644 index 0000000000000000000000000000000000000000..c8f2a99b92b35b645c1d3e94462e6353ec4d33fc --- /dev/null +++ b/Examples/xx-messenger/Tests/UserSearchFeatureTests/UserSearchResultFeatureTests.swift @@ -0,0 +1,46 @@ +import ComposableArchitecture +import XCTest +import XCTestDynamicOverlay +import XXClient +@testable import UserSearchFeature + +final class UserSearchResultFeatureTests: XCTestCase { + func testStart() { + var contact = Contact.unimplemented("contact-data".data(using: .utf8)!) + contact.getFactsFromContact.run = { _ in + [ + Fact(fact: "contact-username", type: 0), + Fact(fact: "contact-email", type: 1), + Fact(fact: "contact-phone", type: 2), + ] + } + + let store = TestStore( + initialState: UserSearchResultState( + id: "contact-id".data(using: .utf8)!, + xxContact: contact + ), + reducer: userSearchResultReducer, + environment: .unimplemented + ) + + store.send(.start) { + $0.username = "contact-username" + $0.email = "contact-email" + $0.phone = "contact-phone" + } + } + + func testTapped() { + let store = TestStore( + initialState: UserSearchResultState( + id: "contact-id".data(using: .utf8)!, + xxContact: .unimplemented("contact-data".data(using: .utf8)!) + ), + reducer: userSearchResultReducer, + environment: .unimplemented + ) + + store.send(.tapped) + } +}