diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index a3a6626ab7e..81fe4d594a6 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -2,6 +2,7 @@ 13.8 ----- +- [*] Add Products: A new view is display to celebrate when the first product is created in a store. [https://github.com/woocommerce/woocommerce-ios/pull/9790] - [*] Product form: a share action is shown in the navigation bar if the product can be shared and no more than one action is displayed, in addition to the more menu > Share. [https://github.com/woocommerce/woocommerce-ios/pull/9789] 13.7 diff --git a/WooCommerce.xcworkspace/xcshareddata/swiftpm/Package.resolved b/WooCommerce.xcworkspace/xcshareddata/swiftpm/Package.resolved index 549969dd8c9..de3a1157f17 100644 --- a/WooCommerce.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/WooCommerce.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -19,6 +19,15 @@ "version": "4.1.0" } }, + { + "package": "ConfettiSwiftUI", + "repositoryURL": "https://github.com/simibac/ConfettiSwiftUI.git", + "state": { + "branch": null, + "revision": "8d3a15d0aa2991e0761749b767ef8d89bca6275a", + "version": "1.0.1" + } + }, { "package": "Difference", "repositoryURL": "https://github.com/krzysztofzablocki/Difference.git", diff --git a/WooCommerce/Classes/Analytics/WooAnalyticsStat.swift b/WooCommerce/Classes/Analytics/WooAnalyticsStat.swift index c04b6d700b4..d08f6935cab 100644 --- a/WooCommerce/Classes/Analytics/WooAnalyticsStat.swift +++ b/WooCommerce/Classes/Analytics/WooAnalyticsStat.swift @@ -713,6 +713,10 @@ public enum WooAnalyticsStat: String { case productDescriptionAIGenerationSuccess = "product_description_ai_generation_success" case productDescriptionAIGenerationFailed = "product_description_ai_generation_failed" + // MARK: First created product events + case firstCreatedProductShown = "first_created_product_shown" + case firstCreatedProductShareTapped = "first_created_product_share_tapped" + // MARK: Jetpack Tunnel Events // case jetpackTunnelTimeout = "jetpack_tunnel_timeout" diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/DashboardViewController.swift b/WooCommerce/Classes/ViewRelated/Dashboard/DashboardViewController.swift index 1afcb5536ba..bb3f363e70e 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/DashboardViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/DashboardViewController.swift @@ -471,7 +471,8 @@ private extension DashboardViewController { let coordinator = AddProductCoordinator(siteID: siteID, source: .productOnboarding, sourceView: announcementView, - sourceNavigationController: navigationController) + sourceNavigationController: navigationController, + isFirstProduct: true) coordinator.onProductCreated = { [weak self] _ in guard let self else { return } self.viewModel.announcementViewModel = nil // Remove the products onboarding banner diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Onboarding/StoreOnboardingCoordinator.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Onboarding/StoreOnboardingCoordinator.swift index 0acb219b255..358c5493cff 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Onboarding/StoreOnboardingCoordinator.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Onboarding/StoreOnboardingCoordinator.swift @@ -86,7 +86,8 @@ private extension StoreOnboardingCoordinator { let coordinator = AddProductCoordinator(siteID: site.siteID, source: .storeOnboarding, sourceView: nil, - sourceNavigationController: navigationController) + sourceNavigationController: navigationController, + isFirstProduct: true) self.addProductCoordinator = coordinator coordinator.onProductCreated = { [weak self] _ in self?.onTaskCompleted(.addFirstProduct) diff --git a/WooCommerce/Classes/ViewRelated/Products/Add Product/AddProductCoordinator.swift b/WooCommerce/Classes/ViewRelated/Products/Add Product/AddProductCoordinator.swift index db9261955c8..326e16c0342 100644 --- a/WooCommerce/Classes/ViewRelated/Products/Add Product/AddProductCoordinator.swift +++ b/WooCommerce/Classes/ViewRelated/Products/Add Product/AddProductCoordinator.swift @@ -29,6 +29,7 @@ final class AddProductCoordinator: Coordinator { private let sourceView: UIView? private let productImageUploader: ProductImageUploaderProtocol private let storage: StorageManagerType + private let isFirstProduct: Bool /// ResultController to to track the current product count. /// @@ -48,7 +49,8 @@ final class AddProductCoordinator: Coordinator { sourceBarButtonItem: UIBarButtonItem, sourceNavigationController: UINavigationController, storage: StorageManagerType = ServiceLocator.storageManager, - productImageUploader: ProductImageUploaderProtocol = ServiceLocator.productImageUploader) { + productImageUploader: ProductImageUploaderProtocol = ServiceLocator.productImageUploader, + isFirstProduct: Bool) { self.siteID = siteID self.source = source self.sourceBarButtonItem = sourceBarButtonItem @@ -56,6 +58,7 @@ final class AddProductCoordinator: Coordinator { self.navigationController = sourceNavigationController self.productImageUploader = productImageUploader self.storage = storage + self.isFirstProduct = isFirstProduct } init(siteID: Int64, @@ -63,7 +66,8 @@ final class AddProductCoordinator: Coordinator { sourceView: UIView?, sourceNavigationController: UINavigationController, storage: StorageManagerType = ServiceLocator.storageManager, - productImageUploader: ProductImageUploaderProtocol = ServiceLocator.productImageUploader) { + productImageUploader: ProductImageUploaderProtocol = ServiceLocator.productImageUploader, + isFirstProduct: Bool) { self.siteID = siteID self.source = source self.sourceBarButtonItem = nil @@ -71,6 +75,7 @@ final class AddProductCoordinator: Coordinator { self.navigationController = sourceNavigationController self.productImageUploader = productImageUploader self.storage = storage + self.isFirstProduct = isFirstProduct } required init?(coder aDecoder: NSCoder) { @@ -235,7 +240,13 @@ private extension AddProductCoordinator { let viewModel = ProductFormViewModel(product: model, formType: .add, productImageActionHandler: productImageActionHandler) - viewModel.onProductCreated = onProductCreated + viewModel.onProductCreated = { [weak self] product in + guard let self else { return } + self.onProductCreated(product) + if self.isFirstProduct, let url = URL(string: product.permalink) { + self.showFirstProductCreatedView(productURL: url) + } + } let viewController = ProductFormViewController(viewModel: viewModel, eventLogger: ProductFormEventLogger(), productImageActionHandler: productImageActionHandler, @@ -289,4 +300,11 @@ private extension AddProductCoordinator { }() ServiceLocator.analytics.track(event: .ProductsOnboarding.productCreationTypeSelected(type: analyticsType)) } + + /// Presents the celebratory view for the first created product. + /// + func showFirstProductCreatedView(productURL: URL) { + let viewController = FirstProductCreatedHostingController(productURL: productURL) + navigationController.present(UINavigationController(rootViewController: viewController), animated: true) + } } diff --git a/WooCommerce/Classes/ViewRelated/Products/Add Product/FirstProductCreatedView.swift b/WooCommerce/Classes/ViewRelated/Products/Add Product/FirstProductCreatedView.swift new file mode 100644 index 00000000000..6f1707a65be --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Products/Add Product/FirstProductCreatedView.swift @@ -0,0 +1,103 @@ +import ConfettiSwiftUI +import SwiftUI + +final class FirstProductCreatedHostingController: UIHostingController { + init(productURL: URL) { + super.init(rootView: FirstProductCreatedView()) + rootView.onSharingProduct = { [weak self] in + guard let self else { return } + SharingHelper.shareURL(url: productURL, from: self.view, in: self) + ServiceLocator.analytics.track(.firstCreatedProductShareTapped) + } + } + + @available(*, unavailable) + required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + configureTransparentNavigationBar() + navigationItem.leftBarButtonItem = UIBarButtonItem(title: Localization.cancel, style: .plain, target: self, action: #selector(dismissView)) + ServiceLocator.analytics.track(.firstCreatedProductShown) + } + + @objc + private func dismissView() { + dismiss(animated: true) + } +} + +private extension FirstProductCreatedHostingController { + enum Localization { + static let cancel = NSLocalizedString("Dismiss", comment: "Button to dismiss the first created product screen") + } +} + +/// Celebratory screen after creating the first product 🎉 +/// +struct FirstProductCreatedView: View { + var onSharingProduct: () -> Void = {} + @State private var confettiCounter: Int = 0 + + var body: some View { + GeometryReader { proxy in + ScrollableVStack(spacing: Constants.verticalSpacing) { + Spacer() + Text(Localization.title) + .titleStyle() + Image(uiImage: .welcomeImage) + Text(Localization.message) + .secondaryBodyStyle() + .multilineTextAlignment(.center) + Button(Localization.shareAction, + action: onSharingProduct) + .buttonStyle(PrimaryButtonStyle()) + .padding(.horizontal) + Spacer() + } + .padding() + .confettiCannon(counter: $confettiCounter, + num: Constants.confettiCount, + rainHeight: proxy.size.height, + radius: proxy.size.width) + } + .onAppear { + confettiCounter += 1 + } + .background(Color(uiColor: .systemBackground)) + } +} + +private extension FirstProductCreatedView { + enum Constants { + static let verticalSpacing: CGFloat = 40 + static let confettiCount: Int = 100 + } + enum Localization { + static let title = NSLocalizedString( + "First product created 🎉", + comment: "Title of the celebratory screen after creating the first product" + ) + static let message = NSLocalizedString( + "Congratulations! You're one step closer to getting the new store ready.", + comment: "Message on the celebratory screen after creating first product" + ) + static let shareAction = NSLocalizedString( + "Share Product", + comment: "Title of the action button to share the first created product" + ) + } +} + +struct FirstProductCreatedView_Previews: PreviewProvider { + static var previews: some View { + FirstProductCreatedView() + .environment(\.colorScheme, .light) + + FirstProductCreatedView() + .environment(\.colorScheme, .dark) + .previewInterfaceOrientation(.landscapeLeft) + } +} diff --git a/WooCommerce/Classes/ViewRelated/Products/ProductsViewController.swift b/WooCommerce/Classes/ViewRelated/Products/ProductsViewController.swift index d24d02b1f7b..574251cee47 100644 --- a/WooCommerce/Classes/ViewRelated/Products/ProductsViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Products/ProductsViewController.swift @@ -179,6 +179,8 @@ final class ProductsViewController: UIViewController, GhostableViewController { private var subscriptions: Set = [] + private var addProductCoordinator: AddProductCoordinator? + deinit { NotificationCenter.default.removeObserver(self) } @@ -269,10 +271,12 @@ private extension ProductsViewController { } @objc func addProduct(_ sender: UIBarButtonItem) { - addProduct(sourceBarButtonItem: sender) + addProduct(sourceBarButtonItem: sender, isFirstProduct: false) } - func addProduct(sourceBarButtonItem: UIBarButtonItem? = nil, sourceView: UIView? = nil) { + func addProduct(sourceBarButtonItem: UIBarButtonItem? = nil, + sourceView: UIView? = nil, + isFirstProduct: Bool) { guard let navigationController = navigationController, sourceBarButtonItem != nil || sourceView != nil else { return } @@ -283,17 +287,20 @@ private extension ProductsViewController { coordinatingController = AddProductCoordinator(siteID: siteID, source: source, sourceBarButtonItem: sourceBarButtonItem, - sourceNavigationController: navigationController) + sourceNavigationController: navigationController, + isFirstProduct: isFirstProduct) } else if let sourceView = sourceView { coordinatingController = AddProductCoordinator(siteID: siteID, source: source, sourceView: sourceView, - sourceNavigationController: navigationController) + sourceNavigationController: navigationController, + isFirstProduct: isFirstProduct) } else { fatalError("No source view for adding a product") } coordinatingController.start() + self.addProductCoordinator = coordinatingController } } @@ -1022,7 +1029,7 @@ private extension ProductsViewController { details: details, buttonTitle: buttonTitle, onTap: { [weak self] button in - self?.addProduct(sourceView: button) + self?.addProduct(sourceView: button, isFirstProduct: true) }, onPullToRefresh: { [weak self] refreshControl in self?.pullToRefresh(sender: refreshControl) diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 3d1e4e58ec8..b51c53f9d1f 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -2057,6 +2057,8 @@ DE8C94662646990000C94823 /* PluginListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE8C94652646990000C94823 /* PluginListViewController.swift */; }; DE8C946E264699B600C94823 /* PluginListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE8C946D264699B600C94823 /* PluginListViewModel.swift */; }; DE971219290A9615000C0BD3 /* AddStoreFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE971218290A9615000C0BD3 /* AddStoreFooterView.swift */; }; + DE9F2D292A1B1AB2004E5957 /* FirstProductCreatedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE9F2D282A1B1AB2004E5957 /* FirstProductCreatedView.swift */; }; + DE9F2D2C2A1B1F5D004E5957 /* ConfettiSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = DE9F2D2B2A1B1F5D004E5957 /* ConfettiSwiftUI */; }; DEA4269A2875440500265B0C /* PaymentMethodsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEA426992875440500265B0C /* PaymentMethodsScreen.swift */; }; DEC0293729C418FF00FD0E2F /* ApplicationPasswordAuthorizationWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC0293629C418FF00FD0E2F /* ApplicationPasswordAuthorizationWebViewController.swift */; }; DEC0293A29C41BC500FD0E2F /* ApplicationPasswordAuthorizationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC0293929C41BC500FD0E2F /* ApplicationPasswordAuthorizationViewModel.swift */; }; @@ -4364,6 +4366,7 @@ DE8C94652646990000C94823 /* PluginListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginListViewController.swift; sourceTree = ""; }; DE8C946D264699B600C94823 /* PluginListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginListViewModel.swift; sourceTree = ""; }; DE971218290A9615000C0BD3 /* AddStoreFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddStoreFooterView.swift; sourceTree = ""; }; + DE9F2D282A1B1AB2004E5957 /* FirstProductCreatedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirstProductCreatedView.swift; sourceTree = ""; }; DEA426992875440500265B0C /* PaymentMethodsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentMethodsScreen.swift; sourceTree = ""; }; DEC0293629C418FF00FD0E2F /* ApplicationPasswordAuthorizationWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationPasswordAuthorizationWebViewController.swift; sourceTree = ""; }; DEC0293929C41BC500FD0E2F /* ApplicationPasswordAuthorizationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationPasswordAuthorizationViewModel.swift; sourceTree = ""; }; @@ -4564,6 +4567,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + DE9F2D2C2A1B1F5D004E5957 /* ConfettiSwiftUI in Frameworks */, 3FFC5EAC2851942F00563C48 /* Charts in Frameworks */, D88FDB4525DD223B00CB0DBD /* Hardware.framework in Frameworks */, 263E37E12641AD8300260D3B /* Codegen in Frameworks */, @@ -5795,6 +5799,7 @@ isa = PBXGroup; children = ( 02ECD1E324FF5E0B00735BE5 /* AddProductCoordinator.swift */, + DE9F2D282A1B1AB2004E5957 /* FirstProductCreatedView.swift */, 02ECD1E524FFB4E900735BE5 /* ProductFactory.swift */, ); path = "Add Product"; @@ -10422,6 +10427,7 @@ 174CA86927D90A6200126524 /* AutomatticAbout */, 3FFC5EAB2851942F00563C48 /* Charts */, 4598298028574688003A9AFE /* Inject */, + DE9F2D2B2A1B1F5D004E5957 /* ConfettiSwiftUI */, ); productName = WooCommerce; productReference = B56DB3C62049BFAA00D4AA8E /* WooCommerce.app */; @@ -10592,6 +10598,7 @@ 3FFC5EAA2851942F00563C48 /* XCRemoteSwiftPackageReference "Charts" */, 4598297F28574688003A9AFE /* XCRemoteSwiftPackageReference "Inject" */, 3F2C8A17285B038800B1A5BB /* XCRemoteSwiftPackageReference "test-collector-swift" */, + DE9F2D2A2A1B1F5D004E5957 /* XCRemoteSwiftPackageReference "ConfettiSwiftUI" */, ); productRefGroup = B56DB3C72049BFAA00D4AA8E /* Products */; projectDirPath = ""; @@ -12168,6 +12175,7 @@ 035DBA45292D0164003E5125 /* CollectOrderPaymentUseCase.swift in Sources */, 0282DD96233C960C006A5FDB /* SearchResultCell.swift in Sources */, 027F83ED29B046D2002688C6 /* DashboardTopPerformersViewModel.swift in Sources */, + DE9F2D292A1B1AB2004E5957 /* FirstProductCreatedView.swift in Sources */, 260C32BE2527A2DE00157BC2 /* IssueRefundViewModel.swift in Sources */, 2678897C270E6E8B00BD249E /* SimplePaymentsAmount.swift in Sources */, 02D29A9229F7C39200473D6D /* UIImage+Text.swift in Sources */, @@ -13941,6 +13949,14 @@ minimumVersion = 1.1.1; }; }; + DE9F2D2A2A1B1F5D004E5957 /* XCRemoteSwiftPackageReference "ConfettiSwiftUI" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/simibac/ConfettiSwiftUI.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -14026,6 +14042,11 @@ isa = XCSwiftPackageProductDependency; productName = TestKit; }; + DE9F2D2B2A1B1F5D004E5957 /* ConfettiSwiftUI */ = { + isa = XCSwiftPackageProductDependency; + package = DE9F2D2A2A1B1F5D004E5957 /* XCRemoteSwiftPackageReference "ConfettiSwiftUI" */; + productName = ConfettiSwiftUI; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = B56DB3BE2049BFAA00D4AA8E /* Project object */; diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Products/Add Product/AddProductCoordinatorTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Products/Add Product/AddProductCoordinatorTests.swift index 21e16695f3a..4bf2a163668 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Products/Add Product/AddProductCoordinatorTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Products/Add Product/AddProductCoordinatorTests.swift @@ -44,6 +44,7 @@ private extension AddProductCoordinatorTests { return AddProductCoordinator(siteID: 100, source: .productsTab, sourceView: view, - sourceNavigationController: navigationController) + sourceNavigationController: navigationController, + isFirstProduct: false) } }