Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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