Skip to content
Snippets Groups Projects
Commit 0b60e9ca authored by Dariusz Rybicki's avatar Dariusz Rybicki
Browse files

Implement user search

parent eb5df0c2
No related branches found
No related tags found
2 merge requests!102Release 1.0.0,!65Messenger example - user search
......@@ -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
),
......
......@@ -47,7 +47,11 @@ extension AppEnvironment {
)
},
userSearch: {
UserSearchEnvironment()
UserSearchEnvironment(
messenger: messenger,
mainQueue: mainQueue,
bgQueue: bgQueue
)
}
)
}
......
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()
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")
}
}
}
......
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 = []
}
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment