Skip to content

Commit 437ed99

Browse files
authored
Product List: Show syncing animation for items with active image uploads (#15052)
2 parents 3159df9 + 00bed60 commit 437ed99

14 files changed

+315
-14
lines changed

RELEASE-NOTES.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
-----
66
- [*] Now "Suggested by AI" label is visible in dark mode in Blaze campaign creation flow. [https://github.com/woocommerce/woocommerce-ios/pull/15088]
77
- [*] 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]
8+
- [*] Product List: Display syncing animation on items with image upload in progress [https://github.com/woocommerce/woocommerce-ios/pull/15052]
89

910
21.7
1011
-----

WooCommerce/Classes/ServiceLocator/ProductImageUploader.swift

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ struct ProductImageUploaderKey: Equatable, Hashable {
3636

3737
/// Handles product image upload to support background image upload.
3838
protocol ProductImageUploaderProtocol {
39+
40+
/// Emits active image uploads
41+
var activeUploads: AnyPublisher<[ProductImageUploaderKey], Never> { get }
42+
3943
/// Emits product image upload errors.
4044
var errors: AnyPublisher<ProductImageUploadErrorInfo, Never> { get }
4145

@@ -94,6 +98,10 @@ final class ProductImageUploader: ProductImageUploaderProtocol {
9498
errorsSubject.eraseToAnyPublisher()
9599
}
96100

101+
var activeUploads: AnyPublisher<[ProductImageUploaderKey], Never> {
102+
$activeUploadsPublisher.eraseToAnyPublisher()
103+
}
104+
97105
typealias Key = ProductImageUploaderKey
98106

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

103111
private var actionHandlersByProduct: [Key: ProductImageActionHandler] = [:]
104112
private var imagesSaverByProduct: [Key: ProductImagesSaver] = [:]
113+
private var initialStatusesByProduct: [Key: [ProductImageStatus]] = [:]
114+
115+
@Published private var activeUploadsPublisher: [ProductImageUploaderKey] = []
116+
105117
private let stores: StoresManager
106118
private let imagesProductIDUpdater: ProductImagesProductIDUpdaterProtocol
107119

@@ -118,8 +130,10 @@ final class ProductImageUploader: ProductImageUploaderProtocol {
118130
} else {
119131
actionHandler = ProductImageActionHandler(siteID: key.siteID, productID: key.productOrVariationID, imageStatuses: originalStatuses, stores: stores)
120132
actionHandlersByProduct[key] = actionHandler
121-
observeStatusUpdatesForErrors(key: key, actionHandler: actionHandler)
133+
initialStatusesByProduct[key] = originalStatuses
134+
observeStatusUpdates(key: key, actionHandler: actionHandler)
122135
}
136+
123137
return actionHandler
124138
}
125139

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

179194
guard let handler = actionHandlersByProduct[key] else {
195+
removeProductFromActiveUploads(key: key)
180196
return onProductSave(.failure(ProductImageUploaderError.noActionHandlerFound))
181197
}
182198

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

193209
imagesSaver.saveProductImagesWhenNoneIsPendingUploadAnymore(imageActionHandler: handler) { [weak self] result in
194210
guard let self = self else { return }
211+
removeProductFromActiveUploads(key: key)
195212
onProductSave(result)
196213
if case let .failure(error) = result {
197214
self.errorsSubject.send(.init(siteID: key.siteID,
@@ -208,9 +225,11 @@ final class ProductImageUploader: ProductImageUploaderProtocol {
208225
func reset() {
209226
statusUpdatesExcludedProductKeys = []
210227
statusUpdatesSubscriptions = []
228+
activeUploadsPublisher = []
211229

212230
actionHandlersByProduct = [:]
213231
imagesSaverByProduct = [:]
232+
initialStatusesByProduct = [:]
214233
}
215234
}
216235

@@ -229,19 +248,34 @@ private extension ProductImageUploader {
229248
}
230249
}
231250

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

236-
if let error = error, self.statusUpdatesExcludedProductKeys.contains(key) == false {
237-
self.errorsSubject.send(.init(siteID: key.siteID,
238-
productOrVariationID: key.productOrVariationID,
239-
productImageStatuses: productImageStatuses,
240-
error: .failedUploadingImage(error: error)))
255+
if !activeUploadsPublisher.contains(key), productImageStatuses.hasPendingUpload {
256+
activeUploadsPublisher.append(key)
257+
} else if let initialStatuses = initialStatusesByProduct[key],
258+
initialStatuses == productImageStatuses,
259+
activeUploadsPublisher.contains(key) {
260+
/// When upload is reset, remove the key from active uploads
261+
removeProductFromActiveUploads(key: key)
262+
}
263+
264+
if let error = error, statusUpdatesExcludedProductKeys.contains(key) == false {
265+
removeProductFromActiveUploads(key: key)
266+
errorsSubject.send(.init(siteID: key.siteID,
267+
productOrVariationID: key.productOrVariationID,
268+
productImageStatuses: productImageStatuses,
269+
error: .failedUploadingImage(error: error)))
241270
}
242271
}
243272
statusUpdatesSubscriptions.insert(observationToken)
244273
}
274+
275+
func removeProductFromActiveUploads(key: Key) {
276+
activeUploadsPublisher.removeAll(where: { $0 == key })
277+
initialStatusesByProduct.removeValue(forKey: key)
278+
}
245279
}
246280

247281
private extension ProductOrVariationID {

WooCommerce/Classes/ViewRelated/Products/Cells/ProductsTabProductTableViewCell.swift

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ final class ProductsTabProductTableViewCell: UITableViewCell {
88

99
private var selectedProductImageOverlayView: UIView?
1010

11+
private var syncingOverlayView: UIView?
12+
1113
/// ProductImageView.width == 0.1*Cell.width
1214
private var productImageViewRelationalWidthConstraint: NSLayoutConstraint?
1315

@@ -71,11 +73,19 @@ extension ProductsTabProductTableViewCell: SearchResultCell {
7173
}
7274

7375
extension ProductsTabProductTableViewCell {
76+
7477
func update(viewModel: ProductsTabProductViewModel, imageService: ImageService) {
7578
nameLabel.text = viewModel.createNameLabel()
7679
detailsLabel.attributedText = viewModel.detailsAttributedString
7780
accessibilityIdentifier = viewModel.createNameLabel()
7881

82+
if viewModel.hasPendingUploads {
83+
configureSyncingOverlayView()
84+
} else {
85+
syncingOverlayView?.removeFromSuperview()
86+
syncingOverlayView = nil
87+
}
88+
7989
productImageView.contentMode = .center
8090
if viewModel.isDraggable {
8191
configureProductImageViewForSmallIcons()
@@ -225,6 +235,26 @@ private extension ProductsTabProductTableViewCell {
225235
productImageView.addSubview(view)
226236
productImageView.pinSubviewToAllEdges(view)
227237
}
238+
239+
func configureSyncingOverlayView() {
240+
guard syncingOverlayView == nil else {
241+
return
242+
}
243+
244+
let view = UIView(frame: .zero)
245+
view.backgroundColor = .white.withAlphaComponent(0.7)
246+
view.translatesAutoresizingMaskIntoConstraints = false
247+
let activityIndicatorView = UIActivityIndicatorView(style: .medium)
248+
activityIndicatorView.color = .black
249+
activityIndicatorView.startAnimating()
250+
activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
251+
view.addSubview(activityIndicatorView)
252+
view.pinSubviewAtCenter(activityIndicatorView)
253+
syncingOverlayView = view
254+
255+
productImageView.addSubview(view)
256+
productImageView.pinSubviewToAllEdges(view)
257+
}
228258
}
229259

230260
/// Constants

WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormViewController.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1347,14 +1347,16 @@ private extension ProductFormViewController {
13471347
UIAlertController.presentDiscardNewProductActionSheet(viewController: viewControllerToPresentAlert,
13481348
onSaveDraft: { [weak self] in
13491349
self?.saveProductAsDraft()
1350-
}, onDiscard: {
1350+
}, onDiscard: { [weak self] in
1351+
self?.resetProductImages()
13511352
exitForm()
13521353
}, onCancel: {
13531354
onCancel()
13541355
})
13551356
case .edit:
13561357
UIAlertController.presentDiscardChangesActionSheet(viewController: viewControllerToPresentAlert,
1357-
onDiscard: {
1358+
onDiscard: { [weak self] in
1359+
self?.resetProductImages()
13581360
exitForm()
13591361
}, onCancel: {
13601362
onCancel()
@@ -1363,6 +1365,10 @@ private extension ProductFormViewController {
13631365
break
13641366
}
13651367
}
1368+
1369+
func resetProductImages() {
1370+
productImageActionHandler.resetProductImages(to: viewModel.productModel)
1371+
}
13661372
}
13671373

13681374
// MARK: Action - Edit Product Images

WooCommerce/Classes/ViewRelated/Products/Edit Product/ProductFormViewModel.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,12 @@ extension ProductFormViewModel {
601601
}
602602
}
603603
case .edit:
604+
guard hasChangesExcludingImageUploads() else {
605+
/// Skip product update if there are no changes
606+
saveProductImagesWhenNoneIsPendingUploadAnymore()
607+
onCompletion(.success(product))
608+
return
609+
}
604610
remoteActionUseCase.editProduct(product: productModelToSave,
605611
originalProduct: originalProduct,
606612
password: password,
@@ -655,6 +661,14 @@ extension ProductFormViewModel {
655661
// MARK: Background image upload
656662
//
657663
private extension ProductFormViewModel {
664+
665+
func hasChangesExcludingImageUploads() -> Bool {
666+
let hasProductChanges = product.product.copy(images: []) != originalProduct.product.copy(images: [])
667+
let hasUploadedImageChanges = product.images.map(\.imageID) != originalProduct.images.map(\.imageID)
668+
return hasProductChanges || hasUploadedImageChanges || password != originalPassword || isNewTemplateProduct()
669+
670+
}
671+
658672
func replaceProductID(productIDBeforeSave: Int64) {
659673
productImagesUploader.replaceLocalID(siteID: product.siteID,
660674
localID: .product(id: productIDBeforeSave),

WooCommerce/Classes/ViewRelated/Products/ProductsViewController.swift

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,8 @@ final class ProductsViewController: UIViewController, GhostableViewController {
159159
}()
160160

161161
private let imageService: ImageService = ServiceLocator.imageService
162+
private let imageUploader = ServiceLocator.productImageUploader
163+
private var activeUploadIds: [Int64] = []
162164

163165
private var filters: FilterProductListViewModel.Filters = FilterProductListViewModel.Filters() {
164166
didSet {
@@ -255,6 +257,7 @@ final class ProductsViewController: UIViewController, GhostableViewController {
255257
syncProductsSettings()
256258
observeSelectedProductAndDataLoadedStateToUpdateSelectedRow()
257259
observeSelectedProductToAutoScrollWhenProductChanges()
260+
observePendingImageUploads()
258261
}
259262

260263
override func viewWillAppear(_ animated: Bool) {
@@ -1013,6 +1016,27 @@ private extension ProductsViewController {
10131016
.store(in: &subscriptions)
10141017
}
10151018

1019+
func observePendingImageUploads() {
1020+
imageUploader.activeUploads
1021+
.sink { [weak self] keys in
1022+
guard let self else { return }
1023+
let oldIDs = activeUploadIds
1024+
activeUploadIds = keys
1025+
.filter { $0.siteID == self.siteID }
1026+
.map { $0.productOrVariationID.id }
1027+
1028+
var indexPathsToReload: [IndexPath] = []
1029+
for (index, object) in resultsController.fetchedObjects.enumerated() {
1030+
if activeUploadIds.contains(object.productID) != oldIDs.contains(object.productID) {
1031+
indexPathsToReload.append(IndexPath(row: index, section: 0))
1032+
}
1033+
}
1034+
1035+
tableView.reloadRows(at: indexPathsToReload, with: .none)
1036+
}
1037+
.store(in: &subscriptions)
1038+
}
1039+
10161040
func listenToSelectedProductToAutoScrollWhenProductChanges(product: Product) {
10171041
selectedProductListener = .init(storageManager: ServiceLocator.storageManager, readOnlyEntity: product)
10181042
selectedProductListener?.onUpsert = { [weak self] product in
@@ -1091,7 +1115,9 @@ extension ProductsViewController: UITableViewDataSource {
10911115
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
10921116
let cell = tableView.dequeueReusableCell(ProductsTabProductTableViewCell.self, for: indexPath)
10931117
let product = resultsController.object(at: indexPath)
1094-
let viewModel = ProductsTabProductViewModel(product: product)
1118+
1119+
let hasPendingUploads = activeUploadIds.contains(where: { $0 == product.productID })
1120+
let viewModel = ProductsTabProductViewModel(product: product, hasPendingUploads: hasPendingUploads)
10951121
cell.update(viewModel: viewModel, imageService: imageService)
10961122

10971123
return cell

WooCommerce/Classes/ViewRelated/Products/Variations/ProductsTabProductViewModel+ProductVariation.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ extension ProductsTabProductViewModel {
1212
imageService = ServiceLocator.imageService
1313
isSelected = false
1414
isDraggable = false
15+
/// not displaying syncing animation for variation images for now
16+
hasPendingUploads = false
1517
}
1618
}
1719

WooCommerce/Classes/ViewRelated/Products/View Models/ProductsTabProductViewModel.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,13 @@ struct ProductsTabProductViewModel {
2525
let detailsAttributedString: NSAttributedString
2626
let isSelected: Bool
2727
let isDraggable: Bool
28+
let hasPendingUploads: Bool
2829

2930
// Dependency for configuring the view.
3031
let imageService: ImageService
3132

3233
init(product: Product,
34+
hasPendingUploads: Bool = false,
3335
productVariation: ProductVariation? = nil,
3436
isSelected: Bool = false,
3537
isDraggable: Bool = false,
@@ -41,6 +43,7 @@ struct ProductsTabProductViewModel {
4143
self.productVariation = productVariation
4244
self.isSelected = isSelected
4345
self.isDraggable = isDraggable
46+
self.hasPendingUploads = hasPendingUploads
4447
detailsAttributedString = EditableProductModel(product: product).createDetailsAttributedString(isSKUShown: isSKUShown)
4548

4649
self.imageService = imageService

WooCommerce/Classes/ViewRelated/Search/Product/ProductSearchUICommand.swift

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import Combine
12
import Yosemite
23
import protocol WooFoundation.Analytics
34

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

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

19+
var reloadUIRequests: AnyPublisher<Void, Never> {
20+
reloadUINeeded.eraseToAnyPublisher()
21+
}
22+
1823
var resynchronizeModels: (() -> Void) = {}
1924

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

35+
private let imageUploader = ServiceLocator.productImageUploader
36+
private var activeUploadIds: [Int64] = []
37+
private var activeUploadSubscription: AnyCancellable?
38+
39+
private let reloadUINeeded = PassthroughSubject<Void, Never>()
40+
3041
init(siteID: Int64,
3142
stores: StoresManager = ServiceLocator.stores,
3243
analytics: Analytics = ServiceLocator.analytics,
@@ -39,6 +50,8 @@ final class ProductSearchUICommand: SearchUICommand {
3950
self.isSearchProductsBySKUEnabled = isSearchProductsBySKUEnabled
4051
self.onProductSelection = onProductSelection
4152
self.onCancel = onCancel
53+
54+
observePendingImageUploads()
4255
}
4356

4457
func createResultsController() -> ResultsController<ResultsControllerModel> {
@@ -98,7 +111,8 @@ final class ProductSearchUICommand: SearchUICommand {
98111
}
99112

100113
func createCellViewModel(model: Product) -> ProductsTabProductViewModel {
101-
ProductsTabProductViewModel(product: model, isSKUShown: true)
114+
let hasPendingUploads = activeUploadIds.contains(where: { $0 == model.productID })
115+
return ProductsTabProductViewModel(product: model, hasPendingUploads: hasPendingUploads, isSKUShown: true)
102116
}
103117

104118
/// Synchronizes the Products matching a given Keyword
@@ -163,6 +177,17 @@ final class ProductSearchUICommand: SearchUICommand {
163177
}
164178

165179
private extension ProductSearchUICommand {
180+
func observePendingImageUploads() {
181+
activeUploadSubscription = imageUploader.activeUploads
182+
.sink { [weak self] keys in
183+
guard let self else { return }
184+
activeUploadIds = keys
185+
.filter { $0.siteID == self.siteID }
186+
.map { $0.productOrVariationID.id }
187+
reloadUINeeded.send(())
188+
}
189+
}
190+
166191
func showResults(filter: ProductSearchFilter) {
167192
guard filter != self.filter else {
168193
return

0 commit comments

Comments
 (0)