Skip to content

Commit

Permalink
Feat: Created ProductImageStatusStorage for storing image statuses …
Browse files Browse the repository at this point in the history
…in User Defaults. This file has been created in another branch, but given the size of the branch/PR, I'm splitting them, and I renamed the class from `ProductImagesUserDefaultsStatuses` to `ProductImageStatusStorage`.
  • Loading branch information
pmusolino committed Feb 26, 2025
1 parent 7e44604 commit 4b7111e
Show file tree
Hide file tree
Showing 2 changed files with 187 additions and 0 deletions.
4 changes: 4 additions & 0 deletions Networking/Networking.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,7 @@
451A9836260B9DF90059D135 /* ShippingLabelPackagesMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 451A9835260B9DF90059D135 /* ShippingLabelPackagesMapperTests.swift */; };
4523FB872D596F8000FD1328 /* order-shipping-labels-with-error-in-labels-data.json in Resources */ = {isa = PBXBuildFile; fileRef = 4523FB862D596F6300FD1328 /* order-shipping-labels-with-error-in-labels-data.json */; };
4524CD9C242CEFAB00B2F20A /* product-on-sale-with-empty-sale-price.json in Resources */ = {isa = PBXBuildFile; fileRef = 4524CD9B242CEFAB00B2F20A /* product-on-sale-with-empty-sale-price.json */; };
452EDBD42D6F4F47003A96BC /* ProductImageStatusStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 452EDBD32D6F4F46003A96BC /* ProductImageStatusStorage.swift */; };
453305E92459DF2100264E50 /* PostMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 453305E82459DF2100264E50 /* PostMapper.swift */; };
453305EB2459E01A00264E50 /* PostMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 453305EA2459E01A00264E50 /* PostMapperTests.swift */; };
453305ED2459E1AA00264E50 /* site-post.json in Resources */ = {isa = PBXBuildFile; fileRef = 453305EC2459E1AA00264E50 /* site-post.json */; };
Expand Down Expand Up @@ -1676,6 +1677,7 @@
451A9835260B9DF90059D135 /* ShippingLabelPackagesMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelPackagesMapperTests.swift; sourceTree = "<group>"; };
4523FB862D596F6300FD1328 /* order-shipping-labels-with-error-in-labels-data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "order-shipping-labels-with-error-in-labels-data.json"; sourceTree = "<group>"; };
4524CD9B242CEFAB00B2F20A /* product-on-sale-with-empty-sale-price.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "product-on-sale-with-empty-sale-price.json"; sourceTree = "<group>"; };
452EDBD32D6F4F46003A96BC /* ProductImageStatusStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductImageStatusStorage.swift; sourceTree = "<group>"; };
453305E82459DF2100264E50 /* PostMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostMapper.swift; sourceTree = "<group>"; };
453305EA2459E01A00264E50 /* PostMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostMapperTests.swift; sourceTree = "<group>"; };
453305EC2459E1AA00264E50 /* site-post.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "site-post.json"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2710,6 +2712,7 @@
children = (
4587D1142D64CBC2001971E4 /* ProductImageStatus.swift */,
4587D11E2D64D886001971E4 /* ProductOrVariationID.swift */,
452EDBD32D6F4F46003A96BC /* ProductImageStatusStorage.swift */,
);
path = ProductImageInBackground;
sourceTree = "<group>";
Expand Down Expand Up @@ -5501,6 +5504,7 @@
DE8BEF0B2D141DD9008B3A3F /* NotificationSettings.swift in Sources */,
D88D5A49230BC8C7007B6E01 /* ProductReviewStatus.swift in Sources */,
036563DB2906938600D84BFD /* JustInTimeMessage.swift in Sources */,
452EDBD42D6F4F47003A96BC /* ProductImageStatusStorage.swift in Sources */,
CCA1D60429437B2C00B40560 /* SiteSummaryStats.swift in Sources */,
451A97C92609FF050059D135 /* ShippingLabelPackagesResponse.swift in Sources */,
029BA4F0255D7282006171FD /* ShippingLabelRemote.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import Foundation
import Combine

/// Save product image upload statuses in User Defaults.
/// This class is declared in the Networking layer because it will also be accessed by the background URLSession operations.
/// This class avoid KVO, and uses Combine to handle notifications, and efficiently checks for actual data changes.
/// We're not using KVO, because `ProductImageStatus` use a swift type (enum), not compatible with Obj-C.
///
public final class ProductImageStatusStorage {
private let key: String
private let userDefaults: UserDefaults

// Private internal instance for static methods
private static let internalInstance = ProductImageStatusStorage()

// Publisher for observing changes
private let statusesSubject: CurrentValueSubject<[ProductImageStatus], Never>
private var cancellables = Set<AnyCancellable>()

private let encoder: JSONEncoder
private let decoder: JSONDecoder

public var statusesPublisher: AnyPublisher<[ProductImageStatus], Never> {
statusesSubject
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}

public var errorsPublisher: AnyPublisher<(siteID: Int64,
productOrVariationID: ProductOrVariationID?,
assetType: ProductImageAssetType?,
error: Error), Never> {
statusesSubject
.compactMap { statuses in
statuses.first { $0.isUploadFailure }
}
.compactMap { status in
if let error = status.error {
return (siteID: status.siteID,
productOrVariationID: status.productOrVariationID,
assetType: status.assetType,
error: error)
}
return nil
}
.eraseToAnyPublisher()
}

public init(userDefaults: UserDefaults = .standard,
key: String = "savedProductUploadImageStatuses",
encoder: JSONEncoder = JSONEncoder(),
decoder: JSONDecoder = JSONDecoder()) {
self.key = key
self.userDefaults = userDefaults
self.encoder = encoder
self.decoder = decoder
self.encoder.dateEncodingStrategy = .iso8601
self.decoder.dateDecodingStrategy = .iso8601
self.statusesSubject = CurrentValueSubject(Self.loadStatuses(from: userDefaults, key: key, decoder: decoder))
setupObservers()
}

public func addStatus(_ status: ProductImageStatus) {
var current = statusesSubject.value
current.append(status)
saveStatuses(current)
}

public func removeStatus(_ status: ProductImageStatus) {
let current = statusesSubject.value.filter { $0 != status }
saveStatuses(current)
}

public func removeStatus(where predicate: (ProductImageStatus) -> Bool) {
let current = statusesSubject.value.filter { !predicate($0) }
saveStatuses(current)
}

public func updateStatus(_ status: ProductImageStatus) {
var current = statusesSubject.value
if let index = current.firstIndex(where: { $0 == status }) {
current[index] = status
saveStatuses(current)
} else {
addStatus(status)
}
}

public func findStatus(where predicate: (ProductImageStatus) -> Bool) -> ProductImageStatus? {
statusesSubject.value.first(where: predicate)
}

public func getAllStatuses() -> [ProductImageStatus] {
statusesSubject.value
}

public func getAllStatuses(for siteID: Int64, productID: ProductOrVariationID?) -> [ProductImageStatus] {
statusesSubject.value.filter {
switch $0 {
case .uploading(_, let sID, let pID),
.uploadFailure(_, _, let sID, let pID):
return sID == siteID && (productID == nil || pID == productID)
case .remote(_, let sID, let pID):
return sID == siteID && pID == productID
}
}
}

public func clearAllStatuses() {
userDefaults.removeObject(forKey: key)
userDefaults.synchronize()
statusesSubject.send([])
}

public func setAllStatuses(_ statuses: [ProductImageStatus]) {
saveStatuses(statuses)
}

public func setAllStatuses(_ statuses: [ProductImageStatus], for siteID: Int64, productID: ProductOrVariationID?) {
var filtered = statusesSubject.value.filter {
switch $0 {
case .uploading(_, let sID, let pID),
.uploadFailure(_, _, let sID, let pID):
return sID != siteID || (productID != nil && pID != productID)
case .remote(_, let sID, let pID):
return sID != siteID || pID != productID
}
}
filtered.append(contentsOf: statuses)
saveStatuses(filtered)
}

private func saveStatuses(_ statuses: [ProductImageStatus]) {
do {
let data = try encoder.encode(statuses)
userDefaults.set(data, forKey: key)
userDefaults.synchronize()
statusesSubject.send(statuses)
} catch {
print("Encoding error in ProductImageStatusStorage: \(error)")
}
}

private static func loadStatuses(from userDefaults: UserDefaults, key: String, decoder: JSONDecoder = JSONDecoder()) -> [ProductImageStatus] {
guard let data = userDefaults.data(forKey: key) else { return [] }
do {
return try decoder.decode([ProductImageStatus].self, from: data)
} catch {
print("Decoding error in ProductImageStatusStorage: \(error)")
return []
}
}
}

// Private utility methods to observe changes in UserDefaults
private extension ProductImageStatusStorage {
func setupObservers() {
NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification, object: userDefaults)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.checkForExternalUpdates()
}
.store(in: &cancellables)
}

func checkForExternalUpdates() {
let newStatuses = Self.loadStatuses(from: userDefaults, key: key, decoder: decoder)

// Deep comparison
let hasChanges = !newStatuses.elementsEqual(statusesSubject.value) { lhs, rhs in
switch (lhs, rhs) {
case (.remote(let lImg, let lSite, let lProd), .remote(let rImg, let rSite, let rProd)):
return lImg == rImg && lSite == rSite && lProd == rProd
default:
return lhs == rhs
}
}

if hasChanges {
statusesSubject.send(newStatuses)
}
}
}

0 comments on commit 4b7111e

Please sign in to comment.