diff --git a/Networking/Networking/Model/ShippingLabel/Shipments/WooShippingConfigResponse.swift b/Networking/Networking/Model/ShippingLabel/Shipments/WooShippingConfigResponse.swift index d45bd09ed48..8b1a8059af7 100644 --- a/Networking/Networking/Model/ShippingLabel/Shipments/WooShippingConfigResponse.swift +++ b/Networking/Networking/Model/ShippingLabel/Shipments/WooShippingConfigResponse.swift @@ -60,7 +60,7 @@ public struct WooShippingLabelData: Decodable, Equatable { /// Labels purchased for the current order public let currentOrderLabels: [ShippingLabelPurchase] - init(currentOrderLabels: [ShippingLabelPurchase]) { + public init(currentOrderLabels: [ShippingLabelPurchase]) { self.currentOrderLabels = currentOrderLabels } diff --git a/Networking/Networking/Model/ShippingLabel/ShippingLabelPurchase.swift b/Networking/Networking/Model/ShippingLabel/ShippingLabelPurchase.swift index 75892420bfe..0a3e17165ec 100644 --- a/Networking/Networking/Model/ShippingLabel/ShippingLabelPurchase.swift +++ b/Networking/Networking/Model/ShippingLabel/ShippingLabelPurchase.swift @@ -104,7 +104,9 @@ extension ShippingLabelPurchase: Decodable { let productIDs = try container.decodeIfPresent([Int64].self, forKey: .productIDs) ?? [] let productNames = try container.decode([String].self, forKey: .productNames) - let shipmentID = try container.decodeIfPresent(String.self, forKey: .shipmentID) + let shipmentID = container.failsafeDecodeIfPresent(targetType: String.self, + forKey: .shipmentID, + alternativeTypes: [.integer(transform: { String($0) })]) self.init(siteID: siteID, orderID: orderID, diff --git a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsDataSource.swift b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsDataSource.swift index 91cbd111633..8d714663227 100644 --- a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsDataSource.swift +++ b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsDataSource.swift @@ -64,8 +64,11 @@ final class OrderDetailsDataSource: NSObject { /// Whether the button to create shipping labels should be visible. /// var shouldShowShippingLabelCreation: Bool { - return isEligibleForShippingLabelCreation && shippingLabels.nonRefunded.isEmpty && - !isEligibleForPayment + if featureFlags.isFeatureFlagEnabled(.revampedShippingLabelCreation) { + // TODO-15375: update logic to show shipping label creation button + return isEligibleForShippingLabelCreation && !isEligibleForPayment + } + return isEligibleForShippingLabelCreation && shippingLabels.nonRefunded.isEmpty && !isEligibleForPayment } /// Whether the option to re-create shipping labels should be visible. diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Split Shipments/CollapsibleShipmentItemCardViewModel.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Split Shipments/CollapsibleShipmentItemCardViewModel.swift index 7fb4def367d..7b5f382a922 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Split Shipments/CollapsibleShipmentItemCardViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Split Shipments/CollapsibleShipmentItemCardViewModel.swift @@ -25,6 +25,7 @@ final class CollapsibleShipmentItemCardViewModel: ObservableObject, Identifiable } init(item: ShippingLabelPackageItem, + isSelectable: Bool = true, currency: String) { self.packageItem = item @@ -34,7 +35,7 @@ final class CollapsibleShipmentItemCardViewModel: ObservableObject, Identifiable currency: currency) self.mainItemRow = SelectableShipmentItemRowViewModel(itemID: "\(item.orderItemID)", - isSelectable: true, + isSelectable: isSelectable, item: mainShippingItem, showQuantity: true) @@ -45,7 +46,7 @@ final class CollapsibleShipmentItemCardViewModel: ObservableObject, Identifiable for index in 0..] { shipments.enumerated().map { (index, item) in - TopTabItem(name: String.localizedStringWithFormat(Localization.shipmentFormat, index + 1), - content: { EmptyView() }) + return TopTabItem(name: String.localizedStringWithFormat(Localization.shipmentFormat, index + 1), + icon: item.isPurchased ? purchasedIcon : nil, + content: { EmptyView() }) } } @@ -109,14 +112,12 @@ final class WooShippingSplitShipmentsViewModel: ObservableObject { self.currencySettings = currencySettings self.shippingSettingsService = shippingSettingsService - let contents = items.map { item in - CollapsibleShipmentItemCardViewModel(item: item, currency: order.currency) - } - let shipment = Shipment(contents: contents, - currency: order.currency, - currencySettings: currencySettings, - shippingSettingsService: shippingSettingsService) - self.shipments = [shipment] + self.shipments = Self.createShipments(with: config, + packageItems: items, + currency: order.currency, + currencySettings: currencySettings, + shippingSettingsService: shippingSettingsService) + shipmentsSavedInRemote = editedShipmentsInfo configureSectionHeader() @@ -266,10 +267,10 @@ final class WooShippingSplitShipmentsViewModel: ObservableObject { } func mergeAllUnfulfilledShipments() { + let (unfulfilledShipments, fulfilledShipments) = shipments.partitioned(by: { $0.isPurchased }) var mergedShipmentContents = ShipmentContents() - // TODO-15440: check for fulfilled shipments and remove them from the list. - shipments.forEach { shipment in + unfulfilledShipments.forEach { shipment in for item in shipment.contents { let matchingItemIndex = mergedShipmentContents.firstIndex(where: { $0.packageItem.productOrVariationID == item.packageItem.productOrVariationID @@ -286,7 +287,7 @@ final class WooShippingSplitShipmentsViewModel: ObservableObject { } } - shipments = [createShipment(with: mergedShipmentContents)] + shipments = [createShipment(with: mergedShipmentContents)] + fulfilledShipments selectedShipmentIndex = 0 } @@ -400,10 +401,62 @@ private extension WooShippingSplitShipmentsViewModel { } } -// MARK: Shipment +// MARK: Shipments + extension WooShippingSplitShipmentsViewModel { - func createShipment(with contents: [CollapsibleShipmentItemCardViewModel]) -> Shipment { + private static func createShipments(with config: WooShippingConfig, + packageItems: [ShippingLabelPackageItem], + currency: String, + currencySettings: CurrencySettings, + shippingSettingsService: ShippingSettingsService) -> [Shipment] { + guard config.shipments.isEmpty == false else { + let contents = packageItems.map { item in + CollapsibleShipmentItemCardViewModel(item: item, currency: currency) + } + let shipment = Shipment(contents: contents, + currency: currency, + currencySettings: currencySettings, + shippingSettingsService: shippingSettingsService) + return [shipment] + } + + let currentOrderLabels = config.shippingLabelData?.currentOrderLabels ?? [] + var shipments = [Shipment]() + + for key in config.shipments.keys.sorted() { + guard let shipmentItems = config.shipments[key] else { + continue + } + + let isPurchased = (currentOrderLabels.filter { $0.shipmentID == key}).isNotEmpty + + var shipmentContents = ShipmentContents() + for shipmentItem in shipmentItems { + guard let packageItem = packageItems.first(where: { $0.orderItemID == shipmentItem.id }), + let subItems = shipmentItem.subItems else { + continue + } + + let quantity = subItems.count > 0 ? subItems.count : 1 + let updatedItem = ShippingLabelPackageItem(copy: packageItem, quantity: Decimal(quantity)) + let content = CollapsibleShipmentItemCardViewModel(item: updatedItem, + isSelectable: !isPurchased, + currency: currency) + shipmentContents.append(content) + } + + let shipment = Shipment(contents: shipmentContents, + isPurchased: isPurchased, + currency: currency, + currencySettings: currencySettings, + shippingSettingsService: shippingSettingsService) + shipments.append(shipment) + } + return shipments + } + + private func createShipment(with contents: [CollapsibleShipmentItemCardViewModel]) -> Shipment { Shipment(contents: contents, currency: order.currency, currencySettings: currencySettings, @@ -414,6 +467,7 @@ extension WooShippingSplitShipmentsViewModel { let id = UUID().uuidString let contents: [CollapsibleShipmentItemCardViewModel] + let isPurchased: Bool let quantity: String let weight: String @@ -426,10 +480,12 @@ extension WooShippingSplitShipmentsViewModel { } init(contents: [CollapsibleShipmentItemCardViewModel], + isPurchased: Bool = false, currency: String, currencySettings: CurrencySettings, shippingSettingsService: ShippingSettingsService) { self.contents = contents + self.isPurchased = isPurchased let items = contents.map(\.packageItem) let itemsCount = items.map(\.quantity).reduce(0, +) @@ -465,7 +521,7 @@ extension WooShippingSplitShipmentsViewModel { @discardableResult @MainActor - func updateShipment() async throws -> WooShippingShipments { + private func updateShipment() async throws -> WooShippingShipments { let shipments = editedShipmentsInfo return try await withCheckedThrowingContinuation { continuation in let action = WooShippingAction.updateShipment(siteID: order.siteID, diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShippingCreateLabelsViewModel.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShippingCreateLabelsViewModel.swift index 03dcf3919a1..f8d37e52076 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShippingCreateLabelsViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShippingCreateLabelsViewModel.swift @@ -275,8 +275,12 @@ final class WooShippingCreateLabelsViewModel: ObservableObject { } } - group.addTask { - await self.loadShipmentsInfo() + let totalOrderItems = order.items.map(\.quantity).reduce(0, +) + if totalOrderItems > 1 { + // Only fetch shipments info if there are more than one order items. + group.addTask { + await self.loadShipmentsInfo() + } } } @@ -443,7 +447,6 @@ private extension WooShippingCreateLabelsViewModel { stores.dispatch(action) } - // TODO: Create view model only if order has more than 1 items that can be split into multiple shipments. (Check web logic) if let config { splitShipmentsViewModel = WooShippingSplitShipmentsViewModel(order: order, config: config, diff --git a/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/TopTabView.swift b/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/TopTabView.swift index e84deea27f4..47d6b937de1 100644 --- a/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/TopTabView.swift +++ b/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/TopTabView.swift @@ -18,6 +18,11 @@ struct TopTabItem { } struct TopTabView: View { + enum TabsIconAlignment { + case leading + case trailing + } + @Binding private var selectedTab: Int @State private var underlineOffset: CGFloat = 0 @State private var tabWidths: [CGFloat] @@ -50,6 +55,9 @@ struct TopTabView: View { // Specifies the height and width of the icon // - Applied with the conditional modifier let tabsIconSize: CGFloat? + let tabsIconAlignment: TabsIconAlignment + let tabsIconForegroundColor: Color? + let tabItemContentHorizontalPadding: CGFloat? let tabItemContentVerticalPadding: CGFloat? @@ -65,6 +73,8 @@ struct TopTabView: View { tabPadding: CGFloat = Layout.tabPadding, tabsNameFont: Font = .headline, tabsIconSize: CGFloat? = 20.0, + tabsIconAlignment: TabsIconAlignment = .leading, + tabsIconForegroundColor: Color? = nil, tabItemContentHorizontalPadding: CGFloat? = nil, tabItemContentVerticalPadding: CGFloat? = nil) { self.tabs = tabs @@ -80,24 +90,26 @@ struct TopTabView: View { self.tabPadding = tabPadding self.tabsNameFont = tabsNameFont self.tabsIconSize = tabsIconSize + self.tabsIconAlignment = tabsIconAlignment + self.tabsIconForegroundColor = tabsIconForegroundColor self.tabItemContentHorizontalPadding = tabItemContentHorizontalPadding self.tabItemContentVerticalPadding = tabItemContentVerticalPadding } private func tabItemContentView(_ index: Int, selected: Bool) -> some View { HStack { - if let icon = tabs[index].icon { - Image(uiImage: icon) - .resizable() - .aspectRatio(contentMode: .fit) - .if(tabsIconSize != nil) { - $0.frame(width: tabsIconSize, height: tabsIconSize) - } + if let icon = tabs[index].icon, tabsIconAlignment == .leading { + tabIconView(with: icon) } + Text(tabs[index].name) .font(tabsNameFont) .foregroundColor(selected ? selectedStateColor : unselectedStateColor) .id(index) + + if let icon = tabs[index].icon, tabsIconAlignment == .trailing { + tabIconView(with: icon) + } } .contentShape(Rectangle()) .onTapGesture { @@ -110,6 +122,18 @@ struct TopTabView: View { .accessibilityAddTraits(selected ? [.isSelected, .isHeader] : []) } + func tabIconView(with icon: UIImage) -> some View { + Image(uiImage: icon) + .resizable() + .aspectRatio(contentMode: .fit) + .if(tabsIconForegroundColor != nil) { + $0.foregroundStyle(tabsIconForegroundColor ?? .clear) + } + .if(tabsIconSize != nil) { + $0.frame(width: tabsIconSize, height: tabsIconSize) + } + } + var body: some View { VStack(spacing: 0) { if tabs.count > 1 && showTabs { diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/Split shipments/WooShippingSplitShipmentsViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/Split shipments/WooShippingSplitShipmentsViewModelTests.swift index 458c898a3c0..88b68e679dd 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/Split shipments/WooShippingSplitShipmentsViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/Split shipments/WooShippingSplitShipmentsViewModelTests.swift @@ -2,6 +2,8 @@ import XCTest @testable import WooCommerce import WooFoundation import Yosemite +import struct Networking.WooShippingLabelData +import struct Networking.ShippingLabelPurchase final class WooShippingSplitShipmentsViewModelTests: XCTestCase { @@ -79,7 +81,7 @@ final class WooShippingSplitShipmentsViewModelTests: XCTestCase { assertEqual("13 kg • ₹22.50", viewModel.itemsDetailLabel) } - func test_shipments_is_correct_initially() throws { + func test_shipments_is_correct_initially_if_config_is_empty() throws { // Given let items = [sampleItem(id: 1, weight: 5, value: 10, quantity: 2), sampleItem(id: 2, weight: 3, value: 2.5, quantity: 1)] @@ -97,6 +99,36 @@ final class WooShippingSplitShipmentsViewModelTests: XCTestCase { XCTAssertEqual(shipment.contents.count, items.count) } + func test_shipments_is_correct_initially_if_config_is_not_empty() throws { + // Given + let items = [sampleItem(id: 1, weight: 5, value: 10, quantity: 2), + sampleItem(id: 2, weight: 3, value: 2.5, quantity: 1)] + + // When + let shippingLabelData = WooShippingLabelData(currentOrderLabels: [ShippingLabelPurchase.fake().copy(shipmentID: "2")]) + let config = WooShippingConfig(siteID: 123, shipments: [ + "1": [WooShippingShipmentItem(id: 1, subItems: ["sub-1", "sub-2"])], + "2": [WooShippingShipmentItem(id: 2, subItems: [])] + ], shippingLabelData: shippingLabelData) + let viewModel = WooShippingSplitShipmentsViewModel(order: sampleOrder, + config: config, + items: items, + currencySettings: currencySettings, + shippingSettingsService: shippingSettingsService) + + // Then + XCTAssertEqual(viewModel.shipments.count, 2) + XCTAssertEqual(viewModel.shipments[0].contents.count, 1) + XCTAssertEqual(viewModel.shipments[0].contents[0].packageItem.orderItemID, items[0].orderItemID) + XCTAssertEqual(viewModel.shipments[0].contents[0].packageItem.quantity, 2) + XCTAssertFalse(viewModel.shipments[0].isPurchased) + + XCTAssertEqual(viewModel.shipments[1].contents.count, 1) + XCTAssertEqual(viewModel.shipments[1].contents[0].packageItem.orderItemID, items[1].orderItemID) + XCTAssertEqual(viewModel.shipments[1].contents[0].packageItem.quantity, 1) + XCTAssertTrue(viewModel.shipments[1].isPurchased) + } + // MARK: - `moveToNoticeViewModel` func test_moveToNoticeViewModel_is_nil_initially() { @@ -589,8 +621,8 @@ final class WooShippingSplitShipmentsViewModelTests: XCTestCase { func test_mergeAllUnfulfilledShipments_updates_shipments_correctly() { // Given let items = [sampleItem(id: 1, weight: 5, value: 10, quantity: 2), - sampleItem(id: 2, weight: 3, value: 2.5, quantity: 1), - sampleItem(id: 3, weight: 4, value: 5, quantity: 3)] + sampleItem(id: 2, weight: 3, value: 2.5, quantity: 1), + sampleItem(id: 3, weight: 4, value: 5, quantity: 3)] let viewModel = WooShippingSplitShipmentsViewModel(order: sampleOrder, config: WooShippingConfig.fake(), items: items, @@ -620,6 +652,44 @@ final class WooShippingSplitShipmentsViewModelTests: XCTestCase { XCTAssertEqual(viewModel.shipments[0].contents[2].packageItem.quantity, 3) } + func test_mergeAllUnfulfilledShipments_updates_shipments_correctly_when_there_exists_a_purchased_shipment() throws { + // Given + let items = [sampleItem(id: 1, weight: 5, value: 10, quantity: 2), + sampleItem(id: 2, weight: 3, value: 2.5, quantity: 1), + sampleItem(id: 3, weight: 4, value: 5, quantity: 3)] + + let shippingLabelData = WooShippingLabelData(currentOrderLabels: [ShippingLabelPurchase.fake().copy(shipmentID: "2")]) + let config = WooShippingConfig(siteID: 123, shipments: [ + "1": [WooShippingShipmentItem(id: 1, subItems: ["sub-1", "sub-2"])], + "2": [WooShippingShipmentItem(id: 2, subItems: [])], + "3": [WooShippingShipmentItem(id: 3, subItems: ["sub-1", "sub-2", "sub-3"])] + ], shippingLabelData: shippingLabelData) + let viewModel = WooShippingSplitShipmentsViewModel(order: sampleOrder, + config: config, + items: items, + currencySettings: currencySettings, + shippingSettingsService: shippingSettingsService) + + // Confidence check + XCTAssertEqual(viewModel.shipments.count, 3) + + // When + viewModel.mergeAllUnfulfilledShipments() + + // Then + XCTAssertEqual(viewModel.shipments.count, 2) + XCTAssertEqual(viewModel.shipments[0].contents.count, 2) + XCTAssertEqual(viewModel.shipments[0].contents[0].packageItem.orderItemID, items[0].orderItemID) + XCTAssertEqual(viewModel.shipments[0].contents[0].packageItem.quantity, 2) + XCTAssertEqual(viewModel.shipments[0].contents[1].packageItem.orderItemID, items[2].orderItemID) + XCTAssertEqual(viewModel.shipments[0].contents[1].packageItem.quantity, 3) + + + XCTAssertEqual(viewModel.shipments[1].contents.count, 1) + XCTAssertEqual(viewModel.shipments[1].contents[0].packageItem.orderItemID, items[1].orderItemID) + XCTAssertEqual(viewModel.shipments[1].contents[0].packageItem.quantity, 1) + } + // MARK: - `enableDoneButton` @MainActor diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShippingCreateLabelsViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShippingCreateLabelsViewModelTests.swift index 6568f69e751..92306f6281f 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShippingCreateLabelsViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShippingCreateLabelsViewModelTests.swift @@ -167,6 +167,82 @@ final class WooShippingCreateLabelsViewModelTests: XCTestCase { XCTAssertEqual(viewModel.originAddresses.addresses, [originAddress]) } + func test_shipping_config_is_not_loaded_if_order_contains_one_item() { + // Given + let stores = MockStoresManager(sessionManager: .testingInstance) + let error = NetworkError.notFound(response: nil) + let originAddress = WooShippingOriginAddress.fake().copy(id: "default", defaultAddress: true) + + var loadedConfig = false + stores.whenReceivingAction(ofType: WooShippingAction.self) { action in + switch action { + case .loadOriginAddresses(_, let completion): + completion(.success([originAddress])) + case .loadAccountSettings(_, let completion): + completion(.failure(error)) + case .loadConfig(_, _, let completion): + loadedConfig = true + completion(.success(WooShippingConfig.fake())) + case .loadPackages, .verifyDestinationAddress: + break + default: + XCTFail("Unexpected action: \(action)") + } + } + let shippingSettingsService = MockShippingSettingsService(dimensionUnit: nil, weightUnit: nil) + + // When + let order = Order.fake().copy(items: [OrderItem.fake().copy(quantity: Decimal(1))]) + let viewModel = WooShippingCreateLabelsViewModel(order: order, + shippingSettingsService: shippingSettingsService, + stores: stores) + + // Then + waitUntil { + viewModel.state != .loading + } + XCTAssertFalse(loadedConfig) + XCTAssertNil(viewModel.splitShipmentsViewModel) + } + + func test_shipping_config_is_loaded_if_order_contains_more_than_one_item() { + // Given + let stores = MockStoresManager(sessionManager: .testingInstance) + let error = NetworkError.notFound(response: nil) + let originAddress = WooShippingOriginAddress.fake().copy(id: "default", defaultAddress: true) + + var loadedConfig = false + stores.whenReceivingAction(ofType: WooShippingAction.self) { action in + switch action { + case .loadOriginAddresses(_, let completion): + completion(.success([originAddress])) + case .loadAccountSettings(_, let completion): + completion(.failure(error)) + case .loadConfig(_, _, let completion): + loadedConfig = true + completion(.success(WooShippingConfig.fake())) + case .loadPackages, .verifyDestinationAddress: + break + default: + XCTFail("Unexpected action: \(action)") + } + } + let shippingSettingsService = MockShippingSettingsService(dimensionUnit: nil, weightUnit: nil) + + // When + let order = Order.fake().copy(items: [OrderItem.fake().copy(quantity: Decimal(2))]) + let viewModel = WooShippingCreateLabelsViewModel(order: order, + shippingSettingsService: shippingSettingsService, + stores: stores) + + // Then + waitUntil { + viewModel.state != .loading + } + XCTAssertTrue(loadedConfig) + XCTAssertNotNil(viewModel.splitShipmentsViewModel) + } + func test_origin_unverified_state_is_correct() { // Given let stores = MockStoresManager(sessionManager: .testingInstance)