Skip to content

Commit

Permalink
Product List: Show syncing animation for items with active image uplo…
Browse files Browse the repository at this point in the history
…ads (#15052)
  • Loading branch information
itsmeichigo authored Feb 11, 2025
2 parents 3159df9 + 00bed60 commit 437ed99
Show file tree
Hide file tree
Showing 14 changed files with 315 additions and 14 deletions.
1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
-----
- [*] Now "Suggested by AI" label is visible in dark mode in Blaze campaign creation flow. [https://github.com/woocommerce/woocommerce-ios/pull/15088]
- [*] Improved image loading in Blaze Campaign Creation: displays a redacted and shimmering effects when loading product image and falls back to a placeholder if no image is available. [https://github.com/woocommerce/woocommerce-ios/pull/15098]
- [*] Product List: Display syncing animation on items with image upload in progress [https://github.com/woocommerce/woocommerce-ios/pull/15052]

21.7
-----
Expand Down
48 changes: 41 additions & 7 deletions WooCommerce/Classes/ServiceLocator/ProductImageUploader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ struct ProductImageUploaderKey: Equatable, Hashable {

/// Handles product image upload to support background image upload.
protocol ProductImageUploaderProtocol {

/// Emits active image uploads
var activeUploads: AnyPublisher<[ProductImageUploaderKey], Never> { get }

/// Emits product image upload errors.
var errors: AnyPublisher<ProductImageUploadErrorInfo, Never> { get }

Expand Down Expand Up @@ -94,6 +98,10 @@ final class ProductImageUploader: ProductImageUploaderProtocol {
errorsSubject.eraseToAnyPublisher()
}

var activeUploads: AnyPublisher<[ProductImageUploaderKey], Never> {
$activeUploadsPublisher.eraseToAnyPublisher()
}

typealias Key = ProductImageUploaderKey

private let errorsSubject: PassthroughSubject<ProductImageUploadErrorInfo, Never> = .init()
Expand All @@ -102,6 +110,10 @@ final class ProductImageUploader: ProductImageUploaderProtocol {

private var actionHandlersByProduct: [Key: ProductImageActionHandler] = [:]
private var imagesSaverByProduct: [Key: ProductImagesSaver] = [:]
private var initialStatusesByProduct: [Key: [ProductImageStatus]] = [:]

@Published private var activeUploadsPublisher: [ProductImageUploaderKey] = []

private let stores: StoresManager
private let imagesProductIDUpdater: ProductImagesProductIDUpdaterProtocol

Expand All @@ -118,8 +130,10 @@ final class ProductImageUploader: ProductImageUploaderProtocol {
} else {
actionHandler = ProductImageActionHandler(siteID: key.siteID, productID: key.productOrVariationID, imageStatuses: originalStatuses, stores: stores)
actionHandlersByProduct[key] = actionHandler
observeStatusUpdatesForErrors(key: key, actionHandler: actionHandler)
initialStatusesByProduct[key] = originalStatuses
observeStatusUpdates(key: key, actionHandler: actionHandler)
}

return actionHandler
}

Expand Down Expand Up @@ -173,10 +187,12 @@ final class ProductImageUploader: ProductImageUploaderProtocol {
// The product has to exist remotely in order to save its images remotely.
// In product creation, this save function should be called after a new product is saved remotely for the first time.
guard key.isLocalID == false else {
removeProductFromActiveUploads(key: key)
return onProductSave(.failure(ProductImageUploaderError.noRemoteProductIDFound))
}

guard let handler = actionHandlersByProduct[key] else {
removeProductFromActiveUploads(key: key)
return onProductSave(.failure(ProductImageUploaderError.noActionHandlerFound))
}

Expand All @@ -192,6 +208,7 @@ final class ProductImageUploader: ProductImageUploaderProtocol {

imagesSaver.saveProductImagesWhenNoneIsPendingUploadAnymore(imageActionHandler: handler) { [weak self] result in
guard let self = self else { return }
removeProductFromActiveUploads(key: key)
onProductSave(result)
if case let .failure(error) = result {
self.errorsSubject.send(.init(siteID: key.siteID,
Expand All @@ -208,9 +225,11 @@ final class ProductImageUploader: ProductImageUploaderProtocol {
func reset() {
statusUpdatesExcludedProductKeys = []
statusUpdatesSubscriptions = []
activeUploadsPublisher = []

actionHandlersByProduct = [:]
imagesSaverByProduct = [:]
initialStatusesByProduct = [:]
}
}

Expand All @@ -229,19 +248,34 @@ private extension ProductImageUploader {
}
}

private func observeStatusUpdatesForErrors(key: Key, actionHandler: ProductImageActionHandler) {
func observeStatusUpdates(key: Key, actionHandler: ProductImageActionHandler) {
let observationToken = actionHandler.addUpdateObserver(self) { [weak self] (productImageStatuses, error) in
guard let self = self else { return }

if let error = error, self.statusUpdatesExcludedProductKeys.contains(key) == false {
self.errorsSubject.send(.init(siteID: key.siteID,
productOrVariationID: key.productOrVariationID,
productImageStatuses: productImageStatuses,
error: .failedUploadingImage(error: error)))
if !activeUploadsPublisher.contains(key), productImageStatuses.hasPendingUpload {
activeUploadsPublisher.append(key)
} else if let initialStatuses = initialStatusesByProduct[key],
initialStatuses == productImageStatuses,
activeUploadsPublisher.contains(key) {
/// When upload is reset, remove the key from active uploads
removeProductFromActiveUploads(key: key)
}

if let error = error, statusUpdatesExcludedProductKeys.contains(key) == false {
removeProductFromActiveUploads(key: key)
errorsSubject.send(.init(siteID: key.siteID,
productOrVariationID: key.productOrVariationID,
productImageStatuses: productImageStatuses,
error: .failedUploadingImage(error: error)))
}
}
statusUpdatesSubscriptions.insert(observationToken)
}

func removeProductFromActiveUploads(key: Key) {
activeUploadsPublisher.removeAll(where: { $0 == key })
initialStatusesByProduct.removeValue(forKey: key)
}
}

private extension ProductOrVariationID {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ final class ProductsTabProductTableViewCell: UITableViewCell {

private var selectedProductImageOverlayView: UIView?

private var syncingOverlayView: UIView?

/// ProductImageView.width == 0.1*Cell.width
private var productImageViewRelationalWidthConstraint: NSLayoutConstraint?

Expand Down Expand Up @@ -71,11 +73,19 @@ extension ProductsTabProductTableViewCell: SearchResultCell {
}

extension ProductsTabProductTableViewCell {

func update(viewModel: ProductsTabProductViewModel, imageService: ImageService) {
nameLabel.text = viewModel.createNameLabel()
detailsLabel.attributedText = viewModel.detailsAttributedString
accessibilityIdentifier = viewModel.createNameLabel()

if viewModel.hasPendingUploads {
configureSyncingOverlayView()
} else {
syncingOverlayView?.removeFromSuperview()
syncingOverlayView = nil
}

productImageView.contentMode = .center
if viewModel.isDraggable {
configureProductImageViewForSmallIcons()
Expand Down Expand Up @@ -225,6 +235,26 @@ private extension ProductsTabProductTableViewCell {
productImageView.addSubview(view)
productImageView.pinSubviewToAllEdges(view)
}

func configureSyncingOverlayView() {
guard syncingOverlayView == nil else {
return
}

let view = UIView(frame: .zero)
view.backgroundColor = .white.withAlphaComponent(0.7)
view.translatesAutoresizingMaskIntoConstraints = false
let activityIndicatorView = UIActivityIndicatorView(style: .medium)
activityIndicatorView.color = .black
activityIndicatorView.startAnimating()
activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(activityIndicatorView)
view.pinSubviewAtCenter(activityIndicatorView)
syncingOverlayView = view

productImageView.addSubview(view)
productImageView.pinSubviewToAllEdges(view)
}
}

/// Constants
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1347,14 +1347,16 @@ private extension ProductFormViewController {
UIAlertController.presentDiscardNewProductActionSheet(viewController: viewControllerToPresentAlert,
onSaveDraft: { [weak self] in
self?.saveProductAsDraft()
}, onDiscard: {
}, onDiscard: { [weak self] in
self?.resetProductImages()
exitForm()
}, onCancel: {
onCancel()
})
case .edit:
UIAlertController.presentDiscardChangesActionSheet(viewController: viewControllerToPresentAlert,
onDiscard: {
onDiscard: { [weak self] in
self?.resetProductImages()
exitForm()
}, onCancel: {
onCancel()
Expand All @@ -1363,6 +1365,10 @@ private extension ProductFormViewController {
break
}
}

func resetProductImages() {
productImageActionHandler.resetProductImages(to: viewModel.productModel)
}
}

// MARK: Action - Edit Product Images
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,12 @@ extension ProductFormViewModel {
}
}
case .edit:
guard hasChangesExcludingImageUploads() else {
/// Skip product update if there are no changes
saveProductImagesWhenNoneIsPendingUploadAnymore()
onCompletion(.success(product))
return
}
remoteActionUseCase.editProduct(product: productModelToSave,
originalProduct: originalProduct,
password: password,
Expand Down Expand Up @@ -655,6 +661,14 @@ extension ProductFormViewModel {
// MARK: Background image upload
//
private extension ProductFormViewModel {

func hasChangesExcludingImageUploads() -> Bool {
let hasProductChanges = product.product.copy(images: []) != originalProduct.product.copy(images: [])
let hasUploadedImageChanges = product.images.map(\.imageID) != originalProduct.images.map(\.imageID)
return hasProductChanges || hasUploadedImageChanges || password != originalPassword || isNewTemplateProduct()

}

func replaceProductID(productIDBeforeSave: Int64) {
productImagesUploader.replaceLocalID(siteID: product.siteID,
localID: .product(id: productIDBeforeSave),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ final class ProductsViewController: UIViewController, GhostableViewController {
}()

private let imageService: ImageService = ServiceLocator.imageService
private let imageUploader = ServiceLocator.productImageUploader
private var activeUploadIds: [Int64] = []

private var filters: FilterProductListViewModel.Filters = FilterProductListViewModel.Filters() {
didSet {
Expand Down Expand Up @@ -255,6 +257,7 @@ final class ProductsViewController: UIViewController, GhostableViewController {
syncProductsSettings()
observeSelectedProductAndDataLoadedStateToUpdateSelectedRow()
observeSelectedProductToAutoScrollWhenProductChanges()
observePendingImageUploads()
}

override func viewWillAppear(_ animated: Bool) {
Expand Down Expand Up @@ -1013,6 +1016,27 @@ private extension ProductsViewController {
.store(in: &subscriptions)
}

func observePendingImageUploads() {
imageUploader.activeUploads
.sink { [weak self] keys in
guard let self else { return }
let oldIDs = activeUploadIds
activeUploadIds = keys
.filter { $0.siteID == self.siteID }
.map { $0.productOrVariationID.id }

var indexPathsToReload: [IndexPath] = []
for (index, object) in resultsController.fetchedObjects.enumerated() {
if activeUploadIds.contains(object.productID) != oldIDs.contains(object.productID) {
indexPathsToReload.append(IndexPath(row: index, section: 0))
}
}

tableView.reloadRows(at: indexPathsToReload, with: .none)
}
.store(in: &subscriptions)
}

func listenToSelectedProductToAutoScrollWhenProductChanges(product: Product) {
selectedProductListener = .init(storageManager: ServiceLocator.storageManager, readOnlyEntity: product)
selectedProductListener?.onUpsert = { [weak self] product in
Expand Down Expand Up @@ -1091,7 +1115,9 @@ extension ProductsViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(ProductsTabProductTableViewCell.self, for: indexPath)
let product = resultsController.object(at: indexPath)
let viewModel = ProductsTabProductViewModel(product: product)

let hasPendingUploads = activeUploadIds.contains(where: { $0 == product.productID })
let viewModel = ProductsTabProductViewModel(product: product, hasPendingUploads: hasPendingUploads)
cell.update(viewModel: viewModel, imageService: imageService)

return cell
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ extension ProductsTabProductViewModel {
imageService = ServiceLocator.imageService
isSelected = false
isDraggable = false
/// not displaying syncing animation for variation images for now
hasPendingUploads = false
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@ struct ProductsTabProductViewModel {
let detailsAttributedString: NSAttributedString
let isSelected: Bool
let isDraggable: Bool
let hasPendingUploads: Bool

// Dependency for configuring the view.
let imageService: ImageService

init(product: Product,
hasPendingUploads: Bool = false,
productVariation: ProductVariation? = nil,
isSelected: Bool = false,
isDraggable: Bool = false,
Expand All @@ -41,6 +43,7 @@ struct ProductsTabProductViewModel {
self.productVariation = productVariation
self.isSelected = isSelected
self.isDraggable = isDraggable
self.hasPendingUploads = hasPendingUploads
detailsAttributedString = EditableProductModel(product: product).createDetailsAttributedString(isSKUShown: isSKUShown)

self.imageService = imageService
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Combine
import Yosemite
import protocol WooFoundation.Analytics

Expand All @@ -15,6 +16,10 @@ final class ProductSearchUICommand: SearchUICommand {

let cancelButtonAccessibilityIdentifier = "product-search-screen-cancel-button"

var reloadUIRequests: AnyPublisher<Void, Never> {
reloadUINeeded.eraseToAnyPublisher()
}

var resynchronizeModels: (() -> Void) = {}

private var lastSearchQueryByFilter: [ProductSearchFilter: String] = [:]
Expand All @@ -27,6 +32,12 @@ final class ProductSearchUICommand: SearchUICommand {
private let onProductSelection: (Product) -> Void
private let onCancel: () -> Void

private let imageUploader = ServiceLocator.productImageUploader
private var activeUploadIds: [Int64] = []
private var activeUploadSubscription: AnyCancellable?

private let reloadUINeeded = PassthroughSubject<Void, Never>()

init(siteID: Int64,
stores: StoresManager = ServiceLocator.stores,
analytics: Analytics = ServiceLocator.analytics,
Expand All @@ -39,6 +50,8 @@ final class ProductSearchUICommand: SearchUICommand {
self.isSearchProductsBySKUEnabled = isSearchProductsBySKUEnabled
self.onProductSelection = onProductSelection
self.onCancel = onCancel

observePendingImageUploads()
}

func createResultsController() -> ResultsController<ResultsControllerModel> {
Expand Down Expand Up @@ -98,7 +111,8 @@ final class ProductSearchUICommand: SearchUICommand {
}

func createCellViewModel(model: Product) -> ProductsTabProductViewModel {
ProductsTabProductViewModel(product: model, isSKUShown: true)
let hasPendingUploads = activeUploadIds.contains(where: { $0 == model.productID })
return ProductsTabProductViewModel(product: model, hasPendingUploads: hasPendingUploads, isSKUShown: true)
}

/// Synchronizes the Products matching a given Keyword
Expand Down Expand Up @@ -163,6 +177,17 @@ final class ProductSearchUICommand: SearchUICommand {
}

private extension ProductSearchUICommand {
func observePendingImageUploads() {
activeUploadSubscription = imageUploader.activeUploads
.sink { [weak self] keys in
guard let self else { return }
activeUploadIds = keys
.filter { $0.siteID == self.siteID }
.map { $0.productOrVariationID.id }
reloadUINeeded.send(())
}
}

func showResults(filter: ProductSearchFilter) {
guard filter != self.filter else {
return
Expand Down
Loading

0 comments on commit 437ed99

Please sign in to comment.