diff --git a/WooCommerce/Classes/Authentication/Store Creation/StoreCreationCoordinator.swift b/WooCommerce/Classes/Authentication/Store Creation/StoreCreationCoordinator.swift index 86719301ce9..f0810048f5a 100644 --- a/WooCommerce/Classes/Authentication/Store Creation/StoreCreationCoordinator.swift +++ b/WooCommerce/Classes/Authentication/Store Creation/StoreCreationCoordinator.swift @@ -263,8 +263,6 @@ private extension StoreCreationCoordinator { name: storeName, domain: domain, planToPurchase: planToPurchase) - }, onSkip: { - // TODO-8045: skip to the next step of store creation with an auto-generated domain. }) navigationController.pushViewController(domainSelector, animated: false) } diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorView.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorView.swift index eb97bdfa5dc..b5fcdba291c 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorView.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorView.swift @@ -4,18 +4,14 @@ import SwiftUI final class DomainSelectorHostingController: UIHostingController { private let viewModel: DomainSelectorViewModel private let onDomainSelection: (String) async -> Void - private let onSkip: () -> Void /// - Parameters: /// - viewModel: View model for the domain selector. /// - onDomainSelection: Called when the user continues with a selected domain name. - /// - onSkip: Called when the user taps to skip domain selection. init(viewModel: DomainSelectorViewModel, - onDomainSelection: @escaping (String) async -> Void, - onSkip: @escaping () -> Void) { + onDomainSelection: @escaping (String) async -> Void) { self.viewModel = viewModel self.onDomainSelection = onDomainSelection - self.onSkip = onSkip super.init(rootView: DomainSelectorView(viewModel: viewModel)) rootView.onDomainSelection = { [weak self] domain in @@ -30,16 +26,11 @@ final class DomainSelectorHostingController: UIHostingController Void) = { _ in } @@ -79,88 +70,113 @@ struct DomainSelectorView: View { @State private var isWaitingForDomainSelectionCompletion: Bool = false + @FocusState private var textFieldIsFocused: Bool + init(viewModel: DomainSelectorViewModel) { self.viewModel = viewModel } var body: some View { - VStack(alignment: .leading, spacing: 0) { - ScrollView { - VStack(alignment: .leading) { - // Header label. - Text(Localization.subtitle) - .foregroundColor(Color(.secondaryLabel)) - .bodyStyle() - .padding(.horizontal, Layout.defaultHorizontalPadding) + ScrollView { + VStack(alignment: .leading, spacing: 0) { + // Header label. + Text(Localization.subtitle) + .foregroundColor(Color(.secondaryLabel)) + .bodyStyle() + .padding(.horizontal, Layout.defaultHorizontalPadding) + .padding(.top, 16) - // Search text field. - SearchHeader(text: $viewModel.searchTerm, - placeholder: Localization.searchPlaceholder, - customizations: .init(backgroundColor: .clear, - borderColor: .separator, - internalHorizontalPadding: 21, - internalVerticalPadding: 12)) + Spacer() + .frame(height: 30) - // Results header. - Text(Localization.suggestionsHeader) + // Search text field. + SearchHeader(text: $viewModel.searchTerm, + placeholder: Localization.searchPlaceholder, + customizations: .init(backgroundColor: .clear, + borderColor: .separator, + internalHorizontalPadding: 21, + internalVerticalPadding: 12, + iconSize: .init(width: 14, height: 14))) + .focused($textFieldIsFocused) + + switch viewModel.state { + case .placeholder: + // Placeholder image when search query is empty. + Spacer() + .frame(height: 30) + + HStack { + Spacer() + Image(uiImage: .domainSearchPlaceholderImage) + Spacer() + } + case .loading: + // Progress indicator when loading domain suggestions. + Spacer() + .frame(height: 23) + + HStack { + Spacer() + ProgressView() + Spacer() + } + case .error(let errorMessage): + // Error message when there is an error loading domain suggestions. + Spacer() + .frame(height: 23) + + Text(errorMessage) .foregroundColor(Color(.secondaryLabel)) .bodyStyle() + .frame(maxWidth: .infinity, alignment: .center) + .multilineTextAlignment(.center) + .padding(Layout.defaultPadding) + case .results(let domains): + // Domain suggestions. + Text(Localization.suggestionsHeader) + .foregroundColor(Color(.secondaryLabel)) + .footnoteStyle() .padding(.horizontal, Layout.defaultHorizontalPadding) + .padding(.vertical, insets: .init(top: 14, leading: 0, bottom: 8, trailing: 0)) - if viewModel.searchTerm.isEmpty { - // Placeholder image when search query is empty. - HStack { - Spacer() - Image(uiImage: .domainSearchPlaceholderImage) - Spacer() - } - } else if viewModel.isLoadingDomainSuggestions { - // Progress indicator when loading domain suggestions. - HStack { - Spacer() - ProgressView() - Spacer() - } - } else if let errorMessage = viewModel.errorMessage { - // Error message when there is an error loading domain suggestions. - Text(errorMessage) - .padding(Layout.defaultPadding) - } else { - // Domain suggestions. - LazyVStack { - ForEach(viewModel.domains, id: \.self) { domain in - Button { - selectedDomainName = domain - } label: { - VStack(alignment: .leading) { - DomainRowView(viewModel: .init(domainName: domain, - searchQuery: viewModel.searchTerm, - isSelected: domain == selectedDomainName)) - Divider() - .frame(height: Layout.dividerHeight) - .padding(.leading, Layout.defaultHorizontalPadding) - } + LazyVStack { + ForEach(domains, id: \.self) { domain in + Button { + textFieldIsFocused = false + selectedDomainName = domain + } label: { + VStack(alignment: .leading) { + DomainRowView(viewModel: .init(domainName: domain, + searchQuery: viewModel.searchTerm, + isSelected: domain == selectedDomainName)) + Divider() + .frame(height: Layout.dividerHeight) + .padding(.leading, Layout.defaultHorizontalPadding) } } } } } } - + } + .safeAreaInset(edge: .bottom) { // Continue button when a domain is selected. if let selectedDomainName { - Divider() - .frame(height: Layout.dividerHeight) - .foregroundColor(Color(.separator)) - Button(Localization.continueButtonTitle) { - Task { @MainActor in - isWaitingForDomainSelectionCompletion = true - await onDomainSelection(selectedDomainName) - isWaitingForDomainSelectionCompletion = false + VStack { + Divider() + .frame(height: Layout.dividerHeight) + .foregroundColor(Color(.separator)) + Button(Localization.continueButtonTitle) { + Task { @MainActor in + isWaitingForDomainSelectionCompletion = true + await onDomainSelection(selectedDomainName) + isWaitingForDomainSelectionCompletion = false + } } + .buttonStyle(PrimaryLoadingButtonStyle(isLoading: isWaitingForDomainSelectionCompletion)) + .padding(Layout.defaultPadding) } - .buttonStyle(PrimaryLoadingButtonStyle(isLoading: isWaitingForDomainSelectionCompletion)) - .padding(Layout.defaultPadding) + .background(Color(.systemBackground)) } } .navigationTitle(Localization.title) @@ -176,7 +192,6 @@ struct DomainSelectorView: View { private extension DomainSelectorView { enum Layout { - static let spacingBetweenTitleAndSubtitle: CGFloat = 16 static let defaultHorizontalPadding: CGFloat = 16 static let dividerHeight: CGFloat = 1 static let defaultPadding: EdgeInsets = .init(top: 10, leading: 16, bottom: 10, trailing: 16) @@ -185,9 +200,9 @@ private extension DomainSelectorView { enum Localization { static let title = NSLocalizedString("Choose a domain", comment: "Title of the domain selector.") static let subtitle = NSLocalizedString( - "This is where people will find you on the Internet. Don't worry, you can change it later.", + "This is where people will find you on the Internet. You can add another domain later.", comment: "Subtitle of the domain selector.") - static let searchPlaceholder = NSLocalizedString("Type to get suggestions", comment: "Placeholder of the search text field on the domain selector.") + static let searchPlaceholder = NSLocalizedString("Type a name for your store", comment: "Placeholder of the search text field on the domain selector.") static let suggestionsHeader = NSLocalizedString("SUGGESTIONS", comment: "Header label of the domain suggestions on the domain selector.") static let continueButtonTitle = NSLocalizedString("Continue", comment: "Title of the button to continue with a selected domain.") } diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorViewModel.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorViewModel.swift index 5ebc0a47004..2325d43ca8d 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorViewModel.swift @@ -10,14 +10,17 @@ final class DomainSelectorViewModel: ObservableObject { @Published var searchTerm: String = "" /// Domain names after domain suggestions are loaded remotely. - @Published private(set) var domains: [String] = [] + @Published private var domains: [String] = [] /// Error message from loading domain suggestions. - @Published private(set) var errorMessage: String? + @Published private var errorMessage: String? /// Whether domain suggestions are being loaded. @Published private(set) var isLoadingDomainSuggestions: Bool = false + /// The state of the main domain selector view based on the search query and loading state. + @Published private(set) var state: DomainSelectorView.ViewState = .placeholder + /// Subscription for search query changes for domain search. private var searchQuerySubscription: AnyCancellable? @@ -36,6 +39,8 @@ final class DomainSelectorViewModel: ObservableObject { // and thus the initial value isn't emitted in `observeDomainQuery` until setting the value afterward. observeDomainQuery() self.searchTerm = initialSearchTerm + + configureState() } } @@ -64,6 +69,22 @@ private extension DomainSelectorViewModel { } } + func configureState() { + Publishers.CombineLatest4($searchTerm, $isLoadingDomainSuggestions, $errorMessage, $domains) + .map { searchTerm, isLoadingDomainSuggestions, errorMessage, domains in + if searchTerm.isEmpty { + return .placeholder + } else if isLoadingDomainSuggestions { + return .loading + } else if let errorMessage { + return .error(message: errorMessage) + } else { + return .results(domains: domains) + } + } + .assign(to: &$state) + } + @MainActor func loadFreeDomainSuggestions(query: String) async -> Result<[FreeDomainSuggestion], Error> { await withCheckedContinuation { continuation in diff --git a/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/SearchHeader.swift b/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/SearchHeader.swift index 1567fba39f7..102dcaf5886 100644 --- a/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/SearchHeader.swift +++ b/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/SearchHeader.swift @@ -9,6 +9,7 @@ struct SearchHeader: View { let borderColor: UIColor let internalHorizontalPadding: CGFloat let internalVerticalPadding: CGFloat + let iconSize: CGSize } // Tracks the scale of the view due to accessibility changes @@ -34,7 +35,8 @@ struct SearchHeader: View { backgroundColor: .searchBarBackground, borderColor: .clear, internalHorizontalPadding: Layout.internalPadding, - internalVerticalPadding: Layout.internalPadding + internalVerticalPadding: Layout.internalPadding, + iconSize: Layout.iconSize )) { self._text = text self.placeholder = placeholder @@ -47,7 +49,8 @@ struct SearchHeader: View { Image(uiImage: .searchBarButtonItemImage) .renderingMode(.template) .resizable() - .frame(width: Layout.iconSize.width * scale, height: Layout.iconSize.height * scale) + .frame(width: customizations.iconSize.width * scale, + height: customizations.iconSize.height * scale) .foregroundColor(Color(.listSmallIcon)) .padding([.leading, .trailing], customizations.internalHorizontalPadding) .accessibilityHidden(true) @@ -55,6 +58,7 @@ struct SearchHeader: View { // TextField TextField(placeholder, text: $text) .padding([.bottom, .top], customizations.internalVerticalPadding) + .padding(.trailing, customizations.internalHorizontalPadding) .accessibility(addTraits: .isSearchField) } .background(Color(customizations.backgroundColor)) @@ -89,7 +93,7 @@ struct SearchHeaderView_Previews: PreviewProvider { customizations: .init(backgroundColor: .clear, borderColor: .separator, internalHorizontalPadding: 21, - internalVerticalPadding: 12)) + internalVerticalPadding: 12, iconSize: .init(width: 14, height: 14))) } } } diff --git a/WooCommerce/Resources/Images.xcassets/domain-search-placeholder.imageset/Contents.json b/WooCommerce/Resources/Images.xcassets/domain-search-placeholder.imageset/Contents.json index f7152ebcc14..64e502b3b04 100644 --- a/WooCommerce/Resources/Images.xcassets/domain-search-placeholder.imageset/Contents.json +++ b/WooCommerce/Resources/Images.xcassets/domain-search-placeholder.imageset/Contents.json @@ -3,6 +3,16 @@ { "filename" : "domain-search-placeholder.pdf", "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "domain-search-placeholder-dark.pdf", + "idiom" : "universal" } ], "info" : { diff --git a/WooCommerce/Resources/Images.xcassets/domain-search-placeholder.imageset/domain-search-placeholder-dark.pdf b/WooCommerce/Resources/Images.xcassets/domain-search-placeholder.imageset/domain-search-placeholder-dark.pdf new file mode 100644 index 00000000000..33cc40d1e74 Binary files /dev/null and b/WooCommerce/Resources/Images.xcassets/domain-search-placeholder.imageset/domain-search-placeholder-dark.pdf differ diff --git a/WooCommerce/WooCommerceTests/ViewModels/Domains/DomainSelectorViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewModels/Domains/DomainSelectorViewModelTests.swift index 55db00afbb4..e9d208c6b2b 100644 --- a/WooCommerce/WooCommerceTests/ViewModels/Domains/DomainSelectorViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewModels/Domains/DomainSelectorViewModelTests.swift @@ -24,49 +24,61 @@ final class DomainSelectorViewModelTests: XCTestCase { func test_DomainAction_is_not_dispatched_when_searchTerm_is_empty() { // Given stores.whenReceivingAction(ofType: DomainAction.self) { action in + // Then XCTFail("Unexpected action: \(action)") } // When viewModel.searchTerm = "" viewModel.searchTerm = "" - - // Then - XCTAssertEqual(viewModel.domains, []) - XCTAssertTrue(stores.receivedActions.isEmpty) } - func test_domain_suggestions_success_returns_domain_rows_for_free_domains() { - // Given - mockDomainSuggestionsSuccess(suggestions: [ - .init(name: "free.com", isFree: true), - .init(name: "paid.com", isFree: false) - ]) + // MARK: - `isLoadingDomainSuggestions` + + func test_isLoadingDomainSuggestions_is_toggled_when_loading_suggestions() { + var loadingValues: [Bool] = [] + viewModel.$isLoadingDomainSuggestions.sink { value in + loadingValues.append(value) + }.store(in: &subscriptions) + + mockDomainSuggestionsFailure(error: SampleError.first) // When - viewModel.searchTerm = "woo" + viewModel.searchTerm = "Woo" // Then waitUntil { - self.viewModel.domains.isNotEmpty + loadingValues == [false, true, false] } - XCTAssertEqual(viewModel.domains, ["free.com"]) } - func test_domain_suggestions_failure_does_not_update_domain_rows() { + // MARK: - `state` + + func test_state_is_placeholder_when_searchTerm_is_empty() { + // When + viewModel.searchTerm = "" + + // Then + XCTAssertEqual(viewModel.state, .placeholder) + } + + func test_state_is_results_with_free_domain_only_on_domain_suggestions_success() { // Given - mockDomainSuggestionsFailure(error: SampleError.first) + mockDomainSuggestionsSuccess(suggestions: [ + .init(name: "free.com", isFree: true), + .init(name: "paid.com", isFree: false) + ]) // When viewModel.searchTerm = "woo" // Then - XCTAssertEqual(viewModel.domains, []) + waitUntil { + self.viewModel.state == .results(domains: ["free.com"]) + } } - // MARK: - `errorMessage` - - func test_domain_suggestions_failure_with_non_DotcomError_sets_default_error_message() { + func test_state_is_errorMessage_with_default_error_message_when_failure_is_not_DotcomError() { // Given mockDomainSuggestionsFailure(error: SampleError.first) @@ -75,12 +87,11 @@ final class DomainSelectorViewModelTests: XCTestCase { // Then waitUntil { - self.viewModel.errorMessage?.isNotEmpty == true + self.viewModel.state == .error(message: DomainSelectorViewModel.Localization.defaultErrorMessage) } - XCTAssertEqual(viewModel.errorMessage, DomainSelectorViewModel.Localization.defaultErrorMessage) } - func test_domain_suggestions_failure_with_DotcomError_unknown_error_sets_error_message() { + func test_state_is_errorMessage_with_DotcomError_message_when_failure_is_DotcomError() { // Given mockDomainSuggestionsFailure(error: DotcomError.unknown(code: "", message: "error message")) @@ -89,19 +100,18 @@ final class DomainSelectorViewModelTests: XCTestCase { // Then waitUntil { - self.viewModel.errorMessage?.isNotEmpty == true + self.viewModel.state == .error(message: "error message") } - XCTAssertEqual(viewModel.errorMessage, "error message") } - func test_domain_suggestions_error_message_is_reset_when_loading_domain_suggestions() { + func test_state_is_updated_from_errorMessage_to_results_when_changing_search_term_after_failure() { // Given mockDomainSuggestionsFailure(error: SampleError.first) // When viewModel.searchTerm = "woo" waitUntil { - self.viewModel.errorMessage?.isNotEmpty == true + self.viewModel.state == .error(message: DomainSelectorViewModel.Localization.defaultErrorMessage) } mockDomainSuggestionsSuccess(suggestions: []) @@ -109,26 +119,7 @@ final class DomainSelectorViewModelTests: XCTestCase { // Then waitUntil { - self.viewModel.errorMessage == nil - } - } - - // MARK: `isLoadingDomainSuggestions` - - func test_isLoadingDomainSuggestions_is_toggled_when_loading_suggestions() { - var loadingValues: [Bool] = [] - viewModel.$isLoadingDomainSuggestions.sink { value in - loadingValues.append(value) - }.store(in: &subscriptions) - - mockDomainSuggestionsFailure(error: SampleError.first) - - // When - viewModel.searchTerm = "Woo" - - // Then - waitUntil { - loadingValues == [false, true, false] + self.viewModel.state == .results(domains: []) } } }