Skip to content

Commit cbe632f

Browse files
authored
Merge branch 'trunk' into woomob-935-woo-pos-hack-week-pos-modularization-shared-ui-components
2 parents e07e99c + 5719ed7 commit cbe632f

File tree

67 files changed

+1591
-220
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

67 files changed

+1591
-220
lines changed

Modules/Sources/Experiments/DefaultFeatureFlagService.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ public struct DefaultFeatureFlagService: FeatureFlagService {
104104
return buildConfig == .localDeveloper || buildConfig == .alpha
105105
case .pointOfSaleLocalCatalogi1:
106106
return buildConfig == .localDeveloper || buildConfig == .alpha
107+
case .ciabBookings:
108+
return buildConfig == .localDeveloper || buildConfig == .alpha
107109
default:
108110
return true
109111
}

Modules/Sources/Experiments/FeatureFlag.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,4 +215,8 @@ public enum FeatureFlag: Int {
215215
/// It syncs products and variations to local storage and display them in POS for quick access.
216216
///
217217
case pointOfSaleLocalCatalogi1
218+
219+
/// Enables a new Bookings tab for CIAB sites
220+
///
221+
case ciabBookings
218222
}

Modules/Sources/Networking/Model/Product/Product.swift

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -457,7 +457,7 @@ public struct Product: Codable, GeneratedCopiable, Equatable, GeneratedFakeable
457457
// `true`
458458
value.lowercased() == Values.manageStockParent ? true : false
459459
})
460-
]) ?? false
460+
]) ?? false
461461

462462
// Even though WooCommerce Core returns Int or null values,
463463
// some plugins alter the field value from Int to Decimal or String.
@@ -535,15 +535,17 @@ public struct Product: Codable, GeneratedCopiable, Equatable, GeneratedFakeable
535535
let menuOrder = try container.decode(Int.self, forKey: .menuOrder)
536536

537537
// Filter out metadata if the key is prefixed with an underscore (internal meta keys)
538-
let customFields = (try? container.decode([MetaData].self, forKey: .metadata).filter({ !$0.key.hasPrefix("_")})) ?? []
538+
// Support both array format and object keyed by index strings
539+
let allMetaData = [MetaData].decodeFlexibly(from: container, forKey: .metadata)
540+
let customFields = allMetaData.filter { !$0.key.hasPrefix("_") }
539541

540542
// In some isolated cases, it appears to be some malformed meta-data that causes this line to throw hence the whole product decoding to throw.
541543
// Since add-ons are optional, `try?` will be used to prevent the whole decoding to stop.
542544
// https://github.com/woocommerce/woocommerce-ios/issues/4205
543545
let addOns = (try? container.decodeIfPresent(ProductAddOnEnvelope.self, forKey: .metadata)?.revolve()) ?? []
544546

545-
let metaDataExtractor = try? container.decodeIfPresent(ProductMetadataExtractor.self, forKey: .metadata)
546-
let isSampleItem = (metaDataExtractor?.extractStringValue(forKey: MetadataKeys.headStartPost) == Values.headStartValue)
547+
let metaDataExtractor = ProductMetadataExtractor(metadata: allMetaData)
548+
let isSampleItem = (metaDataExtractor.extractStringValue(forKey: MetadataKeys.headStartPost) == Values.headStartValue)
547549

548550
// Product Bundle properties
549551
// Uses failsafe decoding because non-bundle product types can return unexpected value types.
@@ -561,7 +563,7 @@ public struct Product: Codable, GeneratedCopiable, Equatable, GeneratedFakeable
561563
let compositeComponents = try container.decodeIfPresent([ProductCompositeComponent].self, forKey: .compositeComponents) ?? []
562564

563565
// Subscription properties
564-
let subscription = try? metaDataExtractor?.extractProductSubscription()
566+
let subscription = try? metaDataExtractor.extractProductSubscription()
565567

566568
// Min/Max Quantities properties
567569
let minAllowedQuantity = container.failsafeDecodeIfPresent(stringForKey: .minAllowedQuantity)

Modules/Sources/Networking/Model/Product/ProductMetadataExtractor.swift

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Foundation
22
import WordPressShared
3+
import NetworkingCore
34

45
/// Helper to extract specific data from inside `Product` metadata.
56
/// Sample Json:
@@ -21,25 +22,23 @@ import WordPressShared
2122
/// }
2223
/// ]
2324
///
24-
internal struct ProductMetadataExtractor: Decodable {
25+
struct ProductMetadataExtractor {
2526

26-
private typealias DecodableDictionary = [String: AnyDecodable]
2727
private typealias AnyDictionary = [String: Any?]
2828

2929
/// Internal metadata representation
3030
///
31-
private let metadata: [DecodableDictionary]
31+
private let metadata: [MetaData]
3232

33-
/// Decode main metadata array as an untyped dictionary.
33+
/// Initialize with already-decoded metadata array
3434
///
35-
init(from decoder: Decoder) throws {
36-
let container = try decoder.singleValueContainer()
37-
self.metadata = try container.decode([DecodableDictionary].self)
35+
init(metadata: [MetaData]) {
36+
self.metadata = metadata
3837
}
3938

4039
/// Searches product metadata for subscription data and converts it to a `ProductSubscription` if possible.
4140
///
42-
internal func extractProductSubscription() throws -> ProductSubscription? {
41+
func extractProductSubscription() throws -> ProductSubscription? {
4342
let subscriptionMetadata = filterMetadata(with: Constants.subscriptionPrefix)
4443

4544
guard !subscriptionMetadata.isEmpty else {
@@ -54,30 +53,33 @@ internal struct ProductMetadataExtractor: Decodable {
5453

5554
/// Extracts a `String` metadata value for the provided key.
5655
///
57-
internal func extractStringValue(forKey key: String) -> String? {
56+
func extractStringValue(forKey key: String) -> String? {
5857
let metaData = filterMetadata(with: key)
5958
let keyValueMetadata = getKeyValueDictionary(from: metaData)
6059
return keyValueMetadata.valueAsString(forKey: key)
6160
}
6261

6362
/// Filters product metadata using the provided prefix.
6463
///
65-
private func filterMetadata(with prefix: String) -> [DecodableDictionary] {
66-
metadata.filter { object in
67-
let objectKey = object["key"]?.value as? String ?? ""
68-
return objectKey.hasPrefix(prefix)
69-
}
64+
private func filterMetadata(with prefix: String) -> [MetaData] {
65+
metadata.filter { $0.key.hasPrefix(prefix) }
7066
}
7167

7268
/// Parses provided metadata to return a dictionary with each metadata object's key and value.
7369
///
74-
private func getKeyValueDictionary(from metadata: [DecodableDictionary]) -> AnyDictionary {
75-
metadata.reduce(AnyDictionary()) { (dict, object) in
76-
var newDict = dict
77-
let objectKey = object["key"]?.value as? String ?? ""
78-
let objectValue = object["value"]?.value
79-
newDict.updateValue(objectValue, forKey: objectKey)
80-
return newDict
70+
private func getKeyValueDictionary(from metadata: [MetaData]) -> AnyDictionary {
71+
metadata.reduce(into: AnyDictionary()) { dict, object in
72+
// For JSON values, decode them to get the actual object
73+
if object.value.isJson {
74+
if let data = object.value.stringValue.data(using: .utf8),
75+
let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) {
76+
dict[object.key] = jsonObject
77+
} else {
78+
dict[object.key] = object.value.stringValue
79+
}
80+
} else {
81+
dict[object.key] = object.value.stringValue
82+
}
8183
}
8284
}
8385

Modules/Sources/Networking/Model/Product/ProductVariation.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ public struct ProductVariation: Codable, GeneratedCopiable, Equatable, Generated
279279
}
280280
return false
281281
})
282-
]) ?? false
282+
]) ?? false
283283

284284
// Even though WooCommerce Core returns Int or null values,
285285
// some plugins alter the field value from Int to Decimal or String.
@@ -309,7 +309,9 @@ public struct ProductVariation: Codable, GeneratedCopiable, Equatable, Generated
309309
let menuOrder = try container.decode(Int64.self, forKey: .menuOrder)
310310

311311
// Subscription settings for subscription variations
312-
let subscription = try? container.decodeIfPresent(ProductMetadataExtractor.self, forKey: .metadata)?.extractProductSubscription()
312+
let allMetaData = [MetaData].decodeFlexibly(from: container, forKey: .metadata)
313+
let metaDataExtractor = ProductMetadataExtractor(metadata: allMetaData)
314+
let subscription = try? metaDataExtractor.extractProductSubscription()
313315

314316
// Min/Max Quantities properties
315317
let minAllowedQuantity = container.failsafeDecodeIfPresent(stringForKey: .minAllowedQuantity)

Modules/Sources/Networking/Remote/ProductsRemote.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ public protocol ProductsRemoteProtocol {
4848
pageNumber: Int,
4949
pageSize: Int,
5050
productStatus: ProductStatus?,
51+
productType: ProductType?,
5152
completion: @escaping (Result<[Int64], Error>) -> Void)
5253
func loadNumberOfProducts(siteID: Int64) async throws -> Int64
5354

@@ -593,12 +594,14 @@ public final class ProductsRemote: Remote, ProductsRemoteProtocol {
593594
pageNumber: Int = Default.pageNumber,
594595
pageSize: Int = Default.pageSize,
595596
productStatus: ProductStatus? = nil,
597+
productType: ProductType? = nil,
596598
completion: @escaping (Result<[Int64], Error>) -> Void) {
597599
let parameters = [
598600
ParameterKey.page: String(pageNumber),
599601
ParameterKey.perPage: String(pageSize),
600602
ParameterKey.fields: ParameterKey.id,
601-
ParameterKey.productStatus: productStatus?.rawValue ?? ""
603+
ParameterKey.productStatus: productStatus?.rawValue ?? "",
604+
ParameterKey.productType: productType?.rawValue ?? ""
602605
].filter({ $0.value.isEmpty == false })
603606

604607
let path = Path.products
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import Foundation
2+
3+
/// Extension to support flexible decoding of MetaData arrays
4+
/// Handles both standard array format and object format keyed by index strings
5+
extension Array where Element == MetaData {
6+
7+
/// Custom decoding from container that supports both array and dictionary formats
8+
public static func decodeFlexibly<K>(from container: KeyedDecodingContainer<K>,
9+
forKey key: KeyedDecodingContainer<K>.Key) -> [MetaData] {
10+
// Try to decode as array first (standard format)
11+
if let metaDataArray = try? container.decode([MetaData].self, forKey: key) {
12+
return metaDataArray
13+
}
14+
15+
// Try to decode as object keyed by index strings
16+
if let metaDataDict = try? container.decode([String: MetaData].self, forKey: key) {
17+
return Array(metaDataDict.values)
18+
}
19+
20+
// Fallback to empty array
21+
DDLogWarn("⚠️ Could not decode metadata as either an array or object keyed by index strings. Falling back to empty array.")
22+
return []
23+
}
24+
}

Modules/Sources/NetworkingCore/Model/Order.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,10 @@ public struct Order: Decodable, Sendable, GeneratedCopiable, GeneratedFakeable {
201201
// "payment_url" is only available on stores with version >= 6.4
202202
let paymentURL = try container.decodeIfPresent(URL.self, forKey: .paymentURL)
203203

204-
let allOrderMetaData = try? container.decode([MetaData].self, forKey: .metadata)
204+
let allOrderMetaData: [MetaData]? = {
205+
let metadata = [MetaData].decodeFlexibly(from: container, forKey: .metadata)
206+
return metadata.isEmpty ? nil : metadata
207+
}()
205208
var chargeID: String? = nil
206209
chargeID = allOrderMetaData?.first(where: { $0.key == "_charge_id" })?.value.stringValue
207210

Modules/Sources/NetworkingCore/Network/AlamofireNetwork.swift

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -57,21 +57,25 @@ public class AlamofireNetwork: Network {
5757

5858
public var session: URLSession { Session.default.session }
5959

60-
private var subscription: AnyCancellable?
60+
private var siteSubscription: AnyCancellable?
6161

6262
/// Thread-safe error handler for failure tracking and retry logic
6363
private let errorHandler: AlamofireNetworkErrorHandler
6464

65+
private var appPasswordSupportSubscription: AnyCancellable?
66+
6567
/// Public Initializer
6668
///
6769
/// - Parameters:
6870
/// - credentials: Authentication credentials for requests.
6971
/// - selectedSite: Publisher for site selection changes.
72+
/// This is necessary if you wish to enable network switching to direct requests while authenticated with WPCOM for better performance.
7073
/// - sessionManager: Optional pre-configured session manager.
7174
/// - ensuresSessionManagerIsInitialized: If true, the session is always set during initialization immediately to avoid lazy initialization race conditions.
7275
/// Defaults to false for backward compatibility. Set to true when making concurrent requests immediately after initialization.
7376
public required init(credentials: Credentials?,
74-
selectedSite: AnyPublisher<JetpackSite?, Never>? = nil,
77+
selectedSite: AnyPublisher<JetpackSite?, Never>?,
78+
appPasswordSupportState: AnyPublisher<Bool, Never>?,
7579
userDefaults: UserDefaults = .standard,
7680
sessionManager: Alamofire.Session? = nil,
7781
ensuresSessionManagerIsInitialized: Bool = false) {
@@ -110,17 +114,8 @@ public class AlamofireNetwork: Network {
110114
}
111115
}()
112116
updateAuthenticationMode(authenticationMode)
113-
}
114-
115-
public func updateAppPasswordSwitching(enabled: Bool) {
116-
guard let credentials, case .wpcom = credentials else { return }
117-
if enabled, let selectedSite {
118-
observeSelectedSite(selectedSite)
119-
} else {
120-
requestConverter = RequestConverter(siteAddress: nil)
121-
requestAuthenticator.updateAuthenticator(DefaultRequestAuthenticator(credentials: credentials))
122-
requestAuthenticator.delegate = nil
123-
updateAuthenticationMode(.jetpackTunnel)
117+
if let appPasswordSupportState {
118+
observeAppPasswordSupportState(appPasswordSupportState)
124119
}
125120
}
126121

@@ -277,6 +272,28 @@ public class AlamofireNetwork: Network {
277272
}
278273

279274
private extension AlamofireNetwork {
275+
276+
func observeAppPasswordSupportState(_ appPasswordSupportState: AnyPublisher<Bool, Never>) {
277+
appPasswordSupportSubscription = appPasswordSupportState
278+
.removeDuplicates()
279+
.sink { [weak self] enabled in
280+
self?.updateAppPasswordSwitching(enabled: enabled)
281+
}
282+
}
283+
284+
func updateAppPasswordSwitching(enabled: Bool) {
285+
guard let credentials, case .wpcom = credentials else { return }
286+
if enabled, let selectedSite {
287+
observeSelectedSite(selectedSite)
288+
} else {
289+
requestConverter = RequestConverter(siteAddress: nil)
290+
requestAuthenticator.updateAuthenticator(DefaultRequestAuthenticator(credentials: credentials))
291+
requestAuthenticator.delegate = nil
292+
updateAuthenticationMode(.jetpackTunnel)
293+
siteSubscription = nil
294+
}
295+
}
296+
280297
/// Creates a session manager with request retrier and adapter
281298
///
282299
func makeSession(configuration sessionConfiguration: URLSessionConfiguration) -> Alamofire.Session {
@@ -286,7 +303,7 @@ private extension AlamofireNetwork {
286303
/// Updates `requestConverter` and `requestAuthenticator` when selected site changes
287304
///
288305
func observeSelectedSite(_ selectedSite: AnyPublisher<JetpackSite?, Never>) {
289-
subscription = selectedSite
306+
siteSubscription = selectedSite
290307
.removeDuplicates()
291308
.combineLatest(userDefaults.publisher(for: \.applicationPasswordUnsupportedList))
292309
.sink { [weak self] site, unsupportedList in

Modules/Sources/Storage/Tools/StorageType+Extensions.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,20 @@ public extension StorageType {
271271
return allObjects(ofType: Product.self, matching: predicate, sortedBy: [descriptor])
272272
}
273273

274+
/// Has stored Products for the provided siteID and optional requirements.
275+
///
276+
func hasProducts(siteID: Int64, status: String?, type: String?) -> Bool {
277+
var predicates: [NSPredicate] = [\Product.siteID == siteID]
278+
if let status {
279+
predicates.append(\Product.statusKey == status)
280+
}
281+
if let type {
282+
predicates.append(\Product.productTypeKey == type)
283+
}
284+
let combinedPredicate = NSCompoundPredicate(type: .and, subpredicates: predicates)
285+
return firstObject(ofType: Product.self, matching: combinedPredicate) != nil
286+
}
287+
274288
/// Retrieves all of the stored Products matching the provided array products ids from the provided SiteID
275289
///
276290
func loadProducts(siteID: Int64, productsIDs: [Int64]) -> [Product] {

0 commit comments

Comments
 (0)