Skip to content

Commit fa0a8cd

Browse files
authored
Merge pull request #8065 from woocommerce/feat/8045-domain-selector
Store creation M2 - domain selection: basic UI integration
2 parents ea5de4a + 15e1319 commit fa0a8cd

File tree

11 files changed

+493
-9
lines changed

11 files changed

+493
-9
lines changed

Experiments/Experiments/DefaultFeatureFlagService.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ public struct DefaultFeatureFlagService: FeatureFlagService {
3535
return buildConfig == .localDeveloper || buildConfig == .alpha
3636
case .storeCreationMVP:
3737
return true
38+
case .storeCreationM2:
39+
return buildConfig == .localDeveloper || buildConfig == .alpha
3840
case .justInTimeMessagesOnDashboard:
3941
return true
4042
case .productsOnboarding:

Experiments/Experiments/FeatureFlag.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ public enum FeatureFlag: Int {
7474
///
7575
case storeCreationMVP
7676

77+
/// Store creation milestone 2. https://wp.me/pe5sF9-I3
78+
///
79+
case storeCreationM2
80+
7781
/// Just In Time Messages on Dashboard
7882
///
7983
case justInTimeMessagesOnDashboard

WooCommerce/Classes/Authentication/Store Creation/StoreCreationCoordinator.swift

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Combine
22
import UIKit
33
import Yosemite
4+
import protocol Experiments.FeatureFlagService
45
import protocol Storage.StorageManagerType
56

67
/// Coordinates navigation for store creation flow, with the assumption that the app is already authenticated with a WPCOM user.
@@ -22,12 +23,14 @@ final class StoreCreationCoordinator: Coordinator {
2223
private let source: Source
2324
private let storePickerViewModel: StorePickerViewModel
2425
private let switchStoreUseCase: SwitchStoreUseCaseProtocol
26+
private let featureFlagService: FeatureFlagService
2527

2628
init(source: Source,
2729
navigationController: UINavigationController,
2830
storageManager: StorageManagerType = ServiceLocator.storageManager,
2931
stores: StoresManager = ServiceLocator.stores,
30-
analytics: Analytics = ServiceLocator.analytics) {
32+
analytics: Analytics = ServiceLocator.analytics,
33+
featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService) {
3134
self.source = source
3235
self.navigationController = navigationController
3336
// Passing the `standard` configuration to include sites without WooCommerce (`isWooCommerceActive = false`).
@@ -37,9 +40,17 @@ final class StoreCreationCoordinator: Coordinator {
3740
analytics: analytics)
3841
self.switchStoreUseCase = SwitchStoreUseCase(stores: stores, storageManager: storageManager)
3942
self.analytics = analytics
43+
self.featureFlagService = featureFlagService
4044
}
4145

4246
func start() {
47+
featureFlagService.isFeatureFlagEnabled(.storeCreationM2) ?
48+
startStoreCreationM2(): startStoreCreationM1()
49+
}
50+
}
51+
52+
private extension StoreCreationCoordinator {
53+
func startStoreCreationM1() {
4354
observeSiteURLsFromStoreCreation()
4455

4556
let viewModel = StoreCreationWebViewModel { [weak self] result in
@@ -52,14 +63,29 @@ final class StoreCreationCoordinator: Coordinator {
5263
// Disables interactive dismissal of the store creation modal.
5364
webNavigationController.isModalInPresentation = true
5465

66+
presentStoreCreation(viewController: webNavigationController)
67+
}
68+
69+
func startStoreCreationM2() {
70+
let domainSelector = DomainSelectorHostingController(viewModel: .init(),
71+
onDomainSelection: { domain in
72+
// TODO-8045: navigate to the next step of store creation.
73+
}, onSkip: {
74+
// TODO-8045: skip to the next step of store creation with an auto-generated domain.
75+
})
76+
let storeCreationNavigationController = UINavigationController(rootViewController: domainSelector)
77+
presentStoreCreation(viewController: storeCreationNavigationController)
78+
}
79+
80+
func presentStoreCreation(viewController: UIViewController) {
5581
// If the navigation controller is already presenting another view, the view needs to be dismissed before store
5682
// creation view can be presented.
5783
if navigationController.presentedViewController != nil {
5884
navigationController.dismiss(animated: true) { [weak self] in
59-
self?.navigationController.present(webNavigationController, animated: true)
85+
self?.navigationController.present(viewController, animated: true)
6086
}
6187
} else {
62-
navigationController.present(webNavigationController, animated: true)
88+
navigationController.present(viewController, animated: true)
6389
}
6490
}
6591
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import SwiftUI
2+
3+
/// View model for a row in a list of domain suggestions.
4+
struct DomainRowViewModel {
5+
/// The domain name is used for the selected state.
6+
let name: String
7+
/// Attributed name to be displayed in the row.
8+
let attributedName: AttributedString
9+
/// Whether the domain is selected.
10+
let isSelected: Bool
11+
12+
init(domainName: String, searchQuery: String, isSelected: Bool) {
13+
self.name = domainName
14+
self.isSelected = isSelected
15+
self.attributedName = {
16+
var attributedName = AttributedString(domainName)
17+
attributedName.font = isSelected ? .body.bold(): .body
18+
attributedName.foregroundColor = .init(.label)
19+
20+
if let rangeOfSearchQuery = attributedName
21+
.range(of: searchQuery
22+
// Removes leading/trailing spaces in the search query.
23+
.trimmingCharacters(in: .whitespacesAndNewlines)
24+
// Removes spaces in the search query.
25+
.split(separator: " ").joined()
26+
.lowercased()) {
27+
attributedName[rangeOfSearchQuery].font = .body
28+
attributedName[rangeOfSearchQuery].foregroundColor = .init(.secondaryLabel)
29+
}
30+
return attributedName
31+
}()
32+
}
33+
}
34+
35+
/// A row that shows an attributed domain name with a checkmark if the domain is selected.
36+
struct DomainRowView: View {
37+
let viewModel: DomainRowViewModel
38+
39+
var body: some View {
40+
HStack {
41+
Text(viewModel.attributedName)
42+
if viewModel.isSelected {
43+
Spacer()
44+
Image(uiImage: .checkmarkImage)
45+
.foregroundColor(Color(.brand))
46+
}
47+
}
48+
}
49+
}
50+
51+
struct DomainRowView_Previews: PreviewProvider {
52+
static var previews: some View {
53+
VStack(alignment: .leading) {
54+
DomainRowView(viewModel: .init(domainName: "whitechristmastrees.mywc.mysite", searchQuery: "White Christmas Trees", isSelected: true))
55+
DomainRowView(viewModel: .init(domainName: "whitechristmastrees.mywc.mysite", searchQuery: "White Christmas", isSelected: false))
56+
}
57+
}
58+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import SwiftUI
2+
3+
/// Hosting controller that wraps the `DomainSelectorView` view.
4+
final class DomainSelectorHostingController: UIHostingController<DomainSelectorView> {
5+
private let viewModel: DomainSelectorViewModel
6+
private let onDomainSelection: (String) -> Void
7+
private let onSkip: () -> Void
8+
9+
/// - Parameters:
10+
/// - viewModel: View model for the domain selector.
11+
/// - onDomainSelection: Called when the user continues with a selected domain name.
12+
/// - onSkip: Called when the user taps to skip domain selection.
13+
init(viewModel: DomainSelectorViewModel,
14+
onDomainSelection: @escaping (String) -> Void,
15+
onSkip: @escaping () -> Void) {
16+
self.viewModel = viewModel
17+
self.onDomainSelection = onDomainSelection
18+
self.onSkip = onSkip
19+
super.init(rootView: DomainSelectorView(viewModel: viewModel))
20+
21+
rootView.onDomainSelection = { [weak self] domain in
22+
self?.onDomainSelection(domain)
23+
}
24+
}
25+
26+
required dynamic init?(coder aDecoder: NSCoder) {
27+
fatalError("init(coder:) has not been implemented")
28+
}
29+
30+
override func viewDidLoad() {
31+
super.viewDidLoad()
32+
33+
configureSkipButton()
34+
configureNavigationBarAppearance()
35+
}
36+
}
37+
38+
private extension DomainSelectorHostingController {
39+
func configureSkipButton() {
40+
navigationItem.rightBarButtonItem = .init(title: Localization.skipButtonTitle, style: .plain, target: self, action: #selector(skipButtonTapped))
41+
}
42+
43+
/// Shows a transparent navigation bar without a bottom border.
44+
func configureNavigationBarAppearance() {
45+
let appearance = UINavigationBarAppearance()
46+
appearance.configureWithTransparentBackground()
47+
appearance.backgroundColor = UIColor.clear
48+
49+
navigationItem.standardAppearance = appearance
50+
navigationItem.scrollEdgeAppearance = appearance
51+
navigationItem.compactAppearance = appearance
52+
}
53+
}
54+
55+
private extension DomainSelectorHostingController {
56+
@objc func skipButtonTapped() {
57+
onSkip()
58+
}
59+
}
60+
61+
private extension DomainSelectorHostingController {
62+
enum Localization {
63+
static let skipButtonTitle = NSLocalizedString("Skip", comment: "Navigation bar button on the domain selector screen to skip domain selection.")
64+
}
65+
}
66+
67+
/// Allows the user to search for a domain and then select one to continue.
68+
struct DomainSelectorView: View {
69+
/// Set in the hosting controller.
70+
var onDomainSelection: ((String) -> Void) = { _ in }
71+
72+
/// View model to drive the view.
73+
@ObservedObject var viewModel: DomainSelectorViewModel
74+
75+
/// Currently selected domain name.
76+
/// If this property is kept in the view model, a SwiftUI error appears `Publishing changes from within view updates`
77+
/// when a domain row is selected.
78+
@State var selectedDomainName: String?
79+
80+
var body: some View {
81+
ScrollableVStack(alignment: .leading) {
82+
// Header labels.
83+
VStack(alignment: .leading, spacing: Layout.spacingBetweenTitleAndSubtitle) {
84+
Text(Localization.title)
85+
.titleStyle()
86+
Text(Localization.subtitle)
87+
.foregroundColor(Color(.secondaryLabel))
88+
.bodyStyle()
89+
}
90+
.padding(.horizontal, Layout.defaultHorizontalPadding)
91+
92+
SearchHeader(filterText: $viewModel.searchTerm,
93+
filterPlaceholder: Localization.searchPlaceholder)
94+
.padding(.horizontal, Layout.defaultHorizontalPadding)
95+
96+
Text(Localization.suggestionsHeader)
97+
.foregroundColor(Color(.secondaryLabel))
98+
.bodyStyle()
99+
.padding(.horizontal, Layout.defaultHorizontalPadding)
100+
101+
List(viewModel.domains, id: \.self) { domain in
102+
Button {
103+
selectedDomainName = domain
104+
} label: {
105+
DomainRowView(viewModel: .init(domainName: domain,
106+
searchQuery: viewModel.searchTerm,
107+
isSelected: domain == selectedDomainName))
108+
}
109+
}.listStyle(.inset)
110+
111+
if let selectedDomainName {
112+
Button(Localization.continueButtonTitle) {
113+
onDomainSelection(selectedDomainName)
114+
}
115+
.buttonStyle(PrimaryButtonStyle())
116+
}
117+
}
118+
}
119+
}
120+
121+
private extension DomainSelectorView {
122+
enum Layout {
123+
static let spacingBetweenTitleAndSubtitle: CGFloat = 16
124+
static let defaultHorizontalPadding: CGFloat = 16
125+
}
126+
127+
enum Localization {
128+
static let title = NSLocalizedString("Choose a domain", comment: "Title of the domain selector.")
129+
static let subtitle = NSLocalizedString(
130+
"This is where people will find you on the Internet. Don't worry, you can change it later.",
131+
comment: "Subtitle of the domain selector.")
132+
static let searchPlaceholder = NSLocalizedString("Type to get suggestions", comment: "Placeholder of the search text field on the domain selector.")
133+
static let suggestionsHeader = NSLocalizedString("SUGGESTIONS", comment: "Header label of the domain suggestions on the domain selector.")
134+
static let continueButtonTitle = NSLocalizedString("Continue", comment: "Title of the button to continue with a selected domain.")
135+
}
136+
}
137+
138+
struct DomainSelectorView_Previews: PreviewProvider {
139+
static var previews: some View {
140+
DomainSelectorView(viewModel: .init())
141+
}
142+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import Combine
2+
import SwiftUI
3+
import Yosemite
4+
5+
/// View model for `DomainSelectorView`.
6+
final class DomainSelectorViewModel: ObservableObject {
7+
/// Current search term entered by the user.
8+
/// Each update will trigger a remote call for domain suggestions.
9+
@Published var searchTerm: String = ""
10+
11+
/// Domain names after domain suggestions are loaded remotely.
12+
@Published private(set) var domains: [String] = []
13+
14+
/// Subscription for search query changes for domain search.
15+
private var searchQuerySubscription: AnyCancellable?
16+
17+
private let stores: StoresManager
18+
private let debounceDuration: Double
19+
20+
init(stores: StoresManager = ServiceLocator.stores,
21+
debounceDuration: Double = Constants.fieldDebounceDuration) {
22+
self.stores = stores
23+
self.debounceDuration = debounceDuration
24+
observeDomainQuery()
25+
}
26+
}
27+
28+
private extension DomainSelectorViewModel {
29+
func observeDomainQuery() {
30+
searchQuerySubscription = $searchTerm
31+
.filter { $0.isNotEmpty }
32+
.removeDuplicates()
33+
.debounce(for: .seconds(debounceDuration), scheduler: DispatchQueue.main)
34+
.sink { [weak self] searchTerm in
35+
guard let self = self else { return }
36+
Task { @MainActor in
37+
let result = await self.loadFreeDomainSuggestions(query: searchTerm)
38+
switch result {
39+
case .success(let suggestions):
40+
self.handleFreeDomainSuggestions(suggestions, query: searchTerm)
41+
case .failure(let error):
42+
self.handleError(error)
43+
}
44+
}
45+
}
46+
}
47+
48+
@MainActor
49+
func loadFreeDomainSuggestions(query: String) async -> Result<[FreeDomainSuggestion], Error> {
50+
await withCheckedContinuation { continuation in
51+
let action = DomainAction.loadFreeDomainSuggestions(query: searchTerm) { result in
52+
continuation.resume(returning: result)
53+
}
54+
stores.dispatch(action)
55+
}
56+
}
57+
58+
@MainActor
59+
func handleFreeDomainSuggestions(_ suggestions: [FreeDomainSuggestion], query: String) {
60+
domains = suggestions
61+
.filter { $0.isFree }
62+
.map {
63+
$0.name
64+
}
65+
}
66+
67+
@MainActor
68+
func handleError(_ error: Error) {
69+
// TODO-8045: error handling - maybe show an error message.
70+
DDLogError("Cannot load domain suggestions for \(searchTerm)")
71+
}
72+
}
73+
74+
private extension DomainSelectorViewModel {
75+
enum Constants {
76+
static let fieldDebounceDuration = 0.3
77+
}
78+
}

WooCommerce/Classes/Yosemite/AuthenticatedState.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ class AuthenticatedState: StoresManagerState {
3737
CouponStore(dispatcher: dispatcher, storageManager: storageManager, network: network),
3838
CustomerStore(dispatcher: dispatcher, storageManager: storageManager, network: network),
3939
DataStore(dispatcher: dispatcher, storageManager: storageManager, network: network),
40+
DomainStore(dispatcher: dispatcher, storageManager: storageManager, network: network),
4041
InAppPurchaseStore(dispatcher: dispatcher, storageManager: storageManager, network: network),
4142
InboxNotesStore(dispatcher: dispatcher, storageManager: storageManager, network: network),
4243
JustInTimeMessageStore(dispatcher: dispatcher, storageManager: storageManager, network: network),

0 commit comments

Comments
 (0)