Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ private extension StoreCreationCoordinator {
// TODO-8045: skip to the next step of store creation with an auto-generated domain.
})
let storeCreationNavigationController = UINavigationController(rootViewController: domainSelector)
storeCreationNavigationController.navigationBar.prefersLargeTitles = true
presentStoreCreation(viewController: storeCreationNavigationController)
}

Expand Down
6 changes: 6 additions & 0 deletions WooCommerce/Classes/Extensions/UIImage+Woo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,12 @@ extension UIImage {
return UIImage.gridicon(.cross, size: CGSize(width: 22, height: 22))
}

/// Domain search placeholder image.
///
static var domainSearchPlaceholderImage: UIImage {
return UIImage(named: "domain-search-placeholder")!
}

/// Ellipsis Icon
///
static var ellipsisImage: UIImage {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ struct DomainRowView: View {
.foregroundColor(Color(.brand))
}
}
.padding(Layout.insets)
}
}

private extension DomainRowView {
enum Layout {
static let insets: EdgeInsets = .init(top: 10, leading: 16, bottom: 10, trailing: 16)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,11 @@ private extension DomainSelectorHostingController {

/// Shows a transparent navigation bar without a bottom border.
func configureNavigationBarAppearance() {
navigationItem.title = Localization.title

let appearance = UINavigationBarAppearance()
appearance.configureWithTransparentBackground()
appearance.backgroundColor = UIColor.clear
appearance.backgroundColor = .systemBackground

navigationItem.standardAppearance = appearance
navigationItem.scrollEdgeAppearance = appearance
Expand All @@ -60,6 +62,7 @@ private extension DomainSelectorHostingController {

private extension DomainSelectorHostingController {
enum Localization {
static let title = NSLocalizedString("Choose a domain", comment: "Title of the domain selector.")
static let skipButtonTitle = NSLocalizedString("Skip", comment: "Navigation bar button on the domain selector screen to skip domain selection.")
}
}
Expand All @@ -70,49 +73,98 @@ struct DomainSelectorView: View {
var onDomainSelection: ((String) -> Void) = { _ in }

/// View model to drive the view.
@ObservedObject var viewModel: DomainSelectorViewModel
@ObservedObject private var viewModel: DomainSelectorViewModel

/// Currently selected domain name.
/// If this property is kept in the view model, a SwiftUI error appears `Publishing changes from within view updates`
/// when a domain row is selected.
@State var selectedDomainName: String?
@State private var selectedDomainName: String?

init(viewModel: DomainSelectorViewModel) {
self.viewModel = viewModel
}

var body: some View {
ScrollableVStack(alignment: .leading) {
// Header labels.
VStack(alignment: .leading, spacing: Layout.spacingBetweenTitleAndSubtitle) {
Text(Localization.title)
.titleStyle()
Text(Localization.subtitle)
.foregroundColor(Color(.secondaryLabel))
.bodyStyle()
}
.padding(.horizontal, Layout.defaultHorizontalPadding)

SearchHeader(filterText: $viewModel.searchTerm,
filterPlaceholder: Localization.searchPlaceholder)
.padding(.horizontal, Layout.defaultHorizontalPadding)

Text(Localization.suggestionsHeader)
.foregroundColor(Color(.secondaryLabel))
.bodyStyle()
.padding(.horizontal, Layout.defaultHorizontalPadding)

List(viewModel.domains, id: \.self) { domain in
Button {
selectedDomainName = domain
} label: {
DomainRowView(viewModel: .init(domainName: domain,
searchQuery: viewModel.searchTerm,
isSelected: domain == selectedDomainName))
VStack(alignment: .leading, spacing: 0) {
ScrollView {
VStack(alignment: .leading) {
// Header label.
Text(Localization.subtitle)
.foregroundColor(Color(.secondaryLabel))
.bodyStyle()
.padding(.horizontal, Layout.defaultHorizontalPadding)

// Search text field.
SearchHeader(text: $viewModel.searchTerm,
placeholder: Localization.searchPlaceholder,
customizations: .init(backgroundColor: .clear,
borderColor: .separator,
internalHorizontalPadding: 21,
internalVerticalPadding: 12))

// Results header.
Text(Localization.suggestionsHeader)
.foregroundColor(Color(.secondaryLabel))
.bodyStyle()
.padding(.horizontal, Layout.defaultHorizontalPadding)

// Domain results.
if let placeholderImage = viewModel.placeholderImage {
// Placeholder image when search query is empty.
HStack {
Spacer()
Image(uiImage: placeholderImage)
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)
}
}
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can simply decide visibility of the placeholder image based on searchTerm. That way we won't need to update the image in the view model. We can also avoid the else-if blocks by using renderredIf - the results LazyVStack will not be rendered if the domain list is empty so there's no need to put it in the else block I believe.

// Placeholder image when search query is empty.
HStack {
    Spacer()
    Image(uiImage: UIImage.domainSearchPlaceholderImage)
    Spacer()
}
.renderedIf(viewModel.searchTerm.isEmpty)

HStack {
    Spacer()
    ProgressView()
    Spacer()
}
.renderedIf(viewModel.isLoadingDomainSuggestions)

if let errorMessage = viewModel.errorMessage {
    // Error message when there is an error loading domain suggestions.
    Text(errorMessage)
        .padding(Layout.defaultPadding)
}

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)
            }
        }
    }
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice suggestions, I forgot about renderedIf! The if/else style ensures that only one conditional UI is shown at a time. I tested the suggested changes, and I'll have to change the timing when errors are reset and maybe more to make sure only one UI is shown at the same time. I think I'll keep the if/else style for now, but I replaced the viewModel.placeholderImage with viewModel.searchTerm.isEmpty in 42d811f thanks to your suggestion!

}
}.listStyle(.inset)
}

// Continue button when a domain is selected.
if let selectedDomainName {
Divider()
.frame(height: Layout.dividerHeight)
.foregroundColor(Color(.separator))
Button(Localization.continueButtonTitle) {
onDomainSelection(selectedDomainName)
}
.buttonStyle(PrimaryButtonStyle())
.padding(Layout.defaultPadding)
}
}
.onChange(of: viewModel.isLoadingDomainSuggestions) { isLoadingDomainSuggestions in
// Resets selected domain when loading domain suggestions.
if isLoadingDomainSuggestions {
selectedDomainName = nil
}
}
}
Expand All @@ -122,10 +174,11 @@ 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)
}

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.",
comment: "Subtitle of the domain selector.")
Expand All @@ -135,8 +188,62 @@ private extension DomainSelectorView {
}
}

#if DEBUG

import Yosemite
import enum Networking.DotcomError

/// StoresManager that specifically handles `DomainAction` for `DomainSelectorView` previews.
final class DomainSelectorViewStores: DefaultStoresManager {
private let result: Result<[FreeDomainSuggestion], Error>?

init(result: Result<[FreeDomainSuggestion], Error>?) {
self.result = result
super.init(sessionManager: ServiceLocator.stores.sessionManager)
}

override func dispatch(_ action: Action) {
if let action = action as? DomainAction {
if case let .loadFreeDomainSuggestions(_, completion) = action {
if let result {
completion(result)
}
}
}
}
}

struct DomainSelectorView_Previews: PreviewProvider {
static var previews: some View {
DomainSelectorView(viewModel: .init())
Group {
// Empty query state.
DomainSelectorView(viewModel:
.init(initialSearchTerm: "",
stores: DomainSelectorViewStores(result: nil)))
// Results state.
DomainSelectorView(viewModel:
.init(initialSearchTerm: "Fruit smoothie",
stores: DomainSelectorViewStores(result: .success([
.init(name: "grapefruitsmoothie.com", isFree: true),
.init(name: "fruitsmoothie.com", isFree: true),
.init(name: "grapefruitsmoothiee.com", isFree: true),
.init(name: "freesmoothieeee.com", isFree: true),
.init(name: "greatfruitsmoothie1.com", isFree: true),
.init(name: "tropicalsmoothie.com", isFree: true)
]))))
// Error state.
DomainSelectorView(viewModel:
.init(initialSearchTerm: "test",
stores: DomainSelectorViewStores(result: .failure(
DotcomError.unknown(code: "invalid_query",
message: "Domain searches must contain a word with the following characters.")
))))
// Loading state.
DomainSelectorView(viewModel:
.init(initialSearchTerm: "test",
stores: DomainSelectorViewStores(result: nil)))
}
}
}

#endif
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Combine
import SwiftUI
import Yosemite
import enum Networking.DotcomError

/// View model for `DomainSelectorView`.
final class DomainSelectorViewModel: ObservableObject {
Expand All @@ -11,30 +12,55 @@ final class DomainSelectorViewModel: ObservableObject {
/// Domain names after domain suggestions are loaded remotely.
@Published private(set) var domains: [String] = []

/// Error message from loading domain suggestions.
@Published private(set) var errorMessage: String?

/// Whether domain suggestions are being loaded.
@Published private(set) var isLoadingDomainSuggestions: Bool = false

/// Placeholder image is set when the search term is empty. Otherwise, the placeholder image is `nil`.
@Published private(set) var placeholderImage: UIImage?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As comment above, we should not need this to decide the visibility of the placeholder image - the searchTerm should be sufficient.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in 42d811f 👍


/// Subscription for search query changes for domain search.
private var searchQuerySubscription: AnyCancellable?

private let stores: StoresManager
private let debounceDuration: Double

init(stores: StoresManager = ServiceLocator.stores,
init(initialSearchTerm: String = "",
stores: StoresManager = ServiceLocator.stores,
debounceDuration: Double = Constants.fieldDebounceDuration) {
self.stores = stores
self.debounceDuration = debounceDuration

// Sets the initial search term after related subscriptions are set up
// so that the initial value is always emitted.
// In `observeDomainQuery`, `share()` transforms the publisher to `PassthroughSubject`
// and thus the initial value isn't emitted in `observeDomainQuery` until setting the value afterward.
observeDomainQuery()
self.searchTerm = initialSearchTerm
}
}

private extension DomainSelectorViewModel {
func observeDomainQuery() {
searchQuerySubscription = $searchTerm
let searchTermPublisher = $searchTerm
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.debounce(for: .seconds(debounceDuration), scheduler: DispatchQueue.main)
.share()

searchQuerySubscription = searchTermPublisher
.filter { $0.isNotEmpty }
.removeDuplicates()
.debounce(for: .seconds(debounceDuration), scheduler: DispatchQueue.main)
.sink { [weak self] searchTerm in
guard let self = self else { return }
Task { @MainActor in
self.errorMessage = nil
self.placeholderImage = nil
self.isLoadingDomainSuggestions = true
let result = await self.loadFreeDomainSuggestions(query: searchTerm)
self.isLoadingDomainSuggestions = false

switch result {
case .success(let suggestions):
self.handleFreeDomainSuggestions(suggestions, query: searchTerm)
Expand All @@ -43,6 +69,12 @@ private extension DomainSelectorViewModel {
}
}
}

searchTermPublisher
.map {
$0.isEmpty ? UIImage.domainSearchPlaceholderImage: nil
}
.assign(to: &$placeholderImage)
}

@MainActor
Expand All @@ -66,8 +98,13 @@ private extension DomainSelectorViewModel {

@MainActor
func handleError(_ error: Error) {
// TODO-8045: error handling - maybe show an error message.
DDLogError("Cannot load domain suggestions for \(searchTerm)")
if let dotcomError = error as? DotcomError,
case let .unknown(_, message) = dotcomError {
errorMessage = message
} else {
errorMessage = Localization.defaultErrorMessage
}
DDLogError("Cannot load domain suggestions for \(searchTerm): \(error)")
}
}

Expand All @@ -76,3 +113,11 @@ private extension DomainSelectorViewModel {
static let fieldDebounceDuration = 0.3
}
}

extension DomainSelectorViewModel {
enum Localization {
static let defaultErrorMessage =
NSLocalizedString("Please try another query.",
comment: "Default message when there is an unexpected error loading domain suggestions on the domain selector.")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ struct FilterListSelector<ViewModel: FilterListSelectorViewModelable>: View {

var body: some View {
VStack(spacing: 0) {
SearchHeader(filterText: $searchTerm, filterPlaceholder: viewModel.filterPlaceholder)
SearchHeader(text: $searchTerm, placeholder: viewModel.filterPlaceholder)
.background(Color(.listForeground))
.onChange(of: searchTerm) { newValue in
viewModel.searchTerm = newValue
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ struct ProductSelector: View {
var body: some View {
NavigationView {
VStack(spacing: 0) {
SearchHeader(filterText: $viewModel.searchTerm, filterPlaceholder: Localization.searchPlaceholder)
SearchHeader(text: $viewModel.searchTerm, placeholder: Localization.searchPlaceholder)
.padding(.horizontal, insets: safeAreaInsets)
.accessibilityIdentifier("product-selector-search-bar")
HStack {
Expand Down
Loading