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

Merge branch 'feature/messenger-example-user-search' into 'development'

Messenger example - user search

See merge request elixxir/elixxir-dapps-sdk-swift!65
parents d3d1ba30 0b60e9ca
No related branches found
No related tags found
2 merge requests!102Release 1.0.0,!65Messenger example - user search
Showing
with 582 additions and 6 deletions
<?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 = "UserSearchFeature"
BuildableName = "UserSearchFeature"
BlueprintName = "UserSearchFeature"
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 = "UserSearchFeatureTests"
BuildableName = "UserSearchFeatureTests"
BlueprintName = "UserSearchFeatureTests"
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 = "UserSearchFeature"
BuildableName = "UserSearchFeature"
BlueprintName = "UserSearchFeature"
ReferencedContainer = "container:">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
......@@ -23,6 +23,7 @@ let package = Package(
.library(name: "HomeFeature", targets: ["HomeFeature"]),
.library(name: "RegisterFeature", targets: ["RegisterFeature"]),
.library(name: "RestoreFeature", targets: ["RestoreFeature"]),
.library(name: "UserSearchFeature", targets: ["UserSearchFeature"]),
.library(name: "WelcomeFeature", targets: ["WelcomeFeature"]),
],
dependencies: [
......@@ -70,6 +71,7 @@ let package = Package(
.target(name: "HomeFeature"),
.target(name: "RegisterFeature"),
.target(name: "RestoreFeature"),
.target(name: "UserSearchFeature"),
.target(name: "WelcomeFeature"),
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
.product(name: "ComposablePresentation", package: "swift-composable-presentation"),
......@@ -90,6 +92,7 @@ let package = Package(
dependencies: [
.target(name: "AppCore"),
.target(name: "RegisterFeature"),
.target(name: "UserSearchFeature"),
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
.product(name: "ComposablePresentation", package: "swift-composable-presentation"),
.product(name: "XXMessengerClient", package: "elixxir-dapps-sdk-swift"),
......@@ -134,6 +137,22 @@ let package = Package(
],
swiftSettings: swiftSettings
),
.target(
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
),
.testTarget(
name: "UserSearchFeatureTests",
dependencies: [
.target(name: "UserSearchFeature"),
],
swiftSettings: swiftSettings
),
.target(
name: "WelcomeFeature",
dependencies: [
......
......@@ -79,6 +79,16 @@
ReferencedContainer = "container:..">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "UserSearchFeatureTests"
BuildableName = "UserSearchFeatureTests"
BlueprintName = "UserSearchFeatureTests"
ReferencedContainer = "container:..">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
......
......@@ -3,6 +3,7 @@ import Foundation
import HomeFeature
import RegisterFeature
import RestoreFeature
import UserSearchFeature
import WelcomeFeature
import XXMessengerClient
import XXModels
......@@ -44,6 +45,13 @@ extension AppEnvironment {
mainQueue: mainQueue,
bgQueue: bgQueue
)
},
userSearch: {
UserSearchEnvironment(
messenger: messenger,
mainQueue: mainQueue,
bgQueue: bgQueue
)
}
)
}
......
......@@ -4,6 +4,7 @@ import ComposableArchitecture
import ComposablePresentation
import Foundation
import RegisterFeature
import UserSearchFeature
import XXClient
import XXMessengerClient
......@@ -14,13 +15,15 @@ public struct HomeState: Equatable {
networkNodesReport: NodeRegistrationReport? = nil,
isDeletingAccount: Bool = false,
alert: AlertState<HomeAction>? = nil,
register: RegisterState? = nil
register: RegisterState? = nil,
userSearch: UserSearchState? = nil
) {
self.failure = failure
self.isNetworkHealthy = isNetworkHealthy
self.isDeletingAccount = isDeletingAccount
self.alert = alert
self.register = register
self.userSearch = userSearch
}
public var failure: String?
......@@ -29,6 +32,7 @@ public struct HomeState: Equatable {
public var isDeletingAccount: Bool
public var alert: AlertState<HomeAction>?
public var register: RegisterState?
public var userSearch: UserSearchState?
}
public enum HomeAction: Equatable {
......@@ -58,7 +62,10 @@ public enum HomeAction: Equatable {
case deleteAccount(DeleteAccount)
case didDismissAlert
case didDismissRegister
case userSearchButtonTapped
case didDismissUserSearch
case register(RegisterAction)
case userSearch(UserSearchAction)
}
public struct HomeEnvironment {
......@@ -67,13 +74,15 @@ public struct HomeEnvironment {
db: DBManagerGetDB,
mainQueue: AnySchedulerOf<DispatchQueue>,
bgQueue: AnySchedulerOf<DispatchQueue>,
register: @escaping () -> RegisterEnvironment
register: @escaping () -> RegisterEnvironment,
userSearch: @escaping () -> UserSearchEnvironment
) {
self.messenger = messenger
self.db = db
self.mainQueue = mainQueue
self.bgQueue = bgQueue
self.register = register
self.userSearch = userSearch
}
public var messenger: Messenger
......@@ -81,6 +90,7 @@ public struct HomeEnvironment {
public var mainQueue: AnySchedulerOf<DispatchQueue>
public var bgQueue: AnySchedulerOf<DispatchQueue>
public var register: () -> RegisterEnvironment
public var userSearch: () -> UserSearchEnvironment
}
extension HomeEnvironment {
......@@ -89,7 +99,8 @@ extension HomeEnvironment {
db: .unimplemented,
mainQueue: .unimplemented,
bgQueue: .unimplemented,
register: { .unimplemented }
register: { .unimplemented },
userSearch: { .unimplemented }
)
}
......@@ -219,11 +230,19 @@ public let homeReducer = Reducer<HomeState, HomeAction, HomeEnvironment>
state.register = nil
return .none
case .userSearchButtonTapped:
state.userSearch = UserSearchState()
return .none
case .didDismissUserSearch:
state.userSearch = nil
return .none
case .register(.finished):
state.register = nil
return Effect(value: .messenger(.start))
case .register(_):
case .register(_), .userSearch(_):
return .none
}
}
......@@ -234,3 +253,10 @@ public let homeReducer = Reducer<HomeState, HomeAction, HomeEnvironment>
action: /HomeAction.register,
environment: { $0.register() }
)
.presenting(
userSearchReducer,
state: .keyPath(\.userSearch),
id: .notNil(),
action: /HomeAction.userSearch,
environment: { $0.userSearch() }
)
......@@ -2,6 +2,7 @@ import ComposableArchitecture
import ComposablePresentation
import RegisterFeature
import SwiftUI
import UserSearchFeature
import XXClient
public struct HomeView: View {
......@@ -86,6 +87,20 @@ public struct HomeView: View {
Text("Network")
}
Section {
Button {
viewStore.send(.userSearchButtonTapped)
} label: {
HStack {
Text("Search users")
Spacer()
Image(systemName: "chevron.forward")
}
}
} header: {
Text("Contacts")
}
Section {
Button(role: .destructive) {
viewStore.send(.deleteAccount(.buttonTapped))
......@@ -108,6 +123,16 @@ public struct HomeView: View {
store.scope(state: \.alert),
dismiss: HomeAction.didDismissAlert
)
.background(NavigationLinkWithStore(
store.scope(
state: \.userSearch,
action: HomeAction.userSearch
),
onDeactivate: {
viewStore.send(.didDismissUserSearch)
},
destination: UserSearchView.init(store:)
))
}
.navigationViewStyle(.stack)
.task { viewStore.send(.messenger(.start)) }
......
import AppCore
import ComposableArchitecture
import SwiftUI
import Foundation
import XCTestDynamicOverlay
import XXMessengerClient
import XXModels
......@@ -13,11 +13,13 @@ public struct RegisterState: Equatable {
public init(
focusedField: Field? = nil,
username: String = "",
isRegistering: Bool = false
isRegistering: Bool = false,
failure: String? = nil
) {
self.focusedField = focusedField
self.username = username
self.isRegistering = isRegistering
self.failure = failure
}
@BindableState public var focusedField: Field?
......
......@@ -37,6 +37,8 @@ public struct RegisterView: View {
label: { Text("Username") }
)
.focused($focusedField, equals: .username)
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
} header: {
Text("Username")
}
......
import ComposableArchitecture
import Foundation
import XCTestDynamicOverlay
import XXClient
import XXMessengerClient
public struct UserSearchState: Equatable {
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, BindableAction {
case searchTapped
case didFail(String)
case didSucceed([Contact])
case binding(BindingAction<UserSearchState>)
}
public struct UserSearchEnvironment {
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(
messenger: .unimplemented,
mainQueue: .unimplemented,
bgQueue: .unimplemented
)
}
#endif
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>) {
self.store = store
}
let store: Store<UserSearchState, UserSearchAction>
@FocusState var focusedField: UserSearchState.Field?
struct ViewState: Equatable {
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
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")
}
}
}
#if DEBUG
public struct UserSearchView_Previews: PreviewProvider {
public static var previews: some View {
UserSearchView(store: Store(
initialState: UserSearchState(),
reducer: .empty,
environment: ()
))
}
}
#endif
import ComposableArchitecture
import RegisterFeature
import UserSearchFeature
import XCTest
import XXClient
import XXMessengerClient
......@@ -437,4 +438,30 @@ final class HomeFeatureTests: XCTestCase {
$0.register = nil
}
}
func testUserSearchButtonTapped() {
let store = TestStore(
initialState: HomeState(),
reducer: homeReducer,
environment: .unimplemented
)
store.send(.userSearchButtonTapped) {
$0.userSearch = UserSearchState()
}
}
func testDidDismissUserSearch() {
let store = TestStore(
initialState: HomeState(
userSearch: UserSearchState()
),
reducer: homeReducer,
environment: .unimplemented
)
store.send(.didDismissUserSearch) {
$0.userSearch = nil
}
}
}
import ComposableArchitecture
import XCTest
import XXClient
import XXMessengerClient
@testable import UserSearchFeature
final class UserSearchFeatureTests: XCTestCase {
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