diff --git a/Examples/xx-messenger/.swiftpm/xcode/xcshareddata/xcschemes/ContactsFeature.xcscheme b/Examples/xx-messenger/.swiftpm/xcode/xcshareddata/xcschemes/ContactsFeature.xcscheme new file mode 100644 index 0000000000000000000000000000000000000000..feb1a4909f8c4e947ab8005e055ad9a9d9e61884 --- /dev/null +++ b/Examples/xx-messenger/.swiftpm/xcode/xcshareddata/xcschemes/ContactsFeature.xcscheme @@ -0,0 +1,78 @@ +<?xml version="1.0" encoding="UTF-8"?> +<Scheme + LastUpgradeVersion = "1400" + version = "1.3"> + <BuildAction + parallelizeBuildables = "YES" + buildImplicitDependencies = "YES"> + <BuildActionEntries> + <BuildActionEntry + buildForTesting = "YES" + buildForRunning = "YES" + buildForProfiling = "YES" + buildForArchiving = "YES" + buildForAnalyzing = "YES"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "ContactsFeature" + BuildableName = "ContactsFeature" + BlueprintName = "ContactsFeature" + 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 = "ContactsFeatureTests" + BuildableName = "ContactsFeatureTests" + BlueprintName = "ContactsFeatureTests" + 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 = "ContactsFeature" + BuildableName = "ContactsFeature" + BlueprintName = "ContactsFeature" + 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 77b97a0f38f4516e0ae9b60feeb18364dbb02247..5df8eb39dd57b9f788e430521dd637d0e48e1b64 100644 --- a/Examples/xx-messenger/Package.swift +++ b/Examples/xx-messenger/Package.swift @@ -21,6 +21,7 @@ let package = Package( .library(name: "AppCore", targets: ["AppCore"]), .library(name: "AppFeature", targets: ["AppFeature"]), .library(name: "ContactFeature", targets: ["ContactFeature"]), + .library(name: "ContactsFeature", targets: ["ContactsFeature"]), .library(name: "HomeFeature", targets: ["HomeFeature"]), .library(name: "RegisterFeature", targets: ["RegisterFeature"]), .library(name: "RestoreFeature", targets: ["RestoreFeature"]), @@ -72,6 +73,7 @@ let package = Package( dependencies: [ .target(name: "AppCore"), .target(name: "ContactFeature"), + .target(name: "ContactsFeature"), .target(name: "HomeFeature"), .target(name: "RegisterFeature"), .target(name: "RestoreFeature"), @@ -111,10 +113,31 @@ let package = Package( ], swiftSettings: swiftSettings ), + .target( + name: "ContactsFeature", + dependencies: [ + .target(name: "AppCore"), + .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 + ), + .testTarget( + name: "ContactsFeatureTests", + dependencies: [ + .target(name: "ContactsFeature"), + ], + swiftSettings: swiftSettings + ), .target( name: "HomeFeature", dependencies: [ .target(name: "AppCore"), + .target(name: "ContactsFeature"), .target(name: "RegisterFeature"), .target(name: "UserSearchFeature"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), 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 0d7cb106a1c962bb00f93bd1668ae0e80dbcc173..9d31f6f70a3f7dd3e87b943466febea60557f6af 100644 --- a/Examples/xx-messenger/Project/XXMessenger.xcodeproj/xcshareddata/xcschemes/XXMessenger.xcscheme +++ b/Examples/xx-messenger/Project/XXMessenger.xcodeproj/xcshareddata/xcschemes/XXMessenger.xcscheme @@ -59,6 +59,16 @@ ReferencedContainer = "container:.."> </BuildableReference> </TestableReference> + <TestableReference + skipped = "NO"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "ContactsFeatureTests" + BuildableName = "ContactsFeatureTests" + BlueprintName = "ContactsFeatureTests" + ReferencedContainer = "container:.."> + </BuildableReference> + </TestableReference> <TestableReference skipped = "NO"> <BuildableReference diff --git a/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift b/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift index 01e315198e89816e8a7f8054bf0c791d707b9d30..61bfba1f3163cf119e610606f9d89ea67a122171 100644 --- a/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift +++ b/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift @@ -1,5 +1,6 @@ import AppCore import ContactFeature +import ContactsFeature import Foundation import HomeFeature import RegisterFeature @@ -18,6 +19,21 @@ extension AppEnvironment { let mainQueue = DispatchQueue.main.eraseToAnyScheduler() let bgQueue = DispatchQueue.global(qos: .background).eraseToAnyScheduler() + let contactEnvironment = ContactEnvironment( + messenger: messenger, + db: dbManager.getDB, + mainQueue: mainQueue, + bgQueue: bgQueue, + sendRequest: { + SendRequestEnvironment( + messenger: messenger, + db: dbManager.getDB, + mainQueue: mainQueue, + bgQueue: bgQueue + ) + } + ) + return AppEnvironment( dbManager: dbManager, messenger: messenger, @@ -48,27 +64,21 @@ extension AppEnvironment { bgQueue: bgQueue ) }, + contacts: { + ContactsEnvironment( + messenger: messenger, + db: dbManager.getDB, + mainQueue: mainQueue, + bgQueue: bgQueue, + contact: { contactEnvironment } + ) + }, userSearch: { UserSearchEnvironment( messenger: messenger, mainQueue: mainQueue, bgQueue: bgQueue, - contact: { - ContactEnvironment( - messenger: messenger, - db: dbManager.getDB, - mainQueue: mainQueue, - bgQueue: bgQueue, - sendRequest: { - SendRequestEnvironment( - messenger: messenger, - db: dbManager.getDB, - mainQueue: mainQueue, - bgQueue: bgQueue - ) - } - ) - } + contact: { contactEnvironment } ) } ) diff --git a/Examples/xx-messenger/Sources/ContactsFeature/ContactsFeature.swift b/Examples/xx-messenger/Sources/ContactsFeature/ContactsFeature.swift new file mode 100644 index 0000000000000000000000000000000000000000..1ded89de4cbf9dd44014395e3e79bf6ff2fdcc20 --- /dev/null +++ b/Examples/xx-messenger/Sources/ContactsFeature/ContactsFeature.swift @@ -0,0 +1,109 @@ +import AppCore +import ComposableArchitecture +import ComposablePresentation +import ContactFeature +import Foundation +import XCTestDynamicOverlay +import XXClient +import XXMessengerClient +import XXModels + +public struct ContactsState: Equatable { + public init( + myId: Data? = nil, + contacts: IdentifiedArrayOf<XXModels.Contact> = [], + contact: ContactState? = nil + ) { + self.myId = myId + self.contacts = contacts + self.contact = contact + } + + public var myId: Data? + public var contacts: IdentifiedArrayOf<XXModels.Contact> + public var contact: ContactState? +} + +public enum ContactsAction: Equatable { + case start + case didFetchContacts([XXModels.Contact]) + case contactSelected(XXModels.Contact) + case contactDismissed + case contact(ContactAction) +} + +public struct ContactsEnvironment { + public init( + messenger: Messenger, + db: DBManagerGetDB, + mainQueue: AnySchedulerOf<DispatchQueue>, + bgQueue: AnySchedulerOf<DispatchQueue>, + contact: @escaping () -> ContactEnvironment + ) { + self.messenger = messenger + self.db = db + self.mainQueue = mainQueue + self.bgQueue = bgQueue + self.contact = contact + } + + public var messenger: Messenger + public var db: DBManagerGetDB + public var mainQueue: AnySchedulerOf<DispatchQueue> + public var bgQueue: AnySchedulerOf<DispatchQueue> + public var contact: () -> ContactEnvironment +} + +#if DEBUG +extension ContactsEnvironment { + public static let unimplemented = ContactsEnvironment( + messenger: .unimplemented, + db: .unimplemented, + mainQueue: .unimplemented, + bgQueue: .unimplemented, + contact: { .unimplemented } + ) +} +#endif + +public let contactsReducer = Reducer<ContactsState, ContactsAction, ContactsEnvironment> +{ state, action, env in + switch action { + case .start: + state.myId = try? env.messenger.e2e.tryGet().getContact().getId() + return Effect + .catching { try env.db() } + .flatMap { $0.fetchContactsPublisher(.init()) } + .assertNoFailure() + .map(ContactsAction.didFetchContacts) + .subscribe(on: env.bgQueue) + .receive(on: env.mainQueue) + .eraseToEffect() + + case .didFetchContacts(var contacts): + if let myId = state.myId, + let myIndex = contacts.firstIndex(where: { $0.id == myId }) { + contacts.move(fromOffsets: [myIndex], toOffset: contacts.startIndex) + } + state.contacts = IdentifiedArray(uniqueElements: contacts) + return .none + + case .contactSelected(let contact): + state.contact = ContactState(id: contact.id, dbContact: contact) + return .none + + case .contactDismissed: + state.contact = nil + return .none + + case .contact(_): + return .none + } +} +.presenting( + contactReducer, + state: .keyPath(\.contact), + id: .keyPath(\.?.id), + action: /ContactsAction.contact, + environment: { $0.contact() } +) diff --git a/Examples/xx-messenger/Sources/ContactsFeature/ContactsView.swift b/Examples/xx-messenger/Sources/ContactsFeature/ContactsView.swift new file mode 100644 index 0000000000000000000000000000000000000000..b7798685cf75d851adba765f751c6fb5a47ceea0 --- /dev/null +++ b/Examples/xx-messenger/Sources/ContactsFeature/ContactsView.swift @@ -0,0 +1,107 @@ +import AppCore +import ComposableArchitecture +import ComposablePresentation +import ContactFeature +import SwiftUI +import XXModels + +public struct ContactsView: View { + public init(store: Store<ContactsState, ContactsAction>) { + self.store = store + } + + let store: Store<ContactsState, ContactsAction> + + struct ViewState: Equatable { + var myId: Data? + var contacts: IdentifiedArrayOf<XXModels.Contact> + + init(state: ContactsState) { + myId = state.myId + contacts = state.contacts + } + } + + public var body: some View { + WithViewStore(store.scope(state: ViewState.init)) { viewStore in + Form { + ForEach(viewStore.contacts) { contact in + if contact.id == viewStore.myId { + Section { + VStack(alignment: .leading, spacing: 8) { + Label(contact.username ?? "", systemImage: "person") + Label(contact.email ?? "", systemImage: "envelope") + Label(contact.phone ?? "", systemImage: "phone") + } + .font(.callout) + .tint(Color.primary) + } header: { + Text("My contact") + } + } else { + Section { + Button { + viewStore.send(.contactSelected(contact)) + } label: { + HStack { + VStack(alignment: .leading, spacing: 8) { + Label(contact.username ?? "", systemImage: "person") + Label(contact.email ?? "", systemImage: "envelope") + Label(contact.phone ?? "", systemImage: "phone") + } + .font(.callout) + .tint(Color.primary) + Spacer() + Image(systemName: "chevron.forward") + } + } + ContactAuthStatusView(contact.authStatus) + } + } + } + } + .navigationTitle("Contacts") + .task { viewStore.send(.start) } + .background(NavigationLinkWithStore( + store.scope( + state: \.contact, + action: ContactsAction.contact + ), + onDeactivate: { viewStore.send(.contactDismissed) }, + destination: ContactView.init(store:) + )) + } + } +} + +#if DEBUG +public struct ContactsView_Previews: PreviewProvider { + public static var previews: some View { + NavigationView { + ContactsView(store: Store( + initialState: ContactsState( + contacts: [ + .init( + id: "1".data(using: .utf8)!, + username: "John Doe", + email: "john@doe.com", + phone: "+1234567890", + authStatus: .friend + ), + .init( + id: "2".data(using: .utf8)!, + username: "Alice Unknown", + authStatus: .requested + ), + .init( + id: "3".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 9df6d745b07e282095cac03a0d978646795af301..faa5757c43a4e22a9c8c3b7dd7a99ee83e059a84 100644 --- a/Examples/xx-messenger/Sources/HomeFeature/HomeFeature.swift +++ b/Examples/xx-messenger/Sources/HomeFeature/HomeFeature.swift @@ -2,6 +2,7 @@ import AppCore import Combine import ComposableArchitecture import ComposablePresentation +import ContactsFeature import Foundation import RegisterFeature import UserSearchFeature @@ -16,6 +17,7 @@ public struct HomeState: Equatable { isDeletingAccount: Bool = false, alert: AlertState<HomeAction>? = nil, register: RegisterState? = nil, + contacts: ContactsState? = nil, userSearch: UserSearchState? = nil ) { self.failure = failure @@ -23,6 +25,7 @@ public struct HomeState: Equatable { self.isDeletingAccount = isDeletingAccount self.alert = alert self.register = register + self.contacts = contacts self.userSearch = userSearch } @@ -32,6 +35,7 @@ public struct HomeState: Equatable { public var isDeletingAccount: Bool public var alert: AlertState<HomeAction>? public var register: RegisterState? + public var contacts: ContactsState? public var userSearch: UserSearchState? } @@ -64,7 +68,10 @@ public enum HomeAction: Equatable { case didDismissRegister case userSearchButtonTapped case didDismissUserSearch + case contactsButtonTapped + case didDismissContacts case register(RegisterAction) + case contacts(ContactsAction) case userSearch(UserSearchAction) } @@ -75,6 +82,7 @@ public struct HomeEnvironment { mainQueue: AnySchedulerOf<DispatchQueue>, bgQueue: AnySchedulerOf<DispatchQueue>, register: @escaping () -> RegisterEnvironment, + contacts: @escaping () -> ContactsEnvironment, userSearch: @escaping () -> UserSearchEnvironment ) { self.messenger = messenger @@ -82,6 +90,7 @@ public struct HomeEnvironment { self.mainQueue = mainQueue self.bgQueue = bgQueue self.register = register + self.contacts = contacts self.userSearch = userSearch } @@ -90,6 +99,7 @@ public struct HomeEnvironment { public var mainQueue: AnySchedulerOf<DispatchQueue> public var bgQueue: AnySchedulerOf<DispatchQueue> public var register: () -> RegisterEnvironment + public var contacts: () -> ContactsEnvironment public var userSearch: () -> UserSearchEnvironment } @@ -100,6 +110,7 @@ extension HomeEnvironment { mainQueue: .unimplemented, bgQueue: .unimplemented, register: { .unimplemented }, + contacts: { .unimplemented }, userSearch: { .unimplemented } ) } @@ -238,11 +249,19 @@ public let homeReducer = Reducer<HomeState, HomeAction, HomeEnvironment> state.userSearch = nil return .none + case .contactsButtonTapped: + state.contacts = ContactsState() + return .none + + case .didDismissContacts: + state.contacts = nil + return .none + case .register(.finished): state.register = nil return Effect(value: .messenger(.start)) - case .register(_), .userSearch(_): + case .register(_), .contacts(_), .userSearch(_): return .none } } @@ -253,6 +272,13 @@ public let homeReducer = Reducer<HomeState, HomeAction, HomeEnvironment> action: /HomeAction.register, environment: { $0.register() } ) +.presenting( + contactsReducer, + state: .keyPath(\.contacts), + id: .notNil(), + action: /HomeAction.contacts, + environment: { $0.contacts() } +) .presenting( userSearchReducer, state: .keyPath(\.userSearch), diff --git a/Examples/xx-messenger/Sources/HomeFeature/HomeView.swift b/Examples/xx-messenger/Sources/HomeFeature/HomeView.swift index e52e8d1b7fb94004fc29b4cb82041400467dbe8c..414bf928d0f2eb0e17b692cacd7cd2c8030b789a 100644 --- a/Examples/xx-messenger/Sources/HomeFeature/HomeView.swift +++ b/Examples/xx-messenger/Sources/HomeFeature/HomeView.swift @@ -1,5 +1,6 @@ import ComposableArchitecture import ComposablePresentation +import ContactsFeature import RegisterFeature import SwiftUI import UserSearchFeature @@ -88,6 +89,16 @@ public struct HomeView: View { } Section { + Button { + viewStore.send(.contactsButtonTapped) + } label: { + HStack { + Text("Contacts") + Spacer() + Image(systemName: "chevron.forward") + } + } + Button { viewStore.send(.userSearchButtonTapped) } label: { @@ -123,6 +134,16 @@ public struct HomeView: View { store.scope(state: \.alert), dismiss: HomeAction.didDismissAlert ) + .background(NavigationLinkWithStore( + store.scope( + state: \.contacts, + action: HomeAction.contacts + ), + onDeactivate: { + viewStore.send(.didDismissContacts) + }, + destination: ContactsView.init(store:) + )) .background(NavigationLinkWithStore( store.scope( state: \.userSearch, diff --git a/Examples/xx-messenger/Tests/ContactsFeatureTests/ContactsFeatureTests.swift b/Examples/xx-messenger/Tests/ContactsFeatureTests/ContactsFeatureTests.swift new file mode 100644 index 0000000000000000000000000000000000000000..a0c0291ef79865fa6c1f3d1ffeb25235bd520acf --- /dev/null +++ b/Examples/xx-messenger/Tests/ContactsFeatureTests/ContactsFeatureTests.swift @@ -0,0 +1,97 @@ +import Combine +import ComposableArchitecture +import ContactFeature +import CustomDump +import XCTest +import XXClient +import XXMessengerClient +import XXModels +@testable import ContactsFeature + +final class ContactsFeatureTests: XCTestCase { + func testStart() { + let store = TestStore( + initialState: ContactsState(), + reducer: contactsReducer, + environment: .unimplemented + ) + + let myId = "2".data(using: .utf8)! + var didFetchContacts: [XXModels.Contact.Query] = [] + let contactsPublisher = 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(Data()) + contact.getIdFromContact.run = { _ in myId } + return contact + } + return e2e + } + store.environment.db.run = { + var db: Database = .failing + db.fetchContactsPublisher.run = { query in + didFetchContacts.append(query) + return contactsPublisher.eraseToAnyPublisher() + } + return db + } + + store.send(.start) { + $0.myId = myId + } + + XCTAssertNoDifference(didFetchContacts, [XXModels.Contact.Query()]) + + let contacts: [XXModels.Contact] = [ + .init(id: "1".data(using: .utf8)!), + .init(id: "2".data(using: .utf8)!), + .init(id: "3".data(using: .utf8)!), + ] + contactsPublisher.send(contacts) + + store.receive(.didFetchContacts(contacts)) { + $0.contacts = IdentifiedArray(uniqueElements: [ + contacts[1], + contacts[0], + contacts[2], + ]) + } + + contactsPublisher.send(completion: .finished) + } + + func testSelectContact() { + let store = TestStore( + initialState: ContactsState(), + reducer: contactsReducer, + environment: .unimplemented + ) + + let contact = XXModels.Contact(id: "id".data(using: .utf8)!) + + store.send(.contactSelected(contact)) { + $0.contact = ContactState(id: contact.id, dbContact: contact) + } + } + + func testDismissContact() { + let store = TestStore( + initialState: ContactsState( + contact: ContactState( + id: "id".data(using: .utf8)!, + dbContact: Contact(id: "id".data(using: .utf8)!) + ) + ), + reducer: contactsReducer, + environment: .unimplemented + ) + + store.send(.contactDismissed) { + $0.contact = nil + } + } +} diff --git a/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift b/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift index 2d7f6dafd6f5833483112c9e7bea9912be90314e..89aef4c9f00a7d887f3b1f6bfead0063ade5f263 100644 --- a/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift +++ b/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift @@ -1,4 +1,5 @@ import ComposableArchitecture +import ContactsFeature import RegisterFeature import UserSearchFeature import XCTest @@ -464,4 +465,30 @@ final class HomeFeatureTests: XCTestCase { $0.userSearch = nil } } + + func testContactsButtonTapped() { + let store = TestStore( + initialState: HomeState(), + reducer: homeReducer, + environment: .unimplemented + ) + + store.send(.contactsButtonTapped) { + $0.contacts = ContactsState() + } + } + + func testDidDismissContacts() { + let store = TestStore( + initialState: HomeState( + contacts: ContactsState() + ), + reducer: homeReducer, + environment: .unimplemented + ) + + store.send(.didDismissContacts) { + $0.contacts = nil + } + } }