diff --git a/WooCommerce/Classes/Authentication/Store Creation/StoreCreationCoordinator.swift b/WooCommerce/Classes/Authentication/Store Creation/StoreCreationCoordinator.swift index aa215895c2f..2fa289518b6 100644 --- a/WooCommerce/Classes/Authentication/Store Creation/StoreCreationCoordinator.swift +++ b/WooCommerce/Classes/Authentication/Store Creation/StoreCreationCoordinator.swift @@ -374,14 +374,14 @@ private extension StoreCreationCoordinator { categoryName: String?, countryCode: SiteAddress.CountryCode?, planToPurchase: WPComPlanProduct) { - let domainSelector = DomainSelectorHostingController(viewModel: .init(initialSearchTerm: storeName), - onDomainSelection: { [weak self] domain in + let domainSelector = FreeDomainSelectorHostingController(viewModel: .init(initialSearchTerm: storeName, dataProvider: FreeDomainSelectorDataProvider()), + onDomainSelection: { [weak self] domain in guard let self else { return } await self.createStoreAndContinueToStoreSummary(from: navigationController, name: storeName, categoryName: categoryName, countryCode: countryCode, - domain: domain, + domain: domain.name, planToPurchase: planToPurchase) }, onSupport: { [weak self] in self?.showSupport(from: navigationController) diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainRowView.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainRowView.swift index 5774564ef9b..f82350779f5 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainRowView.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainRowView.swift @@ -6,11 +6,14 @@ struct DomainRowViewModel { let name: String /// Attributed name to be displayed in the row. let attributedName: AttributedString + /// Attributed detail to be displayed in the row. + let attributedDetail: AttributedString? /// Whether the domain is selected. let isSelected: Bool - init(domainName: String, searchQuery: String, isSelected: Bool) { + init(domainName: String, attributedDetail: AttributedString?, searchQuery: String, isSelected: Bool) { self.name = domainName + self.attributedDetail = attributedDetail self.isSelected = isSelected self.attributedName = { var attributedName = AttributedString(domainName) @@ -38,7 +41,12 @@ struct DomainRowView: View { var body: some View { HStack { - Text(viewModel.attributedName) + VStack(alignment: .leading, spacing: Layout.spacingBetweenNameAndDetail) { + Text(viewModel.attributedName) + if let attributedDetail = viewModel.attributedDetail { + Text(attributedDetail) + } + } if viewModel.isSelected { Spacer() Image(uiImage: .checkmarkImage) @@ -52,14 +60,21 @@ struct DomainRowView: View { private extension DomainRowView { enum Layout { static let insets: EdgeInsets = .init(top: 10, leading: 16, bottom: 10, trailing: 16) + static let spacingBetweenNameAndDetail: CGFloat = 4 } } 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)) + DomainRowView(viewModel: .init(domainName: "whitechristmastrees.mywc.mysite", + attributedDetail: nil, + searchQuery: "White Christmas Trees", + isSelected: true)) + DomainRowView(viewModel: .init(domainName: "whitechristmastrees.mywc.mysite", + attributedDetail: nil, + searchQuery: "White Christmas", + isSelected: false)) } } } diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorDataProvider.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorDataProvider.swift new file mode 100644 index 00000000000..72b3ef11b69 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorDataProvider.swift @@ -0,0 +1,77 @@ +import Foundation +import Yosemite + +/// Provides domain suggestions data of a generic type. +/// The generic type allows different domain suggestion schemas, like free and paid domains. +protocol DomainSelectorDataProvider { + associatedtype DomainSuggestion + + /// Loads domain suggestions async from the remote. + /// - Parameter query: Search query for the domain suggestions. + /// - Returns: A list of domain suggestions. + func loadDomainSuggestions(query: String) async throws -> [DomainSuggestion] +} + +/// View model for free domain suggestion UI that shows the domain name. +struct FreeDomainSuggestionViewModel: DomainSuggestionViewProperties, Equatable { + let name: String + let attributedDetail: AttributedString? = nil + + init(domainSuggestion: FreeDomainSuggestion) { + self.name = domainSuggestion.name + } +} + +/// Provides domain suggestions that are free. +final class FreeDomainSelectorDataProvider: DomainSelectorDataProvider { + private let stores: StoresManager + + init(stores: StoresManager = ServiceLocator.stores) { + self.stores = stores + } + + @MainActor + func loadDomainSuggestions(query: String) async throws -> [FreeDomainSuggestionViewModel] { + try await withCheckedThrowingContinuation { continuation in + stores.dispatch(DomainAction.loadFreeDomainSuggestions(query: query) { result in + continuation.resume(with: result.map { $0 + .filter { $0.isFree } + .map { FreeDomainSuggestionViewModel(domainSuggestion: $0) } + }) + }) + } + } +} + +/// View model for paid domain suggestion UI that shows the domain name and attributed price info. +/// The product ID is for creating a cart after a domain is selected. +struct PaidDomainSuggestionViewModel: DomainSuggestionViewProperties, Equatable { + let name: String + let attributedDetail: AttributedString? + let productID: Int64 + + init(domainSuggestion: PaidDomainSuggestion) { + self.name = domainSuggestion.name + // TODO: 8558 - attributed price info + self.attributedDetail = .init("\(domainSuggestion.saleCost ?? "no sale") / \(domainSuggestion.cost) / \(domainSuggestion.term)") + self.productID = domainSuggestion.productID + } +} + +/// Provides domain suggestions that are paid. +final class PaidDomainSelectorDataProvider: DomainSelectorDataProvider { + private let stores: StoresManager + + init(stores: StoresManager = ServiceLocator.stores) { + self.stores = stores + } + + @MainActor + func loadDomainSuggestions(query: String) async throws -> [PaidDomainSuggestionViewModel] { + try await withCheckedThrowingContinuation { continuation in + stores.dispatch(DomainAction.loadPaidDomainSuggestions(query: query) { result in + continuation.resume(with: result.map { $0.map { PaidDomainSuggestionViewModel(domainSuggestion: $0) } }) + }) + } + } +} diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorView.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorView.swift index e8e8b722070..b140988f613 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorView.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorView.swift @@ -1,20 +1,17 @@ import SwiftUI -/// Hosting controller that wraps the `DomainSelectorView` view. -final class DomainSelectorHostingController: UIHostingController { - private let viewModel: DomainSelectorViewModel - +/// Hosting controller that wraps the `DomainSelectorView` view with free domains. +final class FreeDomainSelectorHostingController: UIHostingController> { /// - Parameters: /// - viewModel: View model for the domain selector. - /// - onDomainSelection: Called when the user continues with a selected domain name. + /// - onDomainSelection: Called when the user continues with a selected domain. /// - onSupport: Called when the user taps to contact support. - init(viewModel: DomainSelectorViewModel, - onDomainSelection: @escaping (String) async -> Void, + init(viewModel: DomainSelectorViewModel, + onDomainSelection: @escaping (FreeDomainSuggestionViewModel) async -> Void, onSupport: @escaping () -> Void) { - self.viewModel = viewModel - super.init(rootView: DomainSelectorView(viewModel: viewModel, - onDomainSelection: onDomainSelection, - onSupport: onSupport)) + super.init(rootView: DomainSelectorView(viewModel: viewModel, + onDomainSelection: onDomainSelection, + onSupport: onSupport)) } required dynamic init?(coder aDecoder: NSCoder) { @@ -24,54 +21,56 @@ final class DomainSelectorHostingController: UIHostingController> { + /// - Parameters: + /// - viewModel: View model for the domain selector. + /// - onDomainSelection: Called when the user continues with a selected domain. + /// - onSupport: Called when the user taps to contact support. + init(viewModel: DomainSelectorViewModel, + onDomainSelection: @escaping (PaidDomainSuggestionViewModel) async -> Void, + onSupport: @escaping () -> Void) { + super.init(rootView: DomainSelectorView(viewModel: viewModel, + onDomainSelection: onDomainSelection, + onSupport: onSupport)) + } - navigationItem.standardAppearance = appearance - navigationItem.scrollEdgeAppearance = appearance - navigationItem.compactAppearance = appearance + required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") } -} -/// Allows the user to search for a domain and then select one to continue. -struct DomainSelectorView: View { - /// The state of the main view below the fixed header. - enum ViewState: Equatable { - /// When loading domain suggestions. - case loading - /// Shown when the search query is empty. - case placeholder - /// When there is an error loading domain suggestions. - case error(message: String) - /// When domain suggestions are displayed. - case results(domains: [String]) + override func viewDidLoad() { + super.viewDidLoad() + + configureTransparentNavigationBar() } +} - private let onDomainSelection: (String) async -> Void +/// Allows the user to search for a domain and then select one to continue. +struct DomainSelectorView: View +where DataProvider.DomainSuggestion == DomainSuggestion { + private let onDomainSelection: (DomainSuggestion) async -> Void private let onSupport: () -> Void /// View model to drive the view. - @ObservedObject private var viewModel: DomainSelectorViewModel + @ObservedObject private var viewModel: DomainSelectorViewModel - /// Currently selected domain name. + /// Currently selected domain. /// 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 private var selectedDomainName: String? + @State private var selectedDomain: DomainSuggestion? @State private var isWaitingForDomainSelectionCompletion: Bool = false @FocusState private var textFieldIsFocused: Bool - init(viewModel: DomainSelectorViewModel, - onDomainSelection: @escaping (String) async -> Void, + init(viewModel: DomainSelectorViewModel, + onDomainSelection: @escaping (DomainSuggestion) async -> Void, onSupport: @escaping () -> Void) { self.viewModel = viewModel self.onDomainSelection = onDomainSelection @@ -142,17 +141,19 @@ struct DomainSelectorView: View { .padding(.vertical, insets: .init(top: 14, leading: 0, bottom: 8, trailing: 0)) LazyVStack { - ForEach(domains, id: \.self) { domain in + ForEach(domains, id: \.name) { domain in Button { textFieldIsFocused = false - selectedDomainName = domain + selectedDomain = domain } label: { VStack(alignment: .leading) { - DomainRowView(viewModel: .init(domainName: domain, - searchQuery: viewModel.searchTerm, - isSelected: domain == selectedDomainName)) + DomainRowView(viewModel: + .init(domainName: domain.name, + attributedDetail: domain.attributedDetail, + searchQuery: viewModel.searchTerm, + isSelected: domain == selectedDomain)) Divider() - .frame(height: Layout.dividerHeight) + .dividerStyle() .padding(.leading, Layout.defaultHorizontalPadding) } } @@ -163,15 +164,14 @@ struct DomainSelectorView: View { } .safeAreaInset(edge: .bottom) { // Continue button when a domain is selected. - if let selectedDomainName { + if let selectedDomain { VStack { Divider() - .frame(height: Layout.dividerHeight) - .foregroundColor(Color(.separator)) + .dividerStyle() Button(Localization.continueButtonTitle) { Task { @MainActor in isWaitingForDomainSelectionCompletion = true - await onDomainSelection(selectedDomainName) + await onDomainSelection(selectedDomain) isWaitingForDomainSelectionCompletion = false } } @@ -193,27 +193,37 @@ struct DomainSelectorView: View { .onChange(of: viewModel.isLoadingDomainSuggestions) { isLoadingDomainSuggestions in // Resets selected domain when loading domain suggestions. if isLoadingDomainSuggestions { - selectedDomainName = nil + selectedDomain = nil } } } } +/// Constants are computed static properties since stored properties are not supported in generic types. private extension DomainSelectorView { enum Layout { - static let defaultHorizontalPadding: CGFloat = 16 - static let dividerHeight: CGFloat = 1 - static let defaultPadding: EdgeInsets = .init(top: 10, leading: 16, bottom: 10, trailing: 16) + static var defaultHorizontalPadding: CGFloat { 16 } + static var 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. You can add another domain later.", - comment: "Subtitle of 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.") + static var title: String { + NSLocalizedString("Choose a domain", comment: "Title of the domain selector.") + } + static var subtitle: String { + NSLocalizedString( + "This is where people will find you on the Internet. You can add another domain later.", + comment: "Subtitle of the domain selector.") + } + static var searchPlaceholder: String { + NSLocalizedString("Type a name for your store", comment: "Placeholder of the search text field on the domain selector.") + } + static var suggestionsHeader: String { + NSLocalizedString("SUGGESTIONS", comment: "Header label of the domain suggestions on the domain selector.") + } + static var continueButtonTitle: String { + NSLocalizedString("Continue", comment: "Title of the button to continue with a selected domain.") + } } } @@ -224,18 +234,25 @@ import enum Networking.DotcomError /// StoresManager that specifically handles `DomainAction` for `DomainSelectorView` previews. final class DomainSelectorViewStores: DefaultStoresManager { - private let result: Result<[FreeDomainSuggestion], Error>? + private let freeDomainsResult: Result<[FreeDomainSuggestion], Error>? + private let paidDomainsResult: Result<[PaidDomainSuggestion], Error>? - init(result: Result<[FreeDomainSuggestion], Error>?) { - self.result = result + init(freeDomainsResult: Result<[FreeDomainSuggestion], Error>? = nil, + paidDomainsResult: Result<[PaidDomainSuggestion], Error>? = nil) { + self.freeDomainsResult = freeDomainsResult + self.paidDomainsResult = paidDomainsResult 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) + if let freeDomainsResult { + completion(freeDomainsResult) + } + } else if case let .loadPaidDomainSuggestions(_, completion) = action { + if let paidDomainsResult { + completion(paidDomainsResult) } } } @@ -246,39 +263,75 @@ struct DomainSelectorView_Previews: PreviewProvider { static var previews: some View { Group { // Empty query state. - DomainSelectorView(viewModel: - .init(initialSearchTerm: "", - stores: DomainSelectorViewStores(result: nil)), - onDomainSelection: { _ in }, - onSupport: {}) - // 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) - ]))), - onDomainSelection: { _ in }, - onSupport: {}) + DomainSelectorView( + viewModel: + .init(initialSearchTerm: "", + dataProvider: FreeDomainSelectorDataProvider( + stores: DomainSelectorViewStores() + )), + onDomainSelection: { _ in }, + onSupport: {} + ) + // Results state for free domains. + DomainSelectorView( + viewModel: + .init(initialSearchTerm: "", + dataProvider: FreeDomainSelectorDataProvider( + stores: DomainSelectorViewStores(freeDomainsResult: .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) + ])) + )), + onDomainSelection: { _ in }, + onSupport: {} + ) + // Results state for paid domains. + DomainSelectorView( + viewModel: + .init(initialSearchTerm: "", + dataProvider: PaidDomainSelectorDataProvider( + stores: DomainSelectorViewStores(paidDomainsResult: .success([ + .init(productID: 1, + name: "grapefruitsmoothie.com", + term: "year", + cost: "NT$154.00"), + .init(productID: 2, + name: "fruitsmoothie.com", + term: "year", + cost: "NT$610.00", + saleCost: "NT$154.00") + ])) + )), + onDomainSelection: { _ in }, + onSupport: {} + ) // 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.") - ))), - onDomainSelection: { _ in }, - onSupport: {}) + DomainSelectorView( + viewModel: + .init(initialSearchTerm: "test", + dataProvider: + FreeDomainSelectorDataProvider( + stores: DomainSelectorViewStores(freeDomainsResult: + .failure(DotcomError.unknown(code: "invalid_query", + message: "Domain searches must contain a word with the following characters.") + )))), + onDomainSelection: { _ in }, + onSupport: {} + ) // Loading state. - DomainSelectorView(viewModel: - .init(initialSearchTerm: "test", - stores: DomainSelectorViewStores(result: nil)), - onDomainSelection: { _ in }, - onSupport: {}) + DomainSelectorView( + viewModel: + .init(initialSearchTerm: "", + dataProvider: FreeDomainSelectorDataProvider( + stores: DomainSelectorViewStores() + )), + onDomainSelection: { _ in }, + onSupport: {} + ) } } } diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorViewModel.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorViewModel.swift index 84f16e2c9a1..15116d0ee4e 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorViewModel.swift @@ -3,14 +3,36 @@ import SwiftUI import Yosemite import enum Networking.DotcomError +/// Properties that are necessary for displaying a domain suggestion in the UI. +protocol DomainSuggestionViewProperties { + /// Domain name. + var name: String { get } + /// Optional attributed detail, e.g. price info for paid domains. + var attributedDetail: AttributedString? { get } +} + /// View model for `DomainSelectorView`. -final class DomainSelectorViewModel: ObservableObject { +final class DomainSelectorViewModel: ObservableObject +where DataProvider.DomainSuggestion == DomainSuggestion { + /// The state of the main view below the fixed header. + enum ViewState: Equatable { + /// When loading domain suggestions. + case loading + /// Shown when the search query is empty. + case placeholder + /// When there is an error loading domain suggestions. + case error(message: String) + /// When domain suggestions are displayed. + case results(domains: [DomainSuggestion]) + } + /// 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 var domains: [String] = [] + @Published private var domains: [DomainSuggestion] = [] /// Error message from loading domain suggestions. @Published private var errorMessage: String? @@ -19,18 +41,18 @@ final class DomainSelectorViewModel: ObservableObject { @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 + @Published private(set) var state: ViewState = .placeholder /// Subscription for search query changes for domain search. private var searchQuerySubscription: AnyCancellable? - private let stores: StoresManager + private let dataProvider: DataProvider private let debounceDuration: Double init(initialSearchTerm: String = "", - stores: StoresManager = ServiceLocator.stores, + dataProvider: DataProvider, debounceDuration: Double = Constants.fieldDebounceDuration) { - self.stores = stores + self.dataProvider = dataProvider self.debounceDuration = debounceDuration // Sets the initial search term after related subscriptions are set up @@ -66,9 +88,8 @@ private extension DomainSelectorViewModel { self.isLoadingDomainSuggestions = true do { - let suggestions = try await self.loadFreeDomainSuggestions(query: searchTerm) + self.domains = try await self.loadDomainSuggestions(query: searchTerm) self.isLoadingDomainSuggestions = false - self.handleFreeDomainSuggestions(suggestions, query: searchTerm) } catch { self.isLoadingDomainSuggestions = false self.handleError(error) @@ -94,28 +115,14 @@ private extension DomainSelectorViewModel { } @MainActor - func loadFreeDomainSuggestions(query: String) async throws -> [FreeDomainSuggestion] { - try await withCheckedThrowingContinuation { continuation in - let action = DomainAction.loadFreeDomainSuggestions(query: searchTerm) { result in - continuation.resume(with: result) - } - stores.dispatch(action) - } - } - - @MainActor - func handleFreeDomainSuggestions(_ suggestions: [FreeDomainSuggestion], query: String) { - domains = suggestions - .filter { $0.isFree } - .map { - $0.name - } + func loadDomainSuggestions(query: String) async throws -> [DomainSuggestion] { + try await dataProvider.loadDomainSuggestions(query: query) } @MainActor func handleError(_ error: Error) { if let dotcomError = error as? DotcomError, - case let .unknown(_, message) = dotcomError { + case let .unknown(_, message) = dotcomError { errorMessage = message } else { errorMessage = Localization.defaultErrorMessage @@ -126,14 +133,17 @@ private extension DomainSelectorViewModel { private extension DomainSelectorViewModel { enum Constants { - static let fieldDebounceDuration = 0.3 + static var fieldDebounceDuration: Double { + 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.") + static var defaultErrorMessage: String { + NSLocalizedString("Please try another query.", + comment: "Default message when there is an unexpected error loading domain suggestions on the domain selector.") + } } } diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSettingsCoordinator.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSettingsCoordinator.swift new file mode 100644 index 00000000000..fa3e1cc4def --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSettingsCoordinator.swift @@ -0,0 +1,50 @@ +import Combine +import UIKit +import Yosemite + +/// Coordinates navigation for domain settings flow. +final class DomainSettingsCoordinator: Coordinator { + /// Navigation source to domain settings. + enum Source { + /// Initiated from the settings. + case settings + } + + let navigationController: UINavigationController + + private let site: Site + private let stores: StoresManager + private let source: Source + + init(source: Source, + site: Site, + navigationController: UINavigationController, + stores: StoresManager = ServiceLocator.stores) { + self.source = source + self.site = site + self.navigationController = navigationController + self.stores = stores + } + + func start() { + let settingsNavigationController = WooNavigationController() + let domainSettings = DomainSettingsHostingController(viewModel: .init(siteID: site.siteID, + stores: stores)) { [weak self] in + self?.showDomainSelector(from: settingsNavigationController) + } + settingsNavigationController.pushViewController(domainSettings, animated: false) + navigationController.present(settingsNavigationController, animated: true) + } +} + +private extension DomainSettingsCoordinator { + func showDomainSelector(from navigationController: UINavigationController) { + let viewModel = DomainSelectorViewModel(initialSearchTerm: site.name, dataProvider: PaidDomainSelectorDataProvider()) + let domainSelector = PaidDomainSelectorHostingController(viewModel: viewModel) { domain in + print("\(domain) - \(domain.productID)") + } onSupport: { + // TODO: 8558 - remove support action + } + navigationController.show(domainSelector, sender: nil) + } +} diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSettingsView.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSettingsView.swift index 43233a2db25..f0726cc0df4 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSettingsView.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSettingsView.swift @@ -2,8 +2,9 @@ import SwiftUI /// Hosting controller that wraps the `DomainSettingsView` view. final class DomainSettingsHostingController: UIHostingController { - init(viewModel: DomainSettingsViewModel) { - super.init(rootView: DomainSettingsView(viewModel: viewModel)) + init(viewModel: DomainSettingsViewModel, addDomain: @escaping () -> Void) { + super.init(rootView: DomainSettingsView(viewModel: viewModel, + addDomain: addDomain)) } required dynamic init?(coder aDecoder: NSCoder) { @@ -20,9 +21,11 @@ final class DomainSettingsHostingController: UIHostingController Void - init(viewModel: DomainSettingsViewModel) { + init(viewModel: DomainSettingsViewModel, addDomain: @escaping () -> Void) { self.viewModel = viewModel + self.addDomain = addDomain } var body: some View { @@ -42,7 +45,7 @@ struct DomainSettingsView: View { if viewModel.domains.isNotEmpty { DomainSettingsListView(domains: viewModel.domains) { - // TODO: 8558 - search domain action + addDomain() } } } @@ -55,7 +58,7 @@ struct DomainSettingsView: View { .frame(height: Layout.dividerHeight) .foregroundColor(Color(.separator)) Button(Localization.searchDomainButton) { - // TODO: 8558 - search domain action + addDomain() } .buttonStyle(PrimaryButtonStyle()) .padding(Layout.bottomContentPadding) @@ -135,7 +138,8 @@ struct DomainSettingsView_Previews: PreviewProvider { .init(name: "duo.test", isPrimary: true, renewalDate: .now) ]), // The site has domain credit. - sitePlanResult: .success(.init(hasDomainCredit: true))))) + sitePlanResult: .success(.init(hasDomainCredit: true)))), + addDomain: {}) } NavigationView { @@ -146,7 +150,8 @@ struct DomainSettingsView_Previews: PreviewProvider { domainsResult: .success([ .init(name: "free.test", isPrimary: true) ]), - sitePlanResult: .success(.init(hasDomainCredit: true))))) + sitePlanResult: .success(.init(hasDomainCredit: true)))), + addDomain: {}) } } } diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Settings/SettingsViewController.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Settings/SettingsViewController.swift index 1c606114566..87fd88362d2 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Settings/SettingsViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Settings/SettingsViewController.swift @@ -29,6 +29,8 @@ final class SettingsViewController: UIViewController { /// private var storePickerCoordinator: StorePickerCoordinator? + private var domainSettingsCoordinator: DomainSettingsCoordinator? + private lazy var closeAccountCoordinator: CloseAccountCoordinator = CloseAccountCoordinator(sourceViewController: self) { [weak self] in guard let self = self else { throw CloseAccountError.presenterDeallocated } @@ -343,15 +345,15 @@ private extension SettingsViewController { } func domainWasPressed() { - guard let site = ServiceLocator.stores.sessionManager.defaultSite else { + guard let site = ServiceLocator.stores.sessionManager.defaultSite, let navigationController else { return } // TODO: 8558 - analytics - let domainSettings = DomainSettingsHostingController(viewModel: .init(siteID: site.siteID)) - let navigationController = WooNavigationController(rootViewController: domainSettings) - present(navigationController, animated: true) + let coordinator = DomainSettingsCoordinator(source: .settings, site: site, navigationController: navigationController) + domainSettingsCoordinator = coordinator + coordinator.start() } func installJetpackWasPressed() { diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index aef3d8cfaab..304843d0e7d 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -198,6 +198,7 @@ 02524A5D252ED5C60033E7BD /* ProductVariationLoadUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02524A5C252ED5C60033E7BD /* ProductVariationLoadUseCaseTests.swift */; }; 02535CBB25823F7A00E137BB /* ShippingLabelPaperSize+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02535CBA25823F7A00E137BB /* ShippingLabelPaperSize+UI.swift */; }; 02562AD0296D1FD100980404 /* View+DividerStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02562ACF296D1FD100980404 /* View+DividerStyle.swift */; }; + 02562AD2296D293D00980404 /* DomainSettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02562AD1296D293D00980404 /* DomainSettingsCoordinator.swift */; }; 02564A88246C047C00D6DB2A /* Optional+StringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02564A87246C047C00D6DB2A /* Optional+StringTests.swift */; }; 02564A8A246CDF6100D6DB2A /* ProductsTopBannerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02564A89246CDF6100D6DB2A /* ProductsTopBannerFactory.swift */; }; 02564A8C246CE38E00D6DB2A /* SwappableSubviewContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02564A8B246CE38E00D6DB2A /* SwappableSubviewContainerView.swift */; }; @@ -291,6 +292,7 @@ 027D67D1245ADDF40036B8DB /* FilterTypeViewModel+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027D67D0245ADDF40036B8DB /* FilterTypeViewModel+Helpers.swift */; }; 027F240C258371150021DB06 /* RefundShippingLabelViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027F240B258371150021DB06 /* RefundShippingLabelViewModelTests.swift */; }; 02817B39242B34560050AD8B /* ToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02817B38242B34560050AD8B /* ToolbarView.swift */; }; + 028203CF297662A200217369 /* DomainSelectorDataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028203CE297662A200217369 /* DomainSelectorDataProvider.swift */; }; 02820F3422C257B700DE0D37 /* UITableView+HeaderFooterHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02820F3322C257B700DE0D37 /* UITableView+HeaderFooterHelpers.swift */; }; 028296EC237D28B600E84012 /* TextViewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028296EA237D28B600E84012 /* TextViewViewController.swift */; }; 028296ED237D28B600E84012 /* TextViewViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 028296EB237D28B600E84012 /* TextViewViewController.xib */; }; @@ -2268,6 +2270,7 @@ 02524A5C252ED5C60033E7BD /* ProductVariationLoadUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductVariationLoadUseCaseTests.swift; sourceTree = ""; }; 02535CBA25823F7A00E137BB /* ShippingLabelPaperSize+UI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ShippingLabelPaperSize+UI.swift"; sourceTree = ""; }; 02562ACF296D1FD100980404 /* View+DividerStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+DividerStyle.swift"; sourceTree = ""; }; + 02562AD1296D293D00980404 /* DomainSettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainSettingsCoordinator.swift; sourceTree = ""; }; 02564A87246C047C00D6DB2A /* Optional+StringTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+StringTests.swift"; sourceTree = ""; }; 02564A89246CDF6100D6DB2A /* ProductsTopBannerFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsTopBannerFactory.swift; sourceTree = ""; }; 02564A8B246CE38E00D6DB2A /* SwappableSubviewContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwappableSubviewContainerView.swift; sourceTree = ""; }; @@ -2362,6 +2365,7 @@ 027D67D0245ADDF40036B8DB /* FilterTypeViewModel+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FilterTypeViewModel+Helpers.swift"; sourceTree = ""; }; 027F240B258371150021DB06 /* RefundShippingLabelViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefundShippingLabelViewModelTests.swift; sourceTree = ""; }; 02817B38242B34560050AD8B /* ToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolbarView.swift; sourceTree = ""; }; + 028203CE297662A200217369 /* DomainSelectorDataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainSelectorDataProvider.swift; sourceTree = ""; }; 02820F3322C257B700DE0D37 /* UITableView+HeaderFooterHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITableView+HeaderFooterHelpers.swift"; sourceTree = ""; }; 028296EA237D28B600E84012 /* TextViewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextViewViewController.swift; sourceTree = ""; }; 028296EB237D28B600E84012 /* TextViewViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TextViewViewController.xib; sourceTree = ""; }; @@ -4551,6 +4555,8 @@ 02C37B7C2967B72A00F0CF9E /* FreeStagingDomainView.swift */, 02DE39D82968647100BB31D4 /* DomainSettingsViewModel.swift */, 02B41A95296D09D100FE3311 /* DomainSettingsListView.swift */, + 02562AD1296D293D00980404 /* DomainSettingsCoordinator.swift */, + 028203CE297662A200217369 /* DomainSelectorDataProvider.swift */, ); path = Domains; sourceTree = ""; @@ -10264,6 +10270,7 @@ E1325EFB28FD544E00EC9B2A /* InAppPurchasesDebugView.swift in Sources */, 74460D4022289B7600D7316A /* Coordinator.swift in Sources */, B57C743D20F5493300EEFC87 /* AccountHeaderView.swift in Sources */, + 02562AD2296D293D00980404 /* DomainSettingsCoordinator.swift in Sources */, 03E471D2293FA8B2001A58AD /* BluetoothCardReaderPaymentAlertsProvider.swift in Sources */, 03E471CA293E0A30001A58AD /* CardPresentModalBuiltInConfigurationProgress.swift in Sources */, 31AD0B1126E9575F000B6391 /* CardPresentModalConnectingFailed.swift in Sources */, @@ -11054,6 +11061,7 @@ 03EF24FA28BF5D21006A033E /* InPersonPaymentsCashOnDeliveryToggleRowViewModel.swift in Sources */, 09EA565527C8ACEE00407D40 /* BulkUpdateViewController.swift in Sources */, 2602A64627BDBEBA00B347F1 /* ProductInputTransformer.swift in Sources */, + 028203CF297662A200217369 /* DomainSelectorDataProvider.swift in Sources */, DE74F2A327E41D650002FE59 /* EnableAnalyticsViewModel.swift in Sources */, AE77EA5027A47C99006A21BD /* View+AddingDividers.swift in Sources */, 0298430C259351F100979CAE /* ShippingLabelsTopBannerFactory.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/ViewModels/Domains/DomainSelectorViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewModels/Domains/DomainSelectorViewModelTests.swift index 5fa244ca22e..3f632a7c80a 100644 --- a/WooCommerce/WooCommerceTests/ViewModels/Domains/DomainSelectorViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewModels/Domains/DomainSelectorViewModelTests.swift @@ -5,14 +5,15 @@ import enum Networking.DotcomError @testable import WooCommerce final class DomainSelectorViewModelTests: XCTestCase { + typealias ViewModel = DomainSelectorViewModel private var stores: MockStoresManager! - private var viewModel: DomainSelectorViewModel! + private var viewModel: ViewModel! private var subscriptions: Set = [] override func setUp() { super.setUp() stores = MockStoresManager(sessionManager: SessionManager.makeForTesting()) - viewModel = .init(stores: stores, debounceDuration: 0) + viewModel = .init(dataProvider: FreeDomainSelectorDataProvider(stores: stores), debounceDuration: 0) } override func tearDown() { @@ -74,7 +75,7 @@ final class DomainSelectorViewModelTests: XCTestCase { // Then waitUntil { - self.viewModel.state == .results(domains: ["free.com"]) + self.viewModel.state == .results(domains: [.init(domainSuggestion: .init(name: "free.com", isFree: true))]) } } @@ -87,7 +88,7 @@ final class DomainSelectorViewModelTests: XCTestCase { // Then waitUntil { - self.viewModel.state == .error(message: DomainSelectorViewModel.Localization.defaultErrorMessage) + self.viewModel.state == .error(message: ViewModel.Localization.defaultErrorMessage) } } @@ -111,7 +112,7 @@ final class DomainSelectorViewModelTests: XCTestCase { // When viewModel.searchTerm = "woo" waitUntil { - self.viewModel.state == .error(message: DomainSelectorViewModel.Localization.defaultErrorMessage) + self.viewModel.state == .error(message: ViewModel.Localization.defaultErrorMessage) } mockDomainSuggestionsSuccess(suggestions: []) diff --git a/Yosemite/Yosemite/Actions/DomainAction.swift b/Yosemite/Yosemite/Actions/DomainAction.swift index 3bd77b0e9c4..c5748571fa4 100644 --- a/Yosemite/Yosemite/Actions/DomainAction.swift +++ b/Yosemite/Yosemite/Actions/DomainAction.swift @@ -20,4 +20,12 @@ public struct PaidDomainSuggestion: Equatable { public let cost: String /// Optional sale cost string including the currency. public let saleCost: String? + + public init(productID: Int64, name: String, term: String, cost: String, saleCost: String? = nil) { + self.productID = productID + self.name = name + self.term = term + self.cost = cost + self.saleCost = saleCost + } }