Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
2 changes: 2 additions & 0 deletions Experiments/Experiments/DefaultFeatureFlagService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ public struct DefaultFeatureFlagService: FeatureFlagService {
return buildConfig == .localDeveloper || buildConfig == .alpha
case .storeCreationMVP:
return true
case .storeCreationM2:
return buildConfig == .localDeveloper || buildConfig == .alpha
case .justInTimeMessagesOnDashboard:
return true
case .productsOnboarding:
Expand Down
4 changes: 4 additions & 0 deletions Experiments/Experiments/FeatureFlag.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ public enum FeatureFlag: Int {
///
case storeCreationMVP

/// Store creation milestone 2. https://wp.me/pe5sF9-I3
///
case storeCreationM2

/// Just In Time Messages on Dashboard
///
case justInTimeMessagesOnDashboard
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Combine
import UIKit
import Yosemite
import protocol Experiments.FeatureFlagService
import protocol Storage.StorageManagerType

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

init(source: Source,
navigationController: UINavigationController,
storageManager: StorageManagerType = ServiceLocator.storageManager,
stores: StoresManager = ServiceLocator.stores,
analytics: Analytics = ServiceLocator.analytics) {
analytics: Analytics = ServiceLocator.analytics,
featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService) {
self.source = source
self.navigationController = navigationController
// Passing the `standard` configuration to include sites without WooCommerce (`isWooCommerceActive = false`).
Expand All @@ -37,9 +40,17 @@ final class StoreCreationCoordinator: Coordinator {
analytics: analytics)
self.switchStoreUseCase = SwitchStoreUseCase(stores: stores, storageManager: storageManager)
self.analytics = analytics
self.featureFlagService = featureFlagService
}

func start() {
featureFlagService.isFeatureFlagEnabled(.storeCreationM2) ?
startStoreCreationM2(): startStoreCreationM1()
}
}

private extension StoreCreationCoordinator {
func startStoreCreationM1() {
observeSiteURLsFromStoreCreation()

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

presentStoreCreation(viewController: webNavigationController)
}

func startStoreCreationM2() {
let domainSelector = DomainSelectorHostingController(viewModel: .init(),
onDomainSelection: { domain in
// TODO-8045: navigate to the next step of store creation.
}, onSkip: {
// TODO-8045: skip to the next step of store creation with an auto-generated domain.
})
let storeCreationNavigationController = UINavigationController(rootViewController: domainSelector)
presentStoreCreation(viewController: storeCreationNavigationController)
}

func presentStoreCreation(viewController: UIViewController) {
// If the navigation controller is already presenting another view, the view needs to be dismissed before store
// creation view can be presented.
if navigationController.presentedViewController != nil {
navigationController.dismiss(animated: true) { [weak self] in
self?.navigationController.present(webNavigationController, animated: true)
self?.navigationController.present(viewController, animated: true)
}
} else {
navigationController.present(webNavigationController, animated: true)
navigationController.present(viewController, animated: true)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import SwiftUI

/// View model for a row in a list of domain suggestions.
struct DomainRowViewModel {
/// The domain name is used for the selected state.
let name: String
/// Attributed name to be displayed in the row.
let attributedName: AttributedString
/// Whether the domain is selected.
let isSelected: Bool

init(domainName: String, searchQuery: String, isSelected: Bool) {
self.name = domainName
self.isSelected = isSelected
self.attributedName = {
var attributedName = AttributedString(domainName)
attributedName.font = isSelected ? .body.bold(): .body
attributedName.foregroundColor = .init(.label)

if let rangeOfSearchQuery = attributedName
.range(of: searchQuery
// Removes leading/trailing spaces in the search query.
.trimmingCharacters(in: .whitespacesAndNewlines)
// Removes spaces in the search query.
.split(separator: " ").joined()
.lowercased()) {
attributedName[rangeOfSearchQuery].font = .body
attributedName[rangeOfSearchQuery].foregroundColor = .init(.secondaryLabel)
}
return attributedName
}()
}
}

/// A row that shows an attributed domain name with a checkmark if the domain is selected.
struct DomainRowView: View {
let viewModel: DomainRowViewModel

var body: some View {
HStack {
Text(viewModel.attributedName)
if viewModel.isSelected {
Spacer()
Image(uiImage: .checkmarkImage)
.foregroundColor(Color(.brand))
}
}
}
}

struct DomainRowView_Previews: PreviewProvider {
static var previews: some View {
VStack(alignment: .leading) {
DomainRowView(viewModel: .init(domainName: "whitechristmastrees.mywc.mysite", searchQuery: "White Christmas Trees", isSelected: true))
DomainRowView(viewModel: .init(domainName: "whitechristmastrees.mywc.mysite", searchQuery: "White Christmas", isSelected: false))
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import SwiftUI

/// Hosting controller that wraps the `DomainSelectorView` view.
final class DomainSelectorHostingController: UIHostingController<DomainSelectorView> {
private let viewModel: DomainSelectorViewModel
private let onDomainSelection: (String) -> 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) -> Void,
onSkip: @escaping () -> Void) {
self.viewModel = viewModel
self.onDomainSelection = onDomainSelection
self.onSkip = onSkip
super.init(rootView: DomainSelectorView(viewModel: viewModel))

rootView.onDomainSelection = { [weak self] domain in
self?.onDomainSelection(domain)
}
}

required dynamic init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override func viewDidLoad() {
super.viewDidLoad()

configureSkipButton()
configureNavigationBarAppearance()
}
}

private extension DomainSelectorHostingController {
func configureSkipButton() {
navigationItem.rightBarButtonItem = .init(title: Localization.skipButtonTitle, style: .plain, target: self, action: #selector(skipButtonTapped))
}

/// Shows a transparent navigation bar without a bottom border.
func configureNavigationBarAppearance() {
let appearance = UINavigationBarAppearance()
appearance.configureWithTransparentBackground()
appearance.backgroundColor = UIColor.clear

navigationItem.standardAppearance = appearance
navigationItem.scrollEdgeAppearance = appearance
navigationItem.compactAppearance = appearance
}
}

private extension DomainSelectorHostingController {
@objc func skipButtonTapped() {
onSkip()
}
}

private extension DomainSelectorHostingController {
enum Localization {
static let skipButtonTitle = NSLocalizedString("Skip", comment: "Navigation bar button on the domain selector screen to skip domain selection.")
}
}

/// Allows the user to search for a domain and then select one to continue.
struct DomainSelectorView: View {
/// Set in the hosting controller.
var onDomainSelection: ((String) -> Void) = { _ in }

/// View model to drive the view.
@ObservedObject 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?

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))
}
}.listStyle(.inset)

if let selectedDomainName {
Button(Localization.continueButtonTitle) {
onDomainSelection(selectedDomainName)
}
.buttonStyle(PrimaryButtonStyle())
}
}
}
}

private extension DomainSelectorView {
enum Layout {
static let spacingBetweenTitleAndSubtitle: CGFloat = 16
static let defaultHorizontalPadding: CGFloat = 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.")
static let searchPlaceholder = NSLocalizedString("Type to get suggestions", 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.")
}
}

struct DomainSelectorView_Previews: PreviewProvider {
static var previews: some View {
DomainSelectorView(viewModel: .init())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import Combine
import SwiftUI
import Yosemite

/// View model for `DomainSelectorView`.
final class DomainSelectorViewModel: ObservableObject {
/// Current search term entered by the user.
/// Each update will trigger a remote call for domain suggestions.
@Published var searchTerm: String = ""

/// Domain names after domain suggestions are loaded remotely.
@Published private(set) var domains: [String] = []

/// 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,
debounceDuration: Double = Constants.fieldDebounceDuration) {
self.stores = stores
self.debounceDuration = debounceDuration
observeDomainQuery()
}
}

private extension DomainSelectorViewModel {
func observeDomainQuery() {
searchQuerySubscription = $searchTerm
.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
let result = await self.loadFreeDomainSuggestions(query: searchTerm)
switch result {
case .success(let suggestions):
self.handleFreeDomainSuggestions(suggestions, query: searchTerm)
case .failure(let error):
self.handleError(error)
}
}
}
}

@MainActor
func loadFreeDomainSuggestions(query: String) async -> Result<[FreeDomainSuggestion], Error> {
await withCheckedContinuation { continuation in
let action = DomainAction.loadFreeDomainSuggestions(query: searchTerm) { result in
continuation.resume(returning: result)
}
stores.dispatch(action)
}
}

@MainActor
func handleFreeDomainSuggestions(_ suggestions: [FreeDomainSuggestion], query: String) {
domains = suggestions
.filter { $0.isFree }
.map {
$0.name
}
}

@MainActor
func handleError(_ error: Error) {
// TODO-8045: error handling - maybe show an error message.
DDLogError("Cannot load domain suggestions for \(searchTerm)")
}
}

private extension DomainSelectorViewModel {
enum Constants {
static let fieldDebounceDuration = 0.3
}
}
1 change: 1 addition & 0 deletions WooCommerce/Classes/Yosemite/AuthenticatedState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class AuthenticatedState: StoresManagerState {
CouponStore(dispatcher: dispatcher, storageManager: storageManager, network: network),
CustomerStore(dispatcher: dispatcher, storageManager: storageManager, network: network),
DataStore(dispatcher: dispatcher, storageManager: storageManager, network: network),
DomainStore(dispatcher: dispatcher, storageManager: storageManager, network: network),
InAppPurchaseStore(dispatcher: dispatcher, storageManager: storageManager, network: network),
InboxNotesStore(dispatcher: dispatcher, storageManager: storageManager, network: network),
JustInTimeMessageStore(dispatcher: dispatcher, storageManager: storageManager, network: network),
Expand Down
Loading