diff --git a/Examples/xx-messenger/Package.swift b/Examples/xx-messenger/Package.swift index 74bda195db87f33a04093b8c46e28554bf7b2b4d..f9ceb75fdf29afbda5362c20139003d77ebb131f 100644 --- a/Examples/xx-messenger/Package.swift +++ b/Examples/xx-messenger/Package.swift @@ -141,6 +141,8 @@ let package = Package( name: "UserSearchFeature", dependencies: [ .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + .product(name: "XXClient", package: "elixxir-dapps-sdk-swift"), + .product(name: "XXMessengerClient", package: "elixxir-dapps-sdk-swift"), ], swiftSettings: swiftSettings ), diff --git a/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift b/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift index 1fc08a8cd576f898f440d6347f2ab3aaf0472b33..5d7777802ed09dcfb2e36b14ba2a5b55f1fe906e 100644 --- a/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift +++ b/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift @@ -47,7 +47,11 @@ extension AppEnvironment { ) }, userSearch: { - UserSearchEnvironment() + UserSearchEnvironment( + messenger: messenger, + mainQueue: mainQueue, + bgQueue: bgQueue + ) } ) } diff --git a/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchFeature.swift b/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchFeature.swift index 43b82dfd4b83c426c15dec01d7ea560e5afc6e1c..86a13c0ec97966619bea881f5ac0b8c9de962fed 100644 --- a/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchFeature.swift +++ b/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchFeature.swift @@ -1,20 +1,135 @@ import ComposableArchitecture +import Foundation import XCTestDynamicOverlay +import XXClient +import XXMessengerClient public struct UserSearchState: Equatable { - public init() {} + public enum Field: String, Hashable { + case username + case email + 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> = [] + ) { + self.focusedField = focusedField + self.query = query + self.isSearching = isSearching + self.failure = failure + self.results = results + } + + @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 enum UserSearchAction: Equatable {} +public enum UserSearchAction: Equatable, BindableAction { + case searchTapped + case didFail(String) + case didSucceed([Contact]) + case binding(BindingAction<UserSearchState>) +} public struct UserSearchEnvironment { - public init() {} + public init( + messenger: Messenger, + mainQueue: AnySchedulerOf<DispatchQueue>, + bgQueue: AnySchedulerOf<DispatchQueue> + ) { + self.messenger = messenger + self.mainQueue = mainQueue + self.bgQueue = bgQueue + } + + public var messenger: Messenger + public var mainQueue: AnySchedulerOf<DispatchQueue> + public var bgQueue: AnySchedulerOf<DispatchQueue> } #if DEBUG extension UserSearchEnvironment { - public static let unimplemented = UserSearchEnvironment() + public static let unimplemented = UserSearchEnvironment( + messenger: .unimplemented, + mainQueue: .unimplemented, + bgQueue: .unimplemented + ) } #endif -public let userSearchReducer = Reducer<UserSearchState, UserSearchAction, UserSearchEnvironment>.empty +public let userSearchReducer = Reducer<UserSearchState, UserSearchAction, UserSearchEnvironment> +{ state, action, env in + switch action { + case .searchTapped: + state.focusedField = nil + state.isSearching = true + state.results = [] + state.failure = nil + return .result { [query = state.query] in + do { + return .success(.didSucceed(try env.messenger.searchUsers(query: query))) + } catch { + return .success(.didFail(error.localizedDescription)) + } + } + .subscribe(on: env.bgQueue) + .receive(on: env.mainQueue) + .eraseToEffect() + + case .didSucceed(let contacts): + state.isSearching = false + 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 .none + + case .didFail(let failure): + state.isSearching = false + state.failure = failure + state.results = [] + return .none + + case .binding(_): + return .none + } +} +.binding() diff --git a/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchView.swift b/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchView.swift index 27f94bfa58b0e84836178db110e15c1223dc8a00..266938a29b096d05f695c2f39f55f8c0fb993442 100644 --- a/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchView.swift +++ b/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchView.swift @@ -1,5 +1,6 @@ import ComposableArchitecture import SwiftUI +import XXMessengerClient public struct UserSearchView: View { public init(store: Store<UserSearchState, UserSearchAction>) { @@ -7,14 +8,106 @@ public struct UserSearchView: View { } let store: Store<UserSearchState, UserSearchAction> + @FocusState var focusedField: UserSearchState.Field? struct ViewState: Equatable { - init(state: UserSearchState) {} + var focusedField: UserSearchState.Field? + 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 + } } public var body: some View { WithViewStore(store.scope(state: ViewState.init)) { viewStore in - Text("UserSearchView") + Form { + Section { + TextField( + text: viewStore.binding( + get: { $0.query.username ?? "" }, + send: { UserSearchAction.set(\.$query.username, $0.isEmpty ? nil : $0) } + ), + prompt: Text("Enter username"), + label: { Text("Username") } + ) + .focused($focusedField, equals: .username) + + TextField( + text: viewStore.binding( + get: { $0.query.email ?? "" }, + send: { UserSearchAction.set(\.$query.email, $0.isEmpty ? nil : $0) } + ), + prompt: Text("Enter email"), + label: { Text("Email") } + ) + .focused($focusedField, equals: .email) + + TextField( + text: viewStore.binding( + get: { $0.query.phone ?? "" }, + send: { UserSearchAction.set(\.$query.phone, $0.isEmpty ? nil : $0) } + ), + prompt: Text("Enter phone"), + label: { Text("Phone") } + ) + .focused($focusedField, equals: .phone) + + Button { + viewStore.send(.searchTapped) + } label: { + HStack { + Text("Search") + Spacer() + if viewStore.isSearching { + ProgressView() + } else { + Image(systemName: "magnifyingglass") + } + } + } + .disabled(viewStore.query.isEmpty) + } + .disabled(viewStore.isSearching) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + + if let failure = viewStore.failure { + Section { + Text(failure) + } header: { + Text("Error") + } + } + + 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) + } + } + } + } + .onChange(of: viewStore.focusedField) { focusedField = $0 } + .onChange(of: focusedField) { viewStore.send(.set(\.$focusedField, $0)) } + .navigationTitle("User Search") } } } diff --git a/Examples/xx-messenger/Tests/UserSearchFeatureTests/UserSearchFeatureTests.swift b/Examples/xx-messenger/Tests/UserSearchFeatureTests/UserSearchFeatureTests.swift index 11dfb3171209890b634974342348689e82ce339f..8d2f414a0a6c4fa130ea285863ae92ef80f70c21 100644 --- a/Examples/xx-messenger/Tests/UserSearchFeatureTests/UserSearchFeatureTests.swift +++ b/Examples/xx-messenger/Tests/UserSearchFeatureTests/UserSearchFeatureTests.swift @@ -1,8 +1,119 @@ +import ComposableArchitecture import XCTest +import XXClient +import XXMessengerClient @testable import UserSearchFeature final class UserSearchFeatureTests: XCTestCase { - func testExample() { - XCTAssert(true) + func testSearch() { + let store = TestStore( + initialState: UserSearchState(), + reducer: userSearchReducer, + environment: .unimplemented + ) + + var didSearchWithQuery: [MessengerSearchUsers.Query] = [] + + struct GetIdFromContactError: Error {} + struct GetFactsFromContactError: Error {} + + 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 + store.environment.mainQueue = .immediate + store.environment.messenger.searchUsers.run = { query in + didSearchWithQuery.append(query) + return contacts + } + + store.send(.set(\.$focusedField, .username)) { + $0.focusedField = .username + } + + store.send(.set(\.$query.username, "Username")) { + $0.query.username = "Username" + } + + store.send(.searchTapped) { + $0.focusedField = nil + $0.isSearching = true + $0.results = [] + $0.failure = nil + } + + store.receive(.didSucceed(contacts)) { + $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 + ) + ] + } + } + + func testSearchFailure() { + let store = TestStore( + initialState: UserSearchState(), + reducer: userSearchReducer, + environment: .unimplemented + ) + + struct Failure: Error {} + let failure = Failure() + + store.environment.bgQueue = .immediate + store.environment.mainQueue = .immediate + store.environment.messenger.searchUsers.run = { _ in throw failure } + + store.send(.searchTapped) { + $0.focusedField = nil + $0.isSearching = true + $0.results = [] + $0.failure = nil + } + + store.receive(.didFail(failure.localizedDescription)) { + $0.isSearching = false + $0.failure = failure.localizedDescription + $0.results = [] + } } }