From 35d4a6e5948749ea63137f67530c0794cee3909e Mon Sep 17 00:00:00 2001 From: JoaoPinhoMinder Date: Tue, 11 Mar 2025 15:14:18 +0000 Subject: [PATCH 1/4] ALFMOB-162: Improve Wishlist Functionality on PLP & Wishlist - Created `WishlistProduct` and `BagProduct` inheriting from `SelectedProduct`. - Made `WishlistProduct` unique by **product ID and color ID**. - Made `BagProduct` unique by **product ID and variant SKU**. - Removed size information from `WishlistView` and `ProductListingView`. - Updated **"Add to Bag"** action in `WishlistView` to redirect users to **PDP** for size selection instead of adding directly to the bag. - Disabled **"Add to Bag"** button on **PDP** until both **color** (pre-selected) and **size** have been selected. --- .../xcshareddata/swiftpm/Package.resolved | 4 +- Alfie/Alfie/Views/BagView/BagViewModel.swift | 6 +- .../ProductDetails/ProductDetailsView.swift | 2 +- .../ProductDetailsViewModel.swift | 65 +++++++++++++++---- .../ProductListing/ProductListingView.swift | 1 + .../ProductListingViewModel.swift | 14 ++-- .../Views/WishlistView/WishlistView.swift | 4 +- .../WishlistView/WishlistViewModel.swift | 33 ++++------ .../Core/Services/Bag/BagService.swift | 14 ++-- .../Services/Wishlist/WishlistService.swift | 18 +++-- .../Core/Features/MockBagViewModel.swift | 12 ++-- .../MockProductDetailsViewModel.swift | 1 + .../Mocks/Core/Services/MockBagService.swift | 14 ++-- .../Core/Services/MockWishlistService.swift | 18 +++-- .../Features/BagViewModelProtocol.swift | 6 +- .../ProductDetailsViewModelProtocol.swift | 1 + .../Features/WishlistViewModelProtocol.swift | 7 +- .../Models/Models/Product/Product.swift | 15 +++++ .../Product/SelectedProduct/BagProduct.swift | 7 ++ .../SelectedProduct}/SelectedProduct.swift | 18 +++-- .../SelectedProduct/WishlistProduct.swift | 34 ++++++++++ .../Services/Bag/BagServiceProtocol.swift | 6 +- .../Wishlist/WishlistServiceProtocol.swift | 7 +- .../ColorAndSizingSelectorConfiguration.swift | 10 ++- .../VerticalProductCardConfiguration.swift | 9 ++- .../Localization/L10n+Generated.swift | 7 +- .../Resources/Localization/L10n.xcstrings | 11 ++++ .../ProductCards/VerticalProductCard.swift | 5 +- .../ColorAndSizingSelectorHeaderView.swift | 8 ++- .../ProductDetailsViewModelTests.swift | 5 +- 30 files changed, 252 insertions(+), 110 deletions(-) create mode 100644 Alfie/AlfieKit/Sources/Models/Models/Product/SelectedProduct/BagProduct.swift rename Alfie/AlfieKit/Sources/Models/Models/{Base => Product/SelectedProduct}/SelectedProduct.swift (70%) create mode 100644 Alfie/AlfieKit/Sources/Models/Models/Product/SelectedProduct/WishlistProduct.swift diff --git a/Alfie/Alfie.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Alfie/Alfie.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 34b547f5..c12a5c9f 100644 --- a/Alfie/Alfie.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Alfie/Alfie.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -230,8 +230,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", - "version" : "601.0.1" + "revision" : "2bc86522d115234d1f588efe2bcb4ce4be8f8b82", + "version" : "510.0.3" } }, { diff --git a/Alfie/Alfie/Views/BagView/BagViewModel.swift b/Alfie/Alfie/Views/BagView/BagViewModel.swift index 676a39e2..bbb6d01f 100644 --- a/Alfie/Alfie/Views/BagView/BagViewModel.swift +++ b/Alfie/Alfie/Views/BagView/BagViewModel.swift @@ -3,7 +3,7 @@ import Models import SharedUI final class BagViewModel: BagViewModelProtocol { - @Published private(set) var products: [SelectedProduct] + @Published private(set) var products: [BagProduct] private let dependencies: BagDependencyContainer @@ -18,13 +18,13 @@ final class BagViewModel: BagViewModelProtocol { products = dependencies.bagService.getBagContent() } - func didSelectDelete(for selectedProduct: SelectedProduct) { + func didSelectDelete(for selectedProduct: BagProduct) { dependencies.bagService.removeProduct(selectedProduct) products = dependencies.bagService.getBagContent() dependencies.analytics.trackRemoveFromBag(productID: selectedProduct.product.id) } - func productCardViewModel(for selectedProduct: SelectedProduct) -> HorizontalProductCardViewModel { + func productCardViewModel(for selectedProduct: BagProduct) -> HorizontalProductCardViewModel { .init( image: selectedProduct.media.first?.asImage?.url, designer: selectedProduct.brand.name, diff --git a/Alfie/Alfie/Views/ProductDetails/ProductDetailsView.swift b/Alfie/Alfie/Views/ProductDetails/ProductDetailsView.swift index fd6e76a5..3882c7ef 100644 --- a/Alfie/Alfie/Views/ProductDetails/ProductDetailsView.swift +++ b/Alfie/Alfie/Views/ProductDetails/ProductDetailsView.swift @@ -490,7 +490,7 @@ extension ProductDetailsView { ThemedButton( text: viewModel.productHasStock ? addToBagText : outOfStockText, isDisabled: .init( - get: { !viewModel.productHasStock }, + get: { !viewModel.isAddToBagEnabled }, set: { _ in } ), isFullWidth: true diff --git a/Alfie/Alfie/Views/ProductDetails/ProductDetailsViewModel.swift b/Alfie/Alfie/Views/ProductDetails/ProductDetailsViewModel.swift index f3a89ba6..1cc23f55 100644 --- a/Alfie/Alfie/Views/ProductDetails/ProductDetailsViewModel.swift +++ b/Alfie/Alfie/Views/ProductDetails/ProductDetailsViewModel.swift @@ -28,7 +28,7 @@ final class ProductDetailsViewModel: ProductDetailsViewModelProtocol { private var selectedVariant: Product.Variant? { guard case .success(let model) = state else { - return initialSelectedProduct?.selectedVariant ?? baseProduct?.defaultVariant + return initialSelectedProduct?.selectedVariant ?? baseProduct?.defaultVariantWithoutSize } return model.selectedVariant @@ -89,6 +89,18 @@ final class ProductDetailsViewModel: ProductDetailsViewModelProtocol { product?.priceType } + var isAddToBagEnabled: Bool { + productHasStock && hasColorSelected && hasSizeSelected + } + + private var hasColorSelected: Bool { + colorSelectionConfiguration.items.count > 1 ? selectedVariant?.colour != nil : true + } + + private var hasSizeSelected: Bool { + sizingSelectionConfiguration.items.count > 1 ? selectedVariant?.size != nil : true + } + init( configuration: ProductDetailsConfiguration, dependencies: ProductDetailsDependencyContainer @@ -108,7 +120,7 @@ final class ProductDetailsViewModel: ProductDetailsViewModelProtocol { buildColorAndSizingSelectionConfigurations( product: product, - selectedVariant: product.defaultVariant + selectedVariant: product.defaultVariantWithoutSize ) case .selectedProduct(let selectedProduct): @@ -185,14 +197,16 @@ final class ProductDetailsViewModel: ProductDetailsViewModelProtocol { func didTapAddToBag() { guard let selectedProduct else { return } - dependencies.bagService.addProduct(selectedProduct) - dependencies.analytics.trackAddToBag(productID: selectedProduct.id) + let bagProduct = BagProduct(selectedProduct: selectedProduct) + dependencies.bagService.addProduct(bagProduct) + dependencies.analytics.trackAddToBag(productID: bagProduct.id) } func didTapAddToWishlist() { guard let selectedProduct else { return } - dependencies.wishlistService.addProduct(selectedProduct) - dependencies.analytics.trackAddToWishlist(productID: selectedProduct.id) + let wishlistProduct = WishlistProduct(selectedProduct: selectedProduct) + dependencies.wishlistService.addProduct(wishlistProduct) + dependencies.analytics.trackAddToWishlist(productID: wishlistProduct.id) } func colorSwatches(filteredBy searchTerm: String) -> [ColorSwatch] { @@ -227,7 +241,7 @@ final class ProductDetailsViewModel: ProductDetailsViewModelProtocol { return } - let selectedVariant = initialSelectedProduct?.selectedVariant ?? product.defaultVariant + let selectedVariant = initialSelectedProduct?.selectedVariant ?? product.defaultVariantWithoutSize buildColorAndSizingSelectionConfigurations(product: product, selectedVariant: selectedVariant) state = .success(.init(product: product, selectedVariant: selectedVariant)) } @@ -307,7 +321,8 @@ final class ProductDetailsViewModel: ProductDetailsViewModelProtocol { sizingSelectionConfiguration = .init( selectedTitle: L10n.Product.Size.title + ":", items: sizingSwatches, - selectedItem: selectedSwatch + selectedItem: selectedSwatch, + noItemSelectedTitle: L10n.Product.Size.NoSelection.title ) sizingSelectionSubscription = sizingSelectionConfiguration.$selectedItem .receive(on: dependencies.scheduler) @@ -360,14 +375,26 @@ final class ProductDetailsViewModel: ProductDetailsViewModelProtocol { } guard let variant = product.variants.first( - where: { $0.colour?.id == colorSwatch.id && $0.size?.id == selectedVariant?.size?.id } + where: { + $0.colour?.id == colorSwatch.id + && (selectedVariant?.size?.id == nil || $0.size?.id == selectedVariant?.size?.id) + } ) else { log.debug("Unexpected data inconsistency: tried to select color \(colorSwatch.id) on product \(productId) but no variant exists with that color, ignoring selection") return } - state = .success(.init(product: product, selectedVariant: variant)) + let updatedVariant = Product.Variant( + sku: variant.sku, + size: selectedVariant?.size, // Making sure if no size selected, it will not auto select + colour: variant.colour, + attributes: variant.attributes, + stock: variant.stock, + price: variant.price + ) + + state = .success(.init(product: product, selectedVariant: updatedVariant)) } private func didSelect(sizingSwatch: SizingSwatch) { @@ -377,14 +404,26 @@ final class ProductDetailsViewModel: ProductDetailsViewModelProtocol { } guard let variant = product.variants.first( - where: { $0.size?.id == sizingSwatch.id && $0.colour?.id == selectedVariant?.colour?.id } + where: { + $0.size?.id == sizingSwatch.id + && (selectedVariant?.colour?.id == nil || $0.colour?.id == selectedVariant?.colour?.id) + } ) else { log.debug("Unexpected data inconsistency: tried to select size \(sizingSwatch.id) on product \(productId) but no variant exists with that size, ignoring selection") return } - state = .success(.init(product: product, selectedVariant: variant)) + let updatedVariant = Product.Variant( + sku: variant.sku, + size: variant.size, + colour: selectedVariant?.colour, // Making sure if no color selected, it will not auto select + attributes: variant.attributes, + stock: variant.stock, + price: variant.price + ) + + state = .success(.init(product: product, selectedVariant: updatedVariant)) } private var selectedProduct: SelectedProduct? { @@ -397,4 +436,4 @@ final class ProductDetailsViewModel: ProductDetailsViewModelProtocol { return SelectedProduct(product: product, selectedVariant: selectedVariant) } -} +} // swiftlint:disable:this file_length diff --git a/Alfie/Alfie/Views/ProductListing/ProductListingView.swift b/Alfie/Alfie/Views/ProductListing/ProductListingView.swift index a8e4a390..ffba17d0 100644 --- a/Alfie/Alfie/Views/ProductListing/ProductListingView.swift +++ b/Alfie/Alfie/Views/ProductListing/ProductListingView.swift @@ -72,6 +72,7 @@ struct ProductListingView: View { viewModel: .init( configuration: .init( size: viewModel.style == .list ? .large : .medium, + hideSize: true, hideAction: !coordinator.isWishlistEnabled ), product: product diff --git a/Alfie/Alfie/Views/ProductListing/ProductListingViewModel.swift b/Alfie/Alfie/Views/ProductListing/ProductListingViewModel.swift index 7a7ebccb..1de03bee 100644 --- a/Alfie/Alfie/Views/ProductListing/ProductListingViewModel.swift +++ b/Alfie/Alfie/Views/ProductListing/ProductListingViewModel.swift @@ -79,18 +79,18 @@ final class ProductListingViewModel: ProductListingViewModelProtocol { func didSelect(_: Product) {} func isFavoriteState(for product: Product) -> Bool { - wishlistContent.contains { $0.product.defaultVariant.sku == product.defaultVariant.sku } + wishlistContent.contains { $0.product.id == product.id } } func didTapAddToWishlist(for product: Product, isFavorite: Bool) { if !isFavorite { - let selectedProduct = SelectedProduct(product: product) - dependencies.wishlistService.addProduct(selectedProduct) - dependencies.analytics.trackAddToWishlist(productID: product.id) + let wishlistProduct = WishlistProduct(product: product) + dependencies.wishlistService.addProduct(wishlistProduct) + dependencies.analytics.trackAddToWishlist(productID: wishlistProduct.id) } else { - let selectedProduct = SelectedProduct(product: product) - dependencies.wishlistService.removeProduct(selectedProduct) - dependencies.analytics.trackRemoveFromWishlist(productID: product.id) + let wishlistProduct = WishlistProduct(product: product) + dependencies.wishlistService.removeProductVariants(wishlistProduct) + dependencies.analytics.trackRemoveFromWishlist(productID: wishlistProduct.id) } wishlistContent = dependencies.wishlistService.getWishlistContent() } diff --git a/Alfie/Alfie/Views/WishlistView/WishlistView.swift b/Alfie/Alfie/Views/WishlistView/WishlistView.swift index 8948df21..83751be1 100644 --- a/Alfie/Alfie/Views/WishlistView/WishlistView.swift +++ b/Alfie/Alfie/Views/WishlistView/WishlistView.swift @@ -49,13 +49,13 @@ struct WishlistView: View { // MARK: - Private Methods private extension WishlistView { - func handleUserAction(forProduct product: SelectedProduct, actionType: VerticalProductCard.ProductUserActionType) { + func handleUserAction(forProduct product: WishlistProduct, actionType: VerticalProductCard.ProductUserActionType) { // swiftlint:disable vertical_whitespace_between_cases switch actionType { case .remove: viewModel.didSelectDelete(for: product) case .addToBag: - viewModel.didTapAddToBag(for: product) + coordinador.openDetails(for: product) case .wishlist: return } diff --git a/Alfie/Alfie/Views/WishlistView/WishlistViewModel.swift b/Alfie/Alfie/Views/WishlistView/WishlistViewModel.swift index cbbbef72..4591040d 100644 --- a/Alfie/Alfie/Views/WishlistView/WishlistViewModel.swift +++ b/Alfie/Alfie/Views/WishlistView/WishlistViewModel.swift @@ -3,7 +3,7 @@ import Models import SharedUI final class WishlistViewModel: WishlistViewModelProtocol { - @Published private(set) var products: [SelectedProduct] + @Published private(set) var products: [WishlistProduct] private let dependencies: WishlistDependencyContainer @@ -18,32 +18,27 @@ final class WishlistViewModel: WishlistViewModelProtocol { products = dependencies.wishlistService.getWishlistContent() } - func didSelectDelete(for selectedProduct: SelectedProduct) { - dependencies.wishlistService.removeProduct(selectedProduct) - dependencies.analytics.trackRemoveFromWishlist(productID: selectedProduct.product.id) + func didSelectDelete(for wishlistProduct: WishlistProduct) { + dependencies.wishlistService.removeProduct(wishlistProduct) + dependencies.analytics.trackRemoveFromWishlist(productID: wishlistProduct.product.id) products = dependencies.wishlistService.getWishlistContent() } - func didTapAddToBag(for selectedProduct: SelectedProduct) { - dependencies.bagService.addProduct(selectedProduct) - dependencies.analytics.trackAddToBag(productID: selectedProduct.product.id) - } - - func productCardViewModel(for selectedProduct: SelectedProduct) -> VerticalProductCardViewModel { + func productCardViewModel(for wishlistProduct: WishlistProduct) -> VerticalProductCardViewModel { .init( - configuration: .init(size: .medium, hideDetails: false, actionType: .remove), - productId: selectedProduct.id, - image: selectedProduct.media.first?.asImage?.url, - designer: selectedProduct.brand.name, - name: selectedProduct.name, - priceType: selectedProduct.priceType, + configuration: .init(size: .medium, hideSize: true, actionType: .remove), + productId: wishlistProduct.id, + image: wishlistProduct.media.first?.asImage?.url, + designer: wishlistProduct.brand.name, + name: wishlistProduct.name, + priceType: wishlistProduct.priceType, colorTitle: L10n.Product.Color.title + ":", - color: selectedProduct.colour?.name ?? "", + color: wishlistProduct.colour?.name ?? "", sizeTitle: L10n.Product.Size.title + ":", - size: selectedProduct.size == nil ? L10n.Product.OneSize.title : selectedProduct.sizeText, + size: wishlistProduct.size == nil ? L10n.Product.OneSize.title : wishlistProduct.sizeText, addToBagTitle: L10n.Product.AddToBag.Button.cta, outOfStockTitle: L10n.Product.OutOfStock.Button.cta, - isAddToBagDisabled: selectedProduct.stock == .zero + isAddToBagDisabled: wishlistProduct.stock == .zero ) } } diff --git a/Alfie/AlfieKit/Sources/Core/Services/Bag/BagService.swift b/Alfie/AlfieKit/Sources/Core/Services/Bag/BagService.swift index 22501648..9cb0b32c 100644 --- a/Alfie/AlfieKit/Sources/Core/Services/Bag/BagService.swift +++ b/Alfie/AlfieKit/Sources/Core/Services/Bag/BagService.swift @@ -3,21 +3,21 @@ import Models // TODO: Update with an actual implementation with storage public final class BagService: BagServiceProtocol { - private var products: [SelectedProduct] = [] + private var products: [BagProduct] = [] public init() { } - public func addProduct(_ product: SelectedProduct) { - guard !products.contains(where: { $0.id == product.id }) else { return } + public func addProduct(_ bagProduct: BagProduct) { + guard !products.contains(where: { $0.id == bagProduct.id }) else { return } - products.append(product) + products.append(bagProduct) } - public func removeProduct(_ product: SelectedProduct) { - products = products.filter { $0.id != product.id } + public func removeProduct(_ bagProduct: BagProduct) { + products = products.filter { $0.id != bagProduct.id } } - public func getBagContent() -> [SelectedProduct] { + public func getBagContent() -> [BagProduct] { products } } diff --git a/Alfie/AlfieKit/Sources/Core/Services/Wishlist/WishlistService.swift b/Alfie/AlfieKit/Sources/Core/Services/Wishlist/WishlistService.swift index efa00e2e..27f3ea98 100644 --- a/Alfie/AlfieKit/Sources/Core/Services/Wishlist/WishlistService.swift +++ b/Alfie/AlfieKit/Sources/Core/Services/Wishlist/WishlistService.swift @@ -3,21 +3,25 @@ import Models // TODO: Update with an actual implementation with storage public final class WishlistService: WishlistServiceProtocol { - private var products: [SelectedProduct] = [] + private var products: [WishlistProduct] = [] public init() { } - public func addProduct(_ product: SelectedProduct) { - guard !products.contains(where: { $0.id == product.id }) else { return } + public func addProduct(_ wishlistProduct: WishlistProduct) { + guard !products.contains(where: { $0.id == wishlistProduct.id }) else { return } - products.append(product) + products.append(wishlistProduct) } - public func removeProduct(_ product: SelectedProduct) { - products = products.filter { $0.id != product.id } + public func removeProduct(_ wishlistProduct: WishlistProduct) { + products = products.filter { $0.id != wishlistProduct.id } } - public func getWishlistContent() -> [SelectedProduct] { + public func removeProductVariants(_ wishlistProduct: WishlistProduct) { + products = products.filter { $0.product.id != wishlistProduct.product.id } + } + + public func getWishlistContent() -> [WishlistProduct] { products } } diff --git a/Alfie/AlfieKit/Sources/Mocks/Core/Features/MockBagViewModel.swift b/Alfie/AlfieKit/Sources/Mocks/Core/Features/MockBagViewModel.swift index b3a95e52..34d3f1b5 100644 --- a/Alfie/AlfieKit/Sources/Mocks/Core/Features/MockBagViewModel.swift +++ b/Alfie/AlfieKit/Sources/Mocks/Core/Features/MockBagViewModel.swift @@ -1,9 +1,9 @@ import Models public class MockBagViewModel: BagViewModelProtocol { - public var products: [SelectedProduct] + public var products: [BagProduct] - public init(products: [SelectedProduct] = []) { + public init(products: [BagProduct] = []) { self.products = products } @@ -12,13 +12,13 @@ public class MockBagViewModel: BagViewModelProtocol { onViewDidAppearCalled?() } - public var onDidSelectDeleteCalled: ((SelectedProduct) -> Void)? - public func didSelectDelete(for selectedProduct: SelectedProduct) { + public var onDidSelectDeleteCalled: ((BagProduct) -> Void)? + public func didSelectDelete(for selectedProduct: BagProduct) { onDidSelectDeleteCalled?(selectedProduct) } - public var onProductCardViewModelCalled: ((SelectedProduct) -> HorizontalProductCardViewModel)? - public func productCardViewModel(for selectedProduct: SelectedProduct) -> HorizontalProductCardViewModel { + public var onProductCardViewModelCalled: ((BagProduct) -> HorizontalProductCardViewModel)? + public func productCardViewModel(for selectedProduct: BagProduct) -> HorizontalProductCardViewModel { onProductCardViewModelCalled?(selectedProduct) ?? .init( image: nil, designer: "Yves Saint Laurent", diff --git a/Alfie/AlfieKit/Sources/Mocks/Core/Features/MockProductDetailsViewModel.swift b/Alfie/AlfieKit/Sources/Mocks/Core/Features/MockProductDetailsViewModel.swift index 383a12eb..61f4c9e9 100644 --- a/Alfie/AlfieKit/Sources/Mocks/Core/Features/MockProductDetailsViewModel.swift +++ b/Alfie/AlfieKit/Sources/Mocks/Core/Features/MockProductDetailsViewModel.swift @@ -18,6 +18,7 @@ public class MockProductDetailsViewModel: ProductDetailsViewModelProtocol { public var shouldShowMediaPaginatedControl = true public var hasSingleImage: Bool = false public var priceType: PriceType? = nil + public var isAddToBagEnabled: Bool = true public init(state: ViewState = .loading, productId: String = "", diff --git a/Alfie/AlfieKit/Sources/Mocks/Core/Services/MockBagService.swift b/Alfie/AlfieKit/Sources/Mocks/Core/Services/MockBagService.swift index e760f454..ead71e2b 100644 --- a/Alfie/AlfieKit/Sources/Mocks/Core/Services/MockBagService.swift +++ b/Alfie/AlfieKit/Sources/Mocks/Core/Services/MockBagService.swift @@ -2,21 +2,21 @@ import Foundation import Models public final class MockBagService: BagServiceProtocol { - private var products: [SelectedProduct] = [] + private var products: [BagProduct] = [] public init() { } - public func addProduct(_ product: SelectedProduct) { - guard !products.contains(where: { $0.id == product.id }) else { return } + public func addProduct(_ bagProduct: BagProduct) { + guard !products.contains(where: { $0.id == bagProduct.id }) else { return } - products.append(product) + products.append(bagProduct) } - public func removeProduct(_ product: SelectedProduct) { - products = products.filter { $0.id != product.id } + public func removeProduct(_ bagProduct: BagProduct) { + products = products.filter { $0.id != bagProduct.id } } - public func getBagContent() -> [SelectedProduct] { + public func getBagContent() -> [BagProduct] { products } } diff --git a/Alfie/AlfieKit/Sources/Mocks/Core/Services/MockWishlistService.swift b/Alfie/AlfieKit/Sources/Mocks/Core/Services/MockWishlistService.swift index dc255971..e3e0ddfa 100644 --- a/Alfie/AlfieKit/Sources/Mocks/Core/Services/MockWishlistService.swift +++ b/Alfie/AlfieKit/Sources/Mocks/Core/Services/MockWishlistService.swift @@ -2,21 +2,25 @@ import Foundation import Models public final class MockWishlistService: WishlistServiceProtocol { - private var products: [SelectedProduct] = [] + private var products: [WishlistProduct] = [] public init() { } - public func addProduct(_ product: SelectedProduct) { - guard !products.contains(where: { $0.id == product.id }) else { return } + public func addProduct(_ wishlistProduct: WishlistProduct) { + guard !products.contains(where: { $0.id == wishlistProduct.id }) else { return } - products.append(product) + products.append(wishlistProduct) } - public func removeProduct(_ product: SelectedProduct) { - products = products.filter { $0.id != product.id } + public func removeProduct(_ wishlistProduct: WishlistProduct) { + products = products.filter { $0.id != wishlistProduct.id } } - public func getWishlistContent() -> [SelectedProduct] { + public func removeProductVariants(_ wishlistProduct: WishlistProduct) { + products = products.filter { $0.product.id != wishlistProduct.product.id } + } + + public func getWishlistContent() -> [WishlistProduct] { products } } diff --git a/Alfie/AlfieKit/Sources/Models/Features/BagViewModelProtocol.swift b/Alfie/AlfieKit/Sources/Models/Features/BagViewModelProtocol.swift index 41fdb4a1..d26ec992 100644 --- a/Alfie/AlfieKit/Sources/Models/Features/BagViewModelProtocol.swift +++ b/Alfie/AlfieKit/Sources/Models/Features/BagViewModelProtocol.swift @@ -1,9 +1,9 @@ import Foundation public protocol BagViewModelProtocol: ObservableObject { - var products: [SelectedProduct] { get } + var products: [BagProduct] { get } func viewDidAppear() - func didSelectDelete(for selectedProduct: SelectedProduct) - func productCardViewModel(for selectedProduct: SelectedProduct) -> HorizontalProductCardViewModel + func didSelectDelete(for selectedProduct: BagProduct) + func productCardViewModel(for selectedProduct: BagProduct) -> HorizontalProductCardViewModel } diff --git a/Alfie/AlfieKit/Sources/Models/Features/ProductDetailsViewModelProtocol.swift b/Alfie/AlfieKit/Sources/Models/Features/ProductDetailsViewModelProtocol.swift index 0b4f116e..2ed3591e 100644 --- a/Alfie/AlfieKit/Sources/Models/Features/ProductDetailsViewModelProtocol.swift +++ b/Alfie/AlfieKit/Sources/Models/Features/ProductDetailsViewModelProtocol.swift @@ -68,6 +68,7 @@ public protocol ProductDetailsViewModelProtocol: ObservableObject { var shouldShowMediaPaginatedControl: Bool { get } var hasSingleImage: Bool { get } var priceType: PriceType? { get } + var isAddToBagEnabled: Bool { get } func viewDidAppear() func shouldShow(section: ProductDetailsSection) -> Bool diff --git a/Alfie/AlfieKit/Sources/Models/Features/WishlistViewModelProtocol.swift b/Alfie/AlfieKit/Sources/Models/Features/WishlistViewModelProtocol.swift index c69cabcf..09022e74 100644 --- a/Alfie/AlfieKit/Sources/Models/Features/WishlistViewModelProtocol.swift +++ b/Alfie/AlfieKit/Sources/Models/Features/WishlistViewModelProtocol.swift @@ -1,10 +1,9 @@ import Foundation public protocol WishlistViewModelProtocol: ObservableObject { - var products: [SelectedProduct] { get } + var products: [WishlistProduct] { get } func viewDidAppear() - func didSelectDelete(for selectedProduct: SelectedProduct) - func didTapAddToBag(for selectedProduct: SelectedProduct) - func productCardViewModel(for selectedProduct: SelectedProduct) -> VerticalProductCardViewModel + func didSelectDelete(for wishlistProduct: WishlistProduct) + func productCardViewModel(for wishlistProduct: WishlistProduct) -> VerticalProductCardViewModel } diff --git a/Alfie/AlfieKit/Sources/Models/Models/Product/Product.swift b/Alfie/AlfieKit/Sources/Models/Models/Product/Product.swift index 99e7905f..f1ae4969 100644 --- a/Alfie/AlfieKit/Sources/Models/Models/Product/Product.swift +++ b/Alfie/AlfieKit/Sources/Models/Models/Product/Product.swift @@ -27,6 +27,21 @@ public struct Product: Identifiable, Hashable { /// Colour objects also include the Product media for each color. public let colours: [Colour]? + /// The 'default' variant but without a size. + /// + /// ⚠️ The default variant should only have a color associated, not a size, but given the current API model that isn't happening, so + /// we enforce an unnatural way of handling product variants. This should be refactored once the new backend model is available. + public var defaultVariantWithoutSize: Variant { + .init( + sku: defaultVariant.sku, + size: nil, + colour: defaultVariant.colour, + attributes: defaultVariant.attributes, + stock: defaultVariant.stock, + price: defaultVariant.price + ) + } + public init( id: String, styleNumber: String, diff --git a/Alfie/AlfieKit/Sources/Models/Models/Product/SelectedProduct/BagProduct.swift b/Alfie/AlfieKit/Sources/Models/Models/Product/SelectedProduct/BagProduct.swift new file mode 100644 index 00000000..e2623f1f --- /dev/null +++ b/Alfie/AlfieKit/Sources/Models/Models/Product/SelectedProduct/BagProduct.swift @@ -0,0 +1,7 @@ +import Foundation + +public class BagProduct: SelectedProduct { + public init(selectedProduct: SelectedProduct) { + super.init(product: selectedProduct.product, selectedVariant: selectedProduct.selectedVariant) + } +} diff --git a/Alfie/AlfieKit/Sources/Models/Models/Base/SelectedProduct.swift b/Alfie/AlfieKit/Sources/Models/Models/Product/SelectedProduct/SelectedProduct.swift similarity index 70% rename from Alfie/AlfieKit/Sources/Models/Models/Base/SelectedProduct.swift rename to Alfie/AlfieKit/Sources/Models/Models/Product/SelectedProduct/SelectedProduct.swift index a8ece74d..2cc69ef4 100644 --- a/Alfie/AlfieKit/Sources/Models/Models/Base/SelectedProduct.swift +++ b/Alfie/AlfieKit/Sources/Models/Models/Product/SelectedProduct/SelectedProduct.swift @@ -1,9 +1,13 @@ import Foundation -public struct SelectedProduct { +public class SelectedProduct: Identifiable, Hashable { public let product: Product public let selectedVariant: Product.Variant + public var id: String { + "\(product.id)-\(selectedVariant.sku)" + } + public var name: String { product.name } @@ -36,10 +40,14 @@ public struct SelectedProduct { self.product = product self.selectedVariant = selectedVariant ?? product.defaultVariant } -} -extension SelectedProduct: Identifiable, Hashable { - public var id: String { - "\(product.id)-\(selectedVariant.sku)" + public static func == (lhs: SelectedProduct, rhs: SelectedProduct) -> Bool { + lhs.product == rhs.product + && lhs.selectedVariant == rhs.selectedVariant + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(product) + hasher.combine(selectedVariant) } } diff --git a/Alfie/AlfieKit/Sources/Models/Models/Product/SelectedProduct/WishlistProduct.swift b/Alfie/AlfieKit/Sources/Models/Models/Product/SelectedProduct/WishlistProduct.swift new file mode 100644 index 00000000..d07840ce --- /dev/null +++ b/Alfie/AlfieKit/Sources/Models/Models/Product/SelectedProduct/WishlistProduct.swift @@ -0,0 +1,34 @@ +import Foundation + +public class WishlistProduct: SelectedProduct { + override + public var id: String { + "\(product.id)-\(colour?.id ?? "no colour")" + } + + public init(selectedProduct: SelectedProduct) { + let updatedVariant = Product.Variant( + sku: selectedProduct.selectedVariant.sku, + size: nil, // Forcing size to not be included + colour: selectedProduct.selectedVariant.colour, + attributes: selectedProduct.selectedVariant.attributes, + stock: selectedProduct.selectedVariant.stock, + price: selectedProduct.selectedVariant.price + ) + + super.init(product: selectedProduct.product, selectedVariant: updatedVariant) + } + + public init(product: Product) { + let updatedVariant = Product.Variant( + sku: product.defaultVariant.sku, + size: nil, // Forcing size to not be included + colour: product.defaultVariant.colour, + attributes: product.defaultVariant.attributes, + stock: product.defaultVariant.stock, + price: product.defaultVariant.price + ) + + super.init(product: product, selectedVariant: updatedVariant) + } +} diff --git a/Alfie/AlfieKit/Sources/Models/Services/Bag/BagServiceProtocol.swift b/Alfie/AlfieKit/Sources/Models/Services/Bag/BagServiceProtocol.swift index 05912e09..02c36deb 100644 --- a/Alfie/AlfieKit/Sources/Models/Services/Bag/BagServiceProtocol.swift +++ b/Alfie/AlfieKit/Sources/Models/Services/Bag/BagServiceProtocol.swift @@ -1,7 +1,7 @@ import Foundation public protocol BagServiceProtocol { - func addProduct(_ product: SelectedProduct) - func removeProduct(_ product: SelectedProduct) - func getBagContent() -> [SelectedProduct] + func addProduct(_ bagProduct: BagProduct) + func removeProduct(_ bagProduct: BagProduct) + func getBagContent() -> [BagProduct] } diff --git a/Alfie/AlfieKit/Sources/Models/Services/Wishlist/WishlistServiceProtocol.swift b/Alfie/AlfieKit/Sources/Models/Services/Wishlist/WishlistServiceProtocol.swift index 6b8bc58d..c6caae77 100644 --- a/Alfie/AlfieKit/Sources/Models/Services/Wishlist/WishlistServiceProtocol.swift +++ b/Alfie/AlfieKit/Sources/Models/Services/Wishlist/WishlistServiceProtocol.swift @@ -1,7 +1,8 @@ import Foundation public protocol WishlistServiceProtocol { - func addProduct(_ product: SelectedProduct) - func removeProduct(_ product: SelectedProduct) - func getWishlistContent() -> [SelectedProduct] + func addProduct(_ wishlistProduct: WishlistProduct) + func removeProduct(_ wishlistProduct: WishlistProduct) + func removeProductVariants(_ wishlistProduct: WishlistProduct) + func getWishlistContent() -> [WishlistProduct] } diff --git a/Alfie/AlfieKit/Sources/Models/UI/ColorAndSizingSelectorConfiguration.swift b/Alfie/AlfieKit/Sources/Models/UI/ColorAndSizingSelectorConfiguration.swift index a467ebe0..943ca52b 100644 --- a/Alfie/AlfieKit/Sources/Models/UI/ColorAndSizingSelectorConfiguration.swift +++ b/Alfie/AlfieKit/Sources/Models/UI/ColorAndSizingSelectorConfiguration.swift @@ -7,10 +7,18 @@ public class ColorAndSizingSelectorConfiguration Date: Wed, 2 Apr 2025 12:50:28 +0100 Subject: [PATCH 2/4] ALFMOB-162: Code review --- .../ProductDetailsViewModel.swift | 7 +-- .../SelectedProduct/SelectedProduct.swift | 43 ++++--------------- .../SelectedProduct/WishlistProduct.swift | 5 +-- 3 files changed, 14 insertions(+), 41 deletions(-) diff --git a/Alfie/Alfie/Views/ProductDetails/ProductDetailsViewModel.swift b/Alfie/Alfie/Views/ProductDetails/ProductDetailsViewModel.swift index 1cc23f55..1ccbcd60 100644 --- a/Alfie/Alfie/Views/ProductDetails/ProductDetailsViewModel.swift +++ b/Alfie/Alfie/Views/ProductDetails/ProductDetailsViewModel.swift @@ -5,6 +5,7 @@ import Models import SharedUI import StyleGuide +// swiftlint:disable file_length final class ProductDetailsViewModel: ProductDetailsViewModelProtocol { private let dependencies: ProductDetailsDependencyContainer // In case we already have a full or partial product to show while fetching @@ -94,11 +95,11 @@ final class ProductDetailsViewModel: ProductDetailsViewModelProtocol { } private var hasColorSelected: Bool { - colorSelectionConfiguration.items.count > 1 ? selectedVariant?.colour != nil : true + !colorSelectionConfiguration.items.isEmpty ? selectedVariant?.colour != nil : true } private var hasSizeSelected: Bool { - sizingSelectionConfiguration.items.count > 1 ? selectedVariant?.size != nil : true + !sizingSelectionConfiguration.items.isEmpty ? selectedVariant?.size != nil : true } init( @@ -436,4 +437,4 @@ final class ProductDetailsViewModel: ProductDetailsViewModelProtocol { return SelectedProduct(product: product, selectedVariant: selectedVariant) } -} // swiftlint:disable:this file_length +} diff --git a/Alfie/AlfieKit/Sources/Models/Models/Product/SelectedProduct/SelectedProduct.swift b/Alfie/AlfieKit/Sources/Models/Models/Product/SelectedProduct/SelectedProduct.swift index 2cc69ef4..bc0d3910 100644 --- a/Alfie/AlfieKit/Sources/Models/Models/Product/SelectedProduct/SelectedProduct.swift +++ b/Alfie/AlfieKit/Sources/Models/Models/Product/SelectedProduct/SelectedProduct.swift @@ -3,38 +3,14 @@ import Foundation public class SelectedProduct: Identifiable, Hashable { public let product: Product public let selectedVariant: Product.Variant - - public var id: String { - "\(product.id)-\(selectedVariant.sku)" - } - - public var name: String { - product.name - } - - public var brand: Brand { - product.brand - } - - public var size: Product.ProductSize? { - selectedVariant.size - } - - public var colour: Product.Colour? { - selectedVariant.colour - } - - public var stock: Int { - selectedVariant.stock - } - - public var price: Price { - selectedVariant.price - } - - public var media: [Media] { - colour?.media ?? [] - } + public var id: String { "\(product.id)-\(selectedVariant.sku)" } + public var name: String { product.name } + public var brand: Brand { product.brand } + public var size: Product.ProductSize? { selectedVariant.size } + public var colour: Product.Colour? { selectedVariant.colour } + public var stock: Int { selectedVariant.stock } + public var price: Price { selectedVariant.price } + public var media: [Media] { colour?.media ?? [] } public init(product: Product, selectedVariant: Product.Variant? = nil) { self.product = product @@ -42,8 +18,7 @@ public class SelectedProduct: Identifiable, Hashable { } public static func == (lhs: SelectedProduct, rhs: SelectedProduct) -> Bool { - lhs.product == rhs.product - && lhs.selectedVariant == rhs.selectedVariant + lhs.product == rhs.product && lhs.selectedVariant == rhs.selectedVariant } public func hash(into hasher: inout Hasher) { diff --git a/Alfie/AlfieKit/Sources/Models/Models/Product/SelectedProduct/WishlistProduct.swift b/Alfie/AlfieKit/Sources/Models/Models/Product/SelectedProduct/WishlistProduct.swift index d07840ce..d9db60fd 100644 --- a/Alfie/AlfieKit/Sources/Models/Models/Product/SelectedProduct/WishlistProduct.swift +++ b/Alfie/AlfieKit/Sources/Models/Models/Product/SelectedProduct/WishlistProduct.swift @@ -1,10 +1,7 @@ import Foundation public class WishlistProduct: SelectedProduct { - override - public var id: String { - "\(product.id)-\(colour?.id ?? "no colour")" - } + override public var id: String { "\(product.id)-\(colour?.id ?? "no colour")" } public init(selectedProduct: SelectedProduct) { let updatedVariant = Product.Variant( From 3824e3a9d97c0745005f0ed7920a495693383ed5 Mon Sep 17 00:00:00 2001 From: JoaoPinhoMinder Date: Mon, 5 May 2025 17:34:01 +0100 Subject: [PATCH 3/4] ALFMOB-162: Fix rebases --- Alfie/Alfie/Views/BagView/BagView.swift | 2 +- .../Sources/SharedUI/Localization/L10n+Generated.swift | 6 +++--- Alfie/Checksums/swiftgen_checksum.txt | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Alfie/Alfie/Views/BagView/BagView.swift b/Alfie/Alfie/Views/BagView/BagView.swift index 4e839f6f..26e3c224 100644 --- a/Alfie/Alfie/Views/BagView/BagView.swift +++ b/Alfie/Alfie/Views/BagView/BagView.swift @@ -49,7 +49,7 @@ struct BagView: View { BagView( viewModel: MockBagViewModel( products: [ - .init(product: Product.fixture()) + .init(selectedProduct: .init(product: Product.fixture())) ] ) ) diff --git a/Alfie/AlfieKit/Sources/SharedUI/Localization/L10n+Generated.swift b/Alfie/AlfieKit/Sources/SharedUI/Localization/L10n+Generated.swift index 56400ae3..1b913869 100644 --- a/Alfie/AlfieKit/Sources/SharedUI/Localization/L10n+Generated.swift +++ b/Alfie/AlfieKit/Sources/SharedUI/Localization/L10n+Generated.swift @@ -196,10 +196,10 @@ public enum L10n { } public enum Size { /// Size - static let title = L10n.tr("L10n", "product.size.title") - enum NoSelection { + public static let title = L10n.tr("L10n", "product.size.title") + public enum NoSelection { /// Select a size - static let title = L10n.tr("L10n", "product.size.no_selection.title") + public static let title = L10n.tr("L10n", "product.size.no_selection.title") } } } diff --git a/Alfie/Checksums/swiftgen_checksum.txt b/Alfie/Checksums/swiftgen_checksum.txt index bb213d3d..8105d7df 100644 --- a/Alfie/Checksums/swiftgen_checksum.txt +++ b/Alfie/Checksums/swiftgen_checksum.txt @@ -1 +1 @@ -7d01ef2339e415e28066cf3f434f2a839fac8813 +b00b49f7ac3847173e83b27812fb98832a22f311 From e7722e15c725533edba6c18290ec3bc357cf6421 Mon Sep 17 00:00:00 2001 From: JoaoPinhoMinder Date: Mon, 5 May 2025 17:42:22 +0100 Subject: [PATCH 4/4] ALFMOB-162: Code review --- Alfie/Alfie/Views/BagView/BagView.swift | 4 ++-- Alfie/Alfie/Views/HomeView/HomeView.swift | 6 +++--- .../Views/ProductDetails/ProductDetailsViewModel.swift | 4 +--- Alfie/Alfie/Views/WishlistView/WishlistView.swift | 6 +++--- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/Alfie/Alfie/Views/BagView/BagView.swift b/Alfie/Alfie/Views/BagView/BagView.swift index 26e3c224..ebc2ae64 100644 --- a/Alfie/Alfie/Views/BagView/BagView.swift +++ b/Alfie/Alfie/Views/BagView/BagView.swift @@ -6,7 +6,7 @@ import Mocks #endif struct BagView: View { - @EnvironmentObject var coordinador: Coordinator + @EnvironmentObject var coordinator: Coordinator @StateObject private var viewModel: ViewModel init(viewModel: ViewModel) { @@ -17,7 +17,7 @@ struct BagView: View { List { ForEach(viewModel.products) { product in Button( - action: { coordinador.openDetails(for: product) }, + action: { coordinator.openDetails(for: product) }, label: { HorizontalProductCard(viewModel: viewModel.productCardViewModel(for: product)) .contentShape(Rectangle()) diff --git a/Alfie/Alfie/Views/HomeView/HomeView.swift b/Alfie/Alfie/Views/HomeView/HomeView.swift index 7ca32470..e2f2a35e 100644 --- a/Alfie/Alfie/Views/HomeView/HomeView.swift +++ b/Alfie/Alfie/Views/HomeView/HomeView.swift @@ -8,7 +8,7 @@ import Mocks #endif struct HomeView: View { - @EnvironmentObject var coordinador: Coordinator + @EnvironmentObject var coordinator: Coordinator @StateObject private var viewModel: ViewModel @State private var showSearchBar = true @Namespace private var animation @@ -19,7 +19,7 @@ struct HomeView: View { var body: some View { VStack { - if !coordinador.navigationAdapter.isPresentingFullScreenOverlay { + if !coordinator.navigationAdapter.isPresentingFullScreenOverlay { ThemedSearchBarView( searchText: .constant(""), placeholder: L10n.Home.SearchBar.placeholder, @@ -30,7 +30,7 @@ struct HomeView: View { .matchedGeometryEffect(id: Constants.searchBarGeometryID, in: animation) .disabled(true) .onTapGesture { - coordinador.navigationAdapter.presentFullscreenOverlay( + coordinator.navigationAdapter.presentFullscreenOverlay( with: .search( transition: .matchedGeometryEffect( id: Constants.searchBarGeometryID, diff --git a/Alfie/Alfie/Views/ProductDetails/ProductDetailsViewModel.swift b/Alfie/Alfie/Views/ProductDetails/ProductDetailsViewModel.swift index 1ccbcd60..ef6a9f49 100644 --- a/Alfie/Alfie/Views/ProductDetails/ProductDetailsViewModel.swift +++ b/Alfie/Alfie/Views/ProductDetails/ProductDetailsViewModel.swift @@ -90,9 +90,7 @@ final class ProductDetailsViewModel: ProductDetailsViewModelProtocol { product?.priceType } - var isAddToBagEnabled: Bool { - productHasStock && hasColorSelected && hasSizeSelected - } + var isAddToBagEnabled: Bool { productHasStock && hasColorSelected && hasSizeSelected } private var hasColorSelected: Bool { !colorSelectionConfiguration.items.isEmpty ? selectedVariant?.colour != nil : true diff --git a/Alfie/Alfie/Views/WishlistView/WishlistView.swift b/Alfie/Alfie/Views/WishlistView/WishlistView.swift index 83751be1..e2a3dcaf 100644 --- a/Alfie/Alfie/Views/WishlistView/WishlistView.swift +++ b/Alfie/Alfie/Views/WishlistView/WishlistView.swift @@ -6,7 +6,7 @@ import Mocks #endif struct WishlistView: View { - @EnvironmentObject var coordinador: Coordinator + @EnvironmentObject var coordinator: Coordinator @StateObject private var viewModel: ViewModel init(viewModel: ViewModel) { @@ -24,7 +24,7 @@ struct WishlistView: View { ) { ForEach(viewModel.products) { product in Button( - action: { coordinador.openDetails(for: product) }, + action: { coordinator.openDetails(for: product) }, label: { VerticalProductCard( viewModel: viewModel.productCardViewModel(for: product) @@ -55,7 +55,7 @@ private extension WishlistView { case .remove: viewModel.didSelectDelete(for: product) case .addToBag: - coordinador.openDetails(for: product) + coordinator.openDetails(for: product) case .wishlist: return }