diff --git a/Networking/Networking/Model/Product/Product.swift b/Networking/Networking/Model/Product/Product.swift index 1268b342ea8..1511392eb58 100644 --- a/Networking/Networking/Model/Product/Product.swift +++ b/Networking/Networking/Model/Product/Product.swift @@ -662,3 +662,12 @@ private extension Product { enum ProductDecodingError: Error { case missingSiteID } + +// MARK: - Hashable Conformance +// +extension Product: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(siteID) + hasher.combine(productID) + } +} diff --git a/WooCommerce/Classes/ViewRelated/Products/ProductsListViewModel.swift b/WooCommerce/Classes/ViewRelated/Products/ProductsListViewModel.swift new file mode 100644 index 00000000000..9d773c74925 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Products/ProductsListViewModel.swift @@ -0,0 +1,28 @@ +import Foundation +import Yosemite + +/// View model for `ProductsViewController`. Only stores logic related to Bulk Editing. +/// +class ProductListViewModel { + private var selectedProducts: Set = .init() + + var selectedProductsCount: Int { + selectedProducts.count + } + + func productIsSelected(_ productToCheck: Product) -> Bool { + return selectedProducts.contains(productToCheck) + } + + func selectProduct(_ selectedProduct: Product) { + selectedProducts.insert(selectedProduct) + } + + func deselectProduct(_ selectedProduct: Product) { + selectedProducts.remove(selectedProduct) + } + + func deselectAll() { + selectedProducts.removeAll() + } +} diff --git a/WooCommerce/Classes/ViewRelated/Products/ProductsViewController.swift b/WooCommerce/Classes/ViewRelated/Products/ProductsViewController.swift index 087cbe88975..b60e1ed9fd4 100644 --- a/WooCommerce/Classes/ViewRelated/Products/ProductsViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Products/ProductsViewController.swift @@ -9,6 +9,8 @@ import class AutomatticTracks.CrashLogging /// final class ProductsViewController: UIViewController, GhostableViewController { + let viewModel: ProductListViewModel = .init() + /// Main TableView /// @IBOutlet weak var tableView: UITableView! @@ -264,7 +266,24 @@ private extension ProductsViewController { } @objc func startBulkEditing() { - // TODO-8517: implement selection state + tableView.setEditing(true, animated: true) + + // Disable pull-to-refresh while editing + refreshControl.removeFromSuperview() + + configureNavigationBarForEditing() + showOrHideToolbar() + } + + @objc func finishBulkEditing() { + viewModel.deselectAll() + tableView.setEditing(false, animated: true) + + // Enable pull-to-refresh + tableView.addSubview(refreshControl) + + configureNavigationBar() + showOrHideToolbar() } } @@ -330,12 +349,8 @@ private extension ProductsViewController { target: self, action: #selector(startBulkEditing)) button.accessibilityTraits = .button - button.accessibilityLabel = NSLocalizedString("Edit products", - comment: "Action to start bulk editing of products") - button.accessibilityHint = NSLocalizedString( - "Edit status or price for multiple products at once", - comment: "VoiceOver accessibility hint, informing the user the button can be used to bulk edit products" - ) + button.accessibilityLabel = Localization.bulkEditingNavBarButtonTitle + button.accessibilityHint = Localization.bulkEditingNavBarButtonHint return button }() @@ -345,6 +360,26 @@ private extension ProductsViewController { navigationItem.rightBarButtonItems = rightBarButtonItems } + func configureNavigationBarForEditing() { + configureNavigationBarTitleForEditing() + configureNavigationBarRightButtonItemsForEditing() + } + + func configureNavigationBarTitleForEditing() { + let selectedProducts = viewModel.selectedProductsCount + if selectedProducts == 0 { + navigationItem.title = Localization.bulkEditingTitle + } else { + navigationItem.title = String.localizedStringWithFormat(Localization.bulkEditingItemsTitle, String(selectedProducts)) + } + } + + func configureNavigationBarRightButtonItemsForEditing() { + navigationItem.rightBarButtonItems = [UIBarButtonItem(barButtonSystemItem: .cancel, + target: self, + action: #selector(finishBulkEditing))] + } + /// Apply Woo styles. /// func configureMainView() { @@ -371,6 +406,8 @@ private extension ProductsViewController { tableView.tableFooterView = footerSpinnerView tableView.separatorStyle = .none + tableView.allowsMultipleSelectionDuringEditing = true + // Adds the refresh control to table view manually so that the refresh control always appears below the navigation bar title in // large or normal size to be consistent with Dashboard and Orders tab with large titles workaround. // If we do `tableView.refreshControl = refreshControl`, the refresh control appears in the navigation bar when large title is shown. @@ -447,6 +484,11 @@ private extension ProductsViewController { /// if there is 1 or more products, toolbar will be visible /// func showOrHideToolbar() { + guard !tableView.isEditing else { + toolbar.isHidden = true + return + } + toolbar.isHidden = filters.numberOfActiveFilters == 0 ? isEmpty : false } } @@ -627,13 +669,28 @@ extension ProductsViewController: UITableViewDelegate { } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) + let product = resultsController.object(at: indexPath) - ServiceLocator.analytics.track(.productListProductTapped) + if tableView.isEditing { + viewModel.selectProduct(product) + configureNavigationBarTitleForEditing() + } else { + tableView.deselectRow(at: indexPath, animated: true) - let product = resultsController.object(at: indexPath) + ServiceLocator.analytics.track(.productListProductTapped) + + didSelectProduct(product: product) + } + } + + func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) { + guard tableView.isEditing else { + return + } - didSelectProduct(product: product) + let product = resultsController.object(at: indexPath) + viewModel.deselectProduct(product) + configureNavigationBarTitleForEditing() } func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { @@ -646,6 +703,14 @@ extension ProductsViewController: UITableViewDelegate { // the actual value. AKA no flicker! // estimatedRowHeights[indexPath] = cell.frame.height + + // Restore cell selection state + let product = resultsController.object(at: indexPath) + if self.viewModel.productIsSelected(product) { + tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none) + } else { + tableView.deselectRow(at: indexPath, animated: false) + } } func scrollViewDidScroll(_ scrollView: UIScrollView) { @@ -1046,4 +1111,22 @@ private extension ProductsViewController { static let headerContainerInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) static let toolbarButtonInsets = UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16) } + + enum Localization { + + static let bulkEditingNavBarButtonTitle = NSLocalizedString("Edit products", comment: "Action to start bulk editing of products") + static let bulkEditingNavBarButtonHint = NSLocalizedString( + "Edit status or price for multiple products at once", + comment: "VoiceOver accessibility hint, informing the user the button can be used to bulk edit products" + ) + + static let bulkEditingTitle = NSLocalizedString( + "Select items", + comment: "Title that appears on top of the Product List screen when bulk editing starts." + ) + static let bulkEditingItemsTitle = NSLocalizedString( + "%1$@ selected", + comment: "Title that appears on top of the Product List screen during bulk editing. Reads like: 2 selected" + ) + } } diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 328296a3aba..f898edca408 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -1218,6 +1218,7 @@ ABC35528D2D6BE6F516E5CEF /* InPersonPaymentsOnboardingError.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC35A4B736A0B2D8348DD08 /* InPersonPaymentsOnboardingError.swift */; }; ABC35F18E744C5576B986CB3 /* InPersonPaymentsUnavailableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC35055F8AC8C8EB649F421 /* InPersonPaymentsUnavailableView.swift */; }; AE1CC33829129A010021C8EF /* LinkBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE1CC33729129A010021C8EF /* LinkBehavior.swift */; }; + AE2E5F6629685CF8009262D3 /* ProductsListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE2E5F6529685CF8009262D3 /* ProductsListViewModel.swift */; }; AE3AA889290C303B00BE422D /* WebKitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE3AA888290C303B00BE422D /* WebKitViewController.swift */; }; AE3AA88B290C30B900BE422D /* WebViewControllerConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE3AA88A290C30B900BE422D /* WebViewControllerConfiguration.swift */; }; AE3AA88D290C30E800BE422D /* WebProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE3AA88C290C30E800BE422D /* WebProgressView.swift */; }; @@ -3259,6 +3260,7 @@ ABC353433EABC5F0EC796222 /* CardReaderSettingsSearchingViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CardReaderSettingsSearchingViewController.swift; sourceTree = ""; }; ABC35A4B736A0B2D8348DD08 /* InPersonPaymentsOnboardingError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InPersonPaymentsOnboardingError.swift; sourceTree = ""; }; AE1CC33729129A010021C8EF /* LinkBehavior.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkBehavior.swift; sourceTree = ""; }; + AE2E5F6529685CF8009262D3 /* ProductsListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsListViewModel.swift; sourceTree = ""; }; AE3AA888290C303B00BE422D /* WebKitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebKitViewController.swift; sourceTree = ""; }; AE3AA88A290C30B900BE422D /* WebViewControllerConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewControllerConfiguration.swift; sourceTree = ""; }; AE3AA88C290C30E800BE422D /* WebProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebProgressView.swift; sourceTree = ""; }; @@ -6783,6 +6785,7 @@ 02A65300246AA63600755A01 /* ProductDetailsFactory.swift */, B92FF9AF27FC7821005C34E3 /* ProductsViewController.xib */, 0260F40023224E8100EDA10A /* ProductsViewController.swift */, + AE2E5F6529685CF8009262D3 /* ProductsListViewModel.swift */, 020DD49023239DD6005822B1 /* PaginatedListViewControllerStateCoordinator.swift */, 02564A89246CDF6100D6DB2A /* ProductsTopBannerFactory.swift */, 0279F0D9252DB4BE0098D7DE /* ProductVariationDetailsFactory.swift */, @@ -10355,6 +10358,7 @@ CE1CCB402056F21C000EE3AC /* Style.swift in Sources */, 45EF7984244F26BB00B22BA2 /* Array+IndexPath.swift in Sources */, 02E6B97823853D81000A36F0 /* TitleAndValueTableViewCell.swift in Sources */, + AE2E5F6629685CF8009262D3 /* ProductsListViewModel.swift in Sources */, CC770C8A27B1497700CE6ABC /* SearchHeader.swift in Sources */, 02BAB02724D13A6400F8B06E /* ProductVariationFormActionsFactory.swift in Sources */, 45CDAFAE2434CFCA00F83C22 /* ProductCatalogVisibilityViewController.swift in Sources */,