diff --git a/WooCommerce/Classes/Authentication/Store Creation/StoreCreationCoordinator.swift b/WooCommerce/Classes/Authentication/Store Creation/StoreCreationCoordinator.swift index ac7a79f0cf0..3650f57e64e 100644 --- a/WooCommerce/Classes/Authentication/Store Creation/StoreCreationCoordinator.swift +++ b/WooCommerce/Classes/Authentication/Store Creation/StoreCreationCoordinator.swift @@ -19,6 +19,7 @@ final class StoreCreationCoordinator: Coordinator { @Published private var possibleSiteURLsFromStoreCreation: Set = [] private var possibleSiteURLsFromStoreCreationSubscription: AnyCancellable? + private let stores: StoresManager private let analytics: Analytics private let source: Source private let storePickerViewModel: StorePickerViewModel @@ -39,6 +40,7 @@ final class StoreCreationCoordinator: Coordinator { storageManager: storageManager, analytics: analytics) self.switchStoreUseCase = SwitchStoreUseCase(stores: stores, storageManager: storageManager) + self.stores = stores self.analytics = analytics self.featureFlagService = featureFlagService } @@ -67,14 +69,18 @@ private extension StoreCreationCoordinator { } func startStoreCreationM2() { + let storeCreationNavigationController = UINavigationController() + storeCreationNavigationController.navigationBar.prefersLargeTitles = true + let domainSelector = DomainSelectorHostingController(viewModel: .init(), - onDomainSelection: { domain in - // TODO-8045: navigate to the next step of store creation. + onDomainSelection: { [weak self] domain in + guard let self else { return } + // TODO: add a store name screen before the domain selector screen. + await self.createStoreAndContinueToStoreSummary(from: storeCreationNavigationController, name: "Test store", domain: domain) }, onSkip: { // TODO-8045: skip to the next step of store creation with an auto-generated domain. }) - let storeCreationNavigationController = UINavigationController(rootViewController: domainSelector) - storeCreationNavigationController.navigationBar.prefersLargeTitles = true + storeCreationNavigationController.pushViewController(domainSelector, animated: false) presentStoreCreation(viewController: storeCreationNavigationController) } @@ -91,6 +97,8 @@ private extension StoreCreationCoordinator { } } +// MARK: - Store creation M1 + private extension StoreCreationCoordinator { func observeSiteURLsFromStoreCreation() { possibleSiteURLsFromStoreCreationSubscription = $possibleSiteURLsFromStoreCreation @@ -181,6 +189,57 @@ private extension StoreCreationCoordinator { } } +// MARK: - Store creation M2 + +private extension StoreCreationCoordinator { + @MainActor + func createStoreAndContinueToStoreSummary(from navigationController: UINavigationController, name: String, domain: String) async { + let result = await createStore(name: name, domain: domain) + switch result { + case .success(let siteResult): + showStoreSummary(from: navigationController, result: siteResult) + case .failure(let error): + showStoreCreationErrorAlert(from: navigationController, error: error) + } + } + + @MainActor + func createStore(name: String, domain: String) async -> Result { + await withCheckedContinuation { continuation in + stores.dispatch(SiteAction.createSite(name: name, domain: domain) { result in + continuation.resume(returning: result) + }) + } + } + + @MainActor + func showStoreSummary(from navigationController: UINavigationController, result: SiteCreationResult) { + let viewModel = StoreCreationSummaryViewModel(storeName: result.name, storeSlug: result.siteSlug) + let storeSummary = StoreCreationSummaryHostingController(viewModel: viewModel) { + // TODO: 8108 - integrate IAP. + } + navigationController.pushViewController(storeSummary, animated: true) + } + + @MainActor + func showStoreCreationErrorAlert(from navigationController: UINavigationController, error: SiteCreationError) { + let message: String = { + switch error { + case .invalidDomain, .domainExists: + return Localization.StoreCreationErrorAlert.domainErrorMessage + default: + return Localization.StoreCreationErrorAlert.defaultErrorMessage + } + }() + let alertController = UIAlertController(title: Localization.StoreCreationErrorAlert.title, + message: message, + preferredStyle: .alert) + alertController.view.tintColor = .text + _ = alertController.addCancelActionWithTitle(Localization.StoreCreationErrorAlert.cancelActionTitle) { _ in } + navigationController.present(alertController, animated: true) + } +} + private extension StoreCreationCoordinator { enum StoreCreationCoordinatorError: Error { case selfDeallocated @@ -197,6 +256,19 @@ private extension StoreCreationCoordinator { static let cancelActionTitle = NSLocalizedString("Cancel", comment: "Button title Cancel in Discard Changes Action Sheet") } + + enum StoreCreationErrorAlert { + static let title = NSLocalizedString("Cannot create store", + comment: "Title of the alert when the store cannot be created in the store creation flow.") + static let domainErrorMessage = NSLocalizedString("Please try a different domain.", + comment: "Message of the alert when the store cannot be created due to the domain in the store creation flow.") + static let defaultErrorMessage = NSLocalizedString("Please try again.", + comment: "Message of the alert when the store cannot be created in the store creation flow.") + static let cancelActionTitle = NSLocalizedString( + "OK", + comment: "Button title to dismiss the alert when the store cannot be created in the store creation flow." + ) + } } } diff --git a/WooCommerce/Classes/Authentication/Store Creation/StoreCreationSummaryView.swift b/WooCommerce/Classes/Authentication/Store Creation/StoreCreationSummaryView.swift new file mode 100644 index 00000000000..4ffea896046 --- /dev/null +++ b/WooCommerce/Classes/Authentication/Store Creation/StoreCreationSummaryView.swift @@ -0,0 +1,154 @@ +import SwiftUI + +/// Hosting controller that wraps the `StoreCreationSummaryView`. +final class StoreCreationSummaryHostingController: UIHostingController { + private let onContinueToPayment: () -> Void + + init(viewModel: StoreCreationSummaryViewModel, + onContinueToPayment: @escaping () -> Void) { + self.onContinueToPayment = onContinueToPayment + super.init(rootView: StoreCreationSummaryView(viewModel: viewModel)) + + rootView.onContinueToPayment = { [weak self] in + self?.onContinueToPayment() + } + } + + @available(*, unavailable) + required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + configureNavigationBarAppearance() + } + + /// Shows a transparent navigation bar without a bottom border. + func configureNavigationBarAppearance() { + navigationItem.title = Localization.title + + let appearance = UINavigationBarAppearance() + appearance.configureWithTransparentBackground() + appearance.backgroundColor = .systemBackground + + navigationItem.standardAppearance = appearance + navigationItem.scrollEdgeAppearance = appearance + navigationItem.compactAppearance = appearance + } +} + +private extension StoreCreationSummaryHostingController { + enum Localization { + static let title = NSLocalizedString("My store", comment: "Title of the store creation summary screen.") + } +} + +/// View model for `StoreCreationSummaryView`. +struct StoreCreationSummaryViewModel { + /// The name of the store. + let storeName: String + /// The URL slug of the store. + let storeSlug: String +} + +/// Displays a summary of the store creation flow with the store information (e.g. store name, store slug). +struct StoreCreationSummaryView: View { + /// Set in the hosting controller. + var onContinueToPayment: (() -> Void) = {} + + private let viewModel: StoreCreationSummaryViewModel + + init(viewModel: StoreCreationSummaryViewModel) { + self.viewModel = viewModel + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: Layout.spacingBetweenSubtitleAndStoreInfo) { + // Header label. + Text(Localization.subtitle) + .foregroundColor(Color(.secondaryLabel)) + .bodyStyle() + + // Store info. + VStack(alignment: .leading, spacing: 0) { + // Image. + HStack { + Spacer() + Image(uiImage: .storeSummaryImage) + Spacer() + } + .background(Color(.systemColor(.systemGray6))) + + VStack { + VStack(alignment: .leading, spacing: Layout.spacingBetweenStoreNameAndDomain) { + // Store name. + Text(viewModel.storeName) + .headlineStyle() + // Store URL slug. + Text(viewModel.storeSlug) + .foregroundColor(Color(.secondaryLabel)) + .bodyStyle() + } + } + .padding(Layout.storeInfoPadding) + } + .cornerRadius(Layout.storeInfoCornerRadius) + .overlay( + RoundedRectangle(cornerRadius: Layout.storeInfoCornerRadius) + .stroke(Color(.separator), lineWidth: 0.5) + ) + } + .padding(Layout.defaultPadding) + } + + // Continue button. + Group { + Divider() + .frame(height: Layout.dividerHeight) + .foregroundColor(Color(.separator)) + Button(Localization.continueButtonTitle) { + onContinueToPayment() + } + .buttonStyle(PrimaryButtonStyle()) + .padding(Layout.defaultButtonPadding) + } + } + } +} + +private extension StoreCreationSummaryView { + enum Layout { + static let spacingBetweenSubtitleAndStoreInfo: CGFloat = 40 + static let spacingBetweenStoreNameAndDomain: CGFloat = 4 + static let defaultHorizontalPadding: CGFloat = 16 + static let dividerHeight: CGFloat = 1 + static let defaultPadding: EdgeInsets = .init(top: 16, leading: 16, bottom: 16, trailing: 16) + static let defaultButtonPadding: EdgeInsets = .init(top: 10, leading: 16, bottom: 10, trailing: 16) + static let storeInfoPadding: EdgeInsets = .init(top: 16, leading: 16, bottom: 16, trailing: 16) + static let storeInfoCornerRadius: CGFloat = 8 + } + + enum Localization { + static let subtitle = NSLocalizedString( + "Your store will be created based on the options of your choice!", + comment: "Subtitle of the store creation summary screen.") + static let continueButtonTitle = NSLocalizedString( + "Continue to Payment", + comment: "Title of the button on the store creation summary view to continue to payment." + ) + } +} + +struct StoreCreationSummaryView_Previews: PreviewProvider { + static var previews: some View { + StoreCreationSummaryView(viewModel: + .init(storeName: "Fruity shop", storeSlug: "fruityshop.com")) + StoreCreationSummaryView(viewModel: + .init(storeName: "Fruity shop", storeSlug: "fruityshop.com")) + .preferredColorScheme(.dark) + } +} diff --git a/WooCommerce/Classes/Extensions/UIImage+Woo.swift b/WooCommerce/Classes/Extensions/UIImage+Woo.swift index 57bc6fcf2e4..f69812ef173 100644 --- a/WooCommerce/Classes/Extensions/UIImage+Woo.swift +++ b/WooCommerce/Classes/Extensions/UIImage+Woo.swift @@ -373,6 +373,12 @@ extension UIImage { UIImage(named: "icon-store")! } + /// Store summary image used in the store creation flow. + /// + static var storeSummaryImage: UIImage { + return UIImage(named: "store-summary")! + } + /// Cog Image /// static var cogImage: UIImage { diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorView.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorView.swift index 92d13326b61..438b2cf8e43 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorView.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorView.swift @@ -3,7 +3,7 @@ import SwiftUI /// Hosting controller that wraps the `DomainSelectorView` view. final class DomainSelectorHostingController: UIHostingController { private let viewModel: DomainSelectorViewModel - private let onDomainSelection: (String) -> Void + private let onDomainSelection: (String) async -> Void private let onSkip: () -> Void /// - Parameters: @@ -11,7 +11,7 @@ final class DomainSelectorHostingController: UIHostingController Void, + onDomainSelection: @escaping (String) async -> Void, onSkip: @escaping () -> Void) { self.viewModel = viewModel self.onDomainSelection = onDomainSelection @@ -19,7 +19,7 @@ final class DomainSelectorHostingController: UIHostingController Void) = { _ in } + var onDomainSelection: ((String) async -> Void) = { _ in } /// View model to drive the view. @ObservedObject private var viewModel: DomainSelectorViewModel @@ -80,6 +80,8 @@ struct DomainSelectorView: View { /// when a domain row is selected. @State private var selectedDomainName: String? + @State private var isWaitingForDomainSelectionCompletion: Bool = false + init(viewModel: DomainSelectorViewModel) { self.viewModel = viewModel } @@ -154,9 +156,13 @@ struct DomainSelectorView: View { .frame(height: Layout.dividerHeight) .foregroundColor(Color(.separator)) Button(Localization.continueButtonTitle) { - onDomainSelection(selectedDomainName) + Task { @MainActor in + isWaitingForDomainSelectionCompletion = true + await onDomainSelection(selectedDomainName) + isWaitingForDomainSelectionCompletion = false + } } - .buttonStyle(PrimaryButtonStyle()) + .buttonStyle(PrimaryLoadingButtonStyle(isLoading: isWaitingForDomainSelectionCompletion)) .padding(Layout.defaultPadding) } } diff --git a/WooCommerce/Resources/Images.xcassets/store-summary.imageset/Contents.json b/WooCommerce/Resources/Images.xcassets/store-summary.imageset/Contents.json new file mode 100644 index 00000000000..3cc5bb3a351 --- /dev/null +++ b/WooCommerce/Resources/Images.xcassets/store-summary.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "store-summary.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/WooCommerce/Resources/Images.xcassets/store-summary.imageset/store-summary.pdf b/WooCommerce/Resources/Images.xcassets/store-summary.imageset/store-summary.pdf new file mode 100644 index 00000000000..2e273ba4d65 Binary files /dev/null and b/WooCommerce/Resources/Images.xcassets/store-summary.imageset/store-summary.pdf differ diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 933598a1846..908aea41850 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -82,6 +82,7 @@ 0217399E2772FB7E0084CD89 /* StoreStatsAndTopPerformersPeriodViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0217399D2772FB7E0084CD89 /* StoreStatsAndTopPerformersPeriodViewController.swift */; }; 021739A02773F5F60084CD89 /* StoreStatsChartCircleMarker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0217399F2773F5F50084CD89 /* StoreStatsChartCircleMarker.swift */; }; 0218B4EC242E06F00083A847 /* MediaType+WPMediaType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0218B4EB242E06F00083A847 /* MediaType+WPMediaType.swift */; }; + 021940E8291FDBF90090354E /* StoreCreationSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021940E7291FDBF90090354E /* StoreCreationSummaryView.swift */; }; 0219B03723964527007DCD5E /* PaginatedProductShippingClassListSelectorDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0219B03623964527007DCD5E /* PaginatedProductShippingClassListSelectorDataSource.swift */; }; 021A84E0257DFC2A00BC71D1 /* RefundShippingLabelViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021A84DE257DFC2A00BC71D1 /* RefundShippingLabelViewController.swift */; }; 021A84E1257DFC2A00BC71D1 /* RefundShippingLabelViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 021A84DF257DFC2A00BC71D1 /* RefundShippingLabelViewController.xib */; }; @@ -2042,6 +2043,7 @@ 0217399D2772FB7E0084CD89 /* StoreStatsAndTopPerformersPeriodViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StoreStatsAndTopPerformersPeriodViewController.swift; sourceTree = ""; }; 0217399F2773F5F50084CD89 /* StoreStatsChartCircleMarker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreStatsChartCircleMarker.swift; sourceTree = ""; }; 0218B4EB242E06F00083A847 /* MediaType+WPMediaType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MediaType+WPMediaType.swift"; sourceTree = ""; }; + 021940E7291FDBF90090354E /* StoreCreationSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreCreationSummaryView.swift; sourceTree = ""; }; 0219B03623964527007DCD5E /* PaginatedProductShippingClassListSelectorDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginatedProductShippingClassListSelectorDataSource.swift; sourceTree = ""; }; 021A84DE257DFC2A00BC71D1 /* RefundShippingLabelViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefundShippingLabelViewController.swift; sourceTree = ""; }; 021A84DF257DFC2A00BC71D1 /* RefundShippingLabelViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = RefundShippingLabelViewController.xib; sourceTree = ""; }; @@ -4528,6 +4530,7 @@ 02759B9028FFA09600918176 /* StoreCreationWebViewModel.swift */, 02E3B63029066858007E0F13 /* StoreCreationCoordinator.swift */, 02EAA4C7290F992B00918DAB /* LoggedOutStoreCreationCoordinator.swift */, + 021940E7291FDBF90090354E /* StoreCreationSummaryView.swift */, ); path = "Store Creation"; sourceTree = ""; @@ -10447,6 +10450,7 @@ 4515262E2577D56C0076B03C /* AddAttributeViewController.swift in Sources */, 57EBC92024EEE61800C1D45B /* WooAnalyticsEvent.swift in Sources */, 57CDABB9252E9BEB00BED88C /* ButtonTableFooterView.swift in Sources */, + 021940E8291FDBF90090354E /* StoreCreationSummaryView.swift in Sources */, 02E4FD7E2306A8180049610C /* StatsTimeRangeBarViewModel.swift in Sources */, 45D875D22611EA2100226C3F /* ListHeaderView.swift in Sources */, 026B3C57249A046E00F7823C /* TextFieldTextAlignment.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/Extensions/IconsTests.swift b/WooCommerce/WooCommerceTests/Extensions/IconsTests.swift index a08faff433a..c1e4e0108d8 100644 --- a/WooCommerce/WooCommerceTests/Extensions/IconsTests.swift +++ b/WooCommerce/WooCommerceTests/Extensions/IconsTests.swift @@ -216,6 +216,10 @@ final class IconsTests: XCTestCase { XCTAssertNotNil(UIImage.storeImage) } + func test_storeSummaryImage_is_not_nil() { + XCTAssertNotNil(UIImage.storeSummaryImage) + } + func testCotImageIsNotNil() { XCTAssertNotNil(UIImage.cogImage) }