From 95e14799e7a3de8906cd3e13cdd99b4e0082ca29 Mon Sep 17 00:00:00 2001 From: Dariusz Rybicki <dariusz@elixxir.io> Date: Tue, 6 Sep 2022 12:49:56 +0200 Subject: [PATCH 1/5] Add UserSearchFeature library --- .../xcschemes/UserSearchFeature.xcscheme | 78 +++++++++++++++++++ Examples/xx-messenger/Package.swift | 15 ++++ .../xcschemes/XXMessenger.xcscheme | 10 +++ .../UserSearchFeature/UserSearchFeature.swift | 20 +++++ .../UserSearchFeature/UserSearchView.swift | 32 ++++++++ .../UserSearchFeatureTests.swift | 8 ++ 6 files changed, 163 insertions(+) create mode 100644 Examples/xx-messenger/.swiftpm/xcode/xcshareddata/xcschemes/UserSearchFeature.xcscheme create mode 100644 Examples/xx-messenger/Sources/UserSearchFeature/UserSearchFeature.swift create mode 100644 Examples/xx-messenger/Sources/UserSearchFeature/UserSearchView.swift create mode 100644 Examples/xx-messenger/Tests/UserSearchFeatureTests/UserSearchFeatureTests.swift diff --git a/Examples/xx-messenger/.swiftpm/xcode/xcshareddata/xcschemes/UserSearchFeature.xcscheme b/Examples/xx-messenger/.swiftpm/xcode/xcshareddata/xcschemes/UserSearchFeature.xcscheme new file mode 100644 index 00000000..2f807166 --- /dev/null +++ b/Examples/xx-messenger/.swiftpm/xcode/xcshareddata/xcschemes/UserSearchFeature.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 = "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> diff --git a/Examples/xx-messenger/Package.swift b/Examples/xx-messenger/Package.swift index 8f1e26f7..1db1bdf6 100644 --- a/Examples/xx-messenger/Package.swift +++ b/Examples/xx-messenger/Package.swift @@ -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: [ @@ -134,6 +135,20 @@ let package = Package( ], swiftSettings: swiftSettings ), + .target( + name: "UserSearchFeature", + dependencies: [ + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + ], + swiftSettings: swiftSettings + ), + .testTarget( + name: "UserSearchFeatureTests", + dependencies: [ + .target(name: "UserSearchFeature"), + ], + swiftSettings: swiftSettings + ), .target( name: "WelcomeFeature", dependencies: [ 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 669debfc..041cf3f7 100644 --- a/Examples/xx-messenger/Project/XXMessenger.xcodeproj/xcshareddata/xcschemes/XXMessenger.xcscheme +++ b/Examples/xx-messenger/Project/XXMessenger.xcodeproj/xcshareddata/xcschemes/XXMessenger.xcscheme @@ -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 diff --git a/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchFeature.swift b/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchFeature.swift new file mode 100644 index 00000000..43b82dfd --- /dev/null +++ b/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchFeature.swift @@ -0,0 +1,20 @@ +import ComposableArchitecture +import XCTestDynamicOverlay + +public struct UserSearchState: Equatable { + public init() {} +} + +public enum UserSearchAction: Equatable {} + +public struct UserSearchEnvironment { + public init() {} +} + +#if DEBUG +extension UserSearchEnvironment { + public static let unimplemented = UserSearchEnvironment() +} +#endif + +public let userSearchReducer = Reducer<UserSearchState, UserSearchAction, UserSearchEnvironment>.empty diff --git a/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchView.swift b/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchView.swift new file mode 100644 index 00000000..27f94bfa --- /dev/null +++ b/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchView.swift @@ -0,0 +1,32 @@ +import ComposableArchitecture +import SwiftUI + +public struct UserSearchView: View { + public init(store: Store<UserSearchState, UserSearchAction>) { + self.store = store + } + + let store: Store<UserSearchState, UserSearchAction> + + struct ViewState: Equatable { + init(state: UserSearchState) {} + } + + public var body: some View { + WithViewStore(store.scope(state: ViewState.init)) { viewStore in + Text("UserSearchView") + } + } +} + +#if DEBUG +public struct UserSearchView_Previews: PreviewProvider { + public static var previews: some View { + UserSearchView(store: Store( + initialState: UserSearchState(), + reducer: .empty, + environment: () + )) + } +} +#endif diff --git a/Examples/xx-messenger/Tests/UserSearchFeatureTests/UserSearchFeatureTests.swift b/Examples/xx-messenger/Tests/UserSearchFeatureTests/UserSearchFeatureTests.swift new file mode 100644 index 00000000..11dfb317 --- /dev/null +++ b/Examples/xx-messenger/Tests/UserSearchFeatureTests/UserSearchFeatureTests.swift @@ -0,0 +1,8 @@ +import XCTest +@testable import UserSearchFeature + +final class UserSearchFeatureTests: XCTestCase { + func testExample() { + XCTAssert(true) + } +} -- GitLab From bee12093852601243d6e575b9844288f022c0159 Mon Sep 17 00:00:00 2001 From: Dariusz Rybicki <dariusz@elixxir.io> Date: Tue, 6 Sep 2022 13:01:19 +0200 Subject: [PATCH 2/5] Present UserSearch from Home --- Examples/xx-messenger/Package.swift | 2 ++ .../AppFeature/AppEnvironment+Live.swift | 4 +++ .../Sources/HomeFeature/HomeFeature.swift | 34 ++++++++++++++++--- .../Sources/HomeFeature/HomeView.swift | 25 ++++++++++++++ .../HomeFeatureTests/HomeFeatureTests.swift | 27 +++++++++++++++ 5 files changed, 88 insertions(+), 4 deletions(-) diff --git a/Examples/xx-messenger/Package.swift b/Examples/xx-messenger/Package.swift index 1db1bdf6..74bda195 100644 --- a/Examples/xx-messenger/Package.swift +++ b/Examples/xx-messenger/Package.swift @@ -71,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"), @@ -91,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"), diff --git a/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift b/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift index 19f1b7f5..1fc08a8c 100644 --- a/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift +++ b/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift @@ -3,6 +3,7 @@ import Foundation import HomeFeature import RegisterFeature import RestoreFeature +import UserSearchFeature import WelcomeFeature import XXMessengerClient import XXModels @@ -44,6 +45,9 @@ extension AppEnvironment { mainQueue: mainQueue, bgQueue: bgQueue ) + }, + userSearch: { + UserSearchEnvironment() } ) } diff --git a/Examples/xx-messenger/Sources/HomeFeature/HomeFeature.swift b/Examples/xx-messenger/Sources/HomeFeature/HomeFeature.swift index 0a019179..51083ea0 100644 --- a/Examples/xx-messenger/Sources/HomeFeature/HomeFeature.swift +++ b/Examples/xx-messenger/Sources/HomeFeature/HomeFeature.swift @@ -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() } +) diff --git a/Examples/xx-messenger/Sources/HomeFeature/HomeView.swift b/Examples/xx-messenger/Sources/HomeFeature/HomeView.swift index ce8aac42..e52e8d1b 100644 --- a/Examples/xx-messenger/Sources/HomeFeature/HomeView.swift +++ b/Examples/xx-messenger/Sources/HomeFeature/HomeView.swift @@ -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)) } diff --git a/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift b/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift index c7168d5b..aa309016 100644 --- a/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift +++ b/Examples/xx-messenger/Tests/HomeFeatureTests/HomeFeatureTests.swift @@ -1,5 +1,6 @@ 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 + } + } } -- GitLab From 9f5a54adccf3e2614bc5581c8894cb8e238e5f50 Mon Sep 17 00:00:00 2001 From: Dariusz Rybicki <dariusz@elixxir.io> Date: Tue, 6 Sep 2022 13:17:51 +0200 Subject: [PATCH 3/5] Refactor --- .../Sources/RegisterFeature/RegisterFeature.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Examples/xx-messenger/Sources/RegisterFeature/RegisterFeature.swift b/Examples/xx-messenger/Sources/RegisterFeature/RegisterFeature.swift index a8d3280c..8e1f3541 100644 --- a/Examples/xx-messenger/Sources/RegisterFeature/RegisterFeature.swift +++ b/Examples/xx-messenger/Sources/RegisterFeature/RegisterFeature.swift @@ -1,6 +1,6 @@ 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? -- GitLab From 7824f17d5f76519be448e8052152c84f611f72c6 Mon Sep 17 00:00:00 2001 From: Dariusz Rybicki <dariusz@elixxir.io> Date: Tue, 6 Sep 2022 14:20:58 +0200 Subject: [PATCH 4/5] Improve text input on RegisterView --- .../xx-messenger/Sources/RegisterFeature/RegisterView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Examples/xx-messenger/Sources/RegisterFeature/RegisterView.swift b/Examples/xx-messenger/Sources/RegisterFeature/RegisterView.swift index 195b655b..c3601630 100644 --- a/Examples/xx-messenger/Sources/RegisterFeature/RegisterView.swift +++ b/Examples/xx-messenger/Sources/RegisterFeature/RegisterView.swift @@ -37,6 +37,8 @@ public struct RegisterView: View { label: { Text("Username") } ) .focused($focusedField, equals: .username) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) } header: { Text("Username") } -- GitLab From 0b60e9ca577bd760d3bca6c6f59e319d23699ae2 Mon Sep 17 00:00:00 2001 From: Dariusz Rybicki <dariusz@elixxir.io> Date: Tue, 6 Sep 2022 15:19:17 +0200 Subject: [PATCH 5/5] Implement user search --- Examples/xx-messenger/Package.swift | 2 + .../AppFeature/AppEnvironment+Live.swift | 6 +- .../UserSearchFeature/UserSearchFeature.swift | 125 +++++++++++++++++- .../UserSearchFeature/UserSearchView.swift | 97 +++++++++++++- .../UserSearchFeatureTests.swift | 115 +++++++++++++++- 5 files changed, 335 insertions(+), 10 deletions(-) diff --git a/Examples/xx-messenger/Package.swift b/Examples/xx-messenger/Package.swift index 74bda195..f9ceb75f 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 1fc08a8c..5d777780 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 43b82dfd..86a13c0e 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 27f94bfa..266938a2 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 11dfb317..8d2f414a 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 = [] + } } } -- GitLab