diff --git a/Alfie/Alfie.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Alfie/Alfie.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b1077590..34b547f5 100644 --- a/Alfie/Alfie.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Alfie/Alfie.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -95,8 +95,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleUtilities.git", "state" : { - "revision" : "53156c7ec267db846e6b64c9f4c4e31ba4cf75eb", - "version" : "8.0.2" + "revision" : "60da361632d0de02786f709bdc0c4df340f7613e", + "version" : "8.1.0" } }, { @@ -230,8 +230,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "2bc86522d115234d1f588efe2bcb4ce4be8f8b82", - "version" : "510.0.3" + "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", + "version" : "601.0.1" } }, { diff --git a/Alfie/Alfie/Extensions/SelectionProduct+Extension.swift b/Alfie/Alfie/Extensions/SelectedProduct+Extension.swift similarity index 95% rename from Alfie/Alfie/Extensions/SelectionProduct+Extension.swift rename to Alfie/Alfie/Extensions/SelectedProduct+Extension.swift index e1d16a77..f07580f3 100644 --- a/Alfie/Alfie/Extensions/SelectionProduct+Extension.swift +++ b/Alfie/Alfie/Extensions/SelectedProduct+Extension.swift @@ -1,7 +1,7 @@ import Foundation import Models -extension SelectionProduct { +extension SelectedProduct { var sizeText: String { var sizeValue: String = "" if let size { diff --git a/Alfie/Alfie/Navigation/Coordinator.swift b/Alfie/Alfie/Navigation/Coordinator.swift index d85be9ef..97f127a2 100644 --- a/Alfie/Alfie/Navigation/Coordinator.swift +++ b/Alfie/Alfie/Navigation/Coordinator.swift @@ -135,11 +135,15 @@ final class Coordinator: ObservableObject, CoordinatorProtocol { // MARK: - Product Details public func openDetails(for productId: String) { - navigationAdapter.push(.productDetails(.id(productId))) + navigationAdapter.push(.productDetails(configuration: .id(productId))) } public func openDetails(for product: Product) { - navigationAdapter.push(.productDetails(.product(product))) + navigationAdapter.push(.productDetails(configuration: .product(product))) + } + + public func openDetails(for selectedProduct: SelectedProduct) { + navigationAdapter.push(.productDetails(configuration: .selectedProduct(selectedProduct))) } // MARK: - Brands diff --git a/Alfie/Alfie/Navigation/DeepLinking/DeepLinkHandler.swift b/Alfie/Alfie/Navigation/DeepLinking/DeepLinkHandler.swift index b80195d3..6e2feaea 100644 --- a/Alfie/Alfie/Navigation/DeepLinking/DeepLinkHandler.swift +++ b/Alfie/Alfie/Navigation/DeepLinking/DeepLinkHandler.swift @@ -92,7 +92,7 @@ final class DeepLinkHandler: DeepLinkHandlerProtocol { case .productDetail(let productId, _, _, _): // TODO: currently the API does not support fetching a product by the StyleNumber (that is parsed from the URL), just by ProductID, so all requests will return "not found" - target = Screen.productDetails(.id(productId)) + target = Screen.productDetails(configuration: .id(productId)) case .webView(let url): target = Screen.webView(url: url, title: "") diff --git a/Alfie/Alfie/Navigation/Screen.swift b/Alfie/Alfie/Navigation/Screen.swift index daa099e7..d98804b6 100644 --- a/Alfie/Alfie/Navigation/Screen.swift +++ b/Alfie/Alfie/Navigation/Screen.swift @@ -13,7 +13,7 @@ enum Screen: ScreenProtocol { case recentSearches case forceAppUpdate case debugMenu - case productDetails(_ type: ThemedProductDetailsScreen) + case productDetails(configuration: ProductDetailsConfiguration) case productListing(configuration: ProductListingScreenConfiguration) case categoryList(_ categories: [NavigationItem], title: String) @@ -35,17 +35,18 @@ enum TabScreen: ScreenProtocol { static let allCases: [TabScreen] = [.home(), .shop(), .wishlist, .bag] } -enum HomeTabConfig: Equatable, Hashable { +enum HomeTabConfig: Hashable { case loggedIn(username: String, memberSince: Int) case loggedOut } -enum ThemedProductDetailsScreen: Equatable, Hashable { +enum ProductDetailsConfiguration: Hashable { case id(_ id: String) case product(_ product: Product) + case selectedProduct(_ selectedProduct: SelectedProduct) } -struct ProductListingScreenConfiguration: Equatable, Hashable { +struct ProductListingScreenConfiguration: Hashable { let category: String? let searchText: String? let urlQueryParameters: [String: String]? diff --git a/Alfie/Alfie/Navigation/TabCoordinator.swift b/Alfie/Alfie/Navigation/TabCoordinator.swift index c4532c61..eb3c5e61 100644 --- a/Alfie/Alfie/Navigation/TabCoordinator.swift +++ b/Alfie/Alfie/Navigation/TabCoordinator.swift @@ -113,6 +113,9 @@ final class TabCoordinator: TabCoordinatorProtocol, ObservableObject { case .product(let product): coordinator.openDetails(for: product) + + case .selectedProduct(let selectedProduct): + coordinator.openDetails(for: selectedProduct) } case .categoryList(let categories, let title): diff --git a/Alfie/Alfie/Navigation/ViewFactory.swift b/Alfie/Alfie/Navigation/ViewFactory.swift index 10f41e66..855aa671 100644 --- a/Alfie/Alfie/Navigation/ViewFactory.swift +++ b/Alfie/Alfie/Navigation/ViewFactory.swift @@ -135,40 +135,20 @@ final class ViewFactory: ViewFactoryProtocol { wishlistView .withToolbar(for: .wishlist) - case .productDetails(let type): - switch type { - case .id(let id): - ProductDetailsView( - viewModel: ProductDetailsViewModel( - productId: id, - product: nil, - dependencies: ProductDetailsDependencyContainer( - productService: serviceProvider.productService, - webUrlProvider: serviceProvider.webUrlProvider, - bagService: serviceProvider.bagService, - wishlistService: serviceProvider.wishlistService, - configurationService: serviceProvider.configurationService, - analytics: serviceProvider.analytics - ) - ) - ) - - case .product(let product): - ProductDetailsView( - viewModel: ProductDetailsViewModel( - productId: product.id, - product: product, - dependencies: ProductDetailsDependencyContainer( - productService: serviceProvider.productService, - webUrlProvider: serviceProvider.webUrlProvider, - bagService: serviceProvider.bagService, - wishlistService: serviceProvider.wishlistService, - configurationService: serviceProvider.configurationService, - analytics: serviceProvider.analytics - ) + case .productDetails(let configuration): + ProductDetailsView( + viewModel: ProductDetailsViewModel( + configuration: configuration, + dependencies: ProductDetailsDependencyContainer( + productService: serviceProvider.productService, + webUrlProvider: serviceProvider.webUrlProvider, + bagService: serviceProvider.bagService, + wishlistService: serviceProvider.wishlistService, + configurationService: serviceProvider.configurationService, + analytics: serviceProvider.analytics ) ) - } + ) case .recentSearches: let viewModel = RecentSearchesViewModel(recentsService: serviceProvider.recentsService) diff --git a/Alfie/Alfie/Views/BagView/BagView.swift b/Alfie/Alfie/Views/BagView/BagView.swift index 91594c2d..4e839f6f 100644 --- a/Alfie/Alfie/Views/BagView/BagView.swift +++ b/Alfie/Alfie/Views/BagView/BagView.swift @@ -6,6 +6,7 @@ import Mocks #endif struct BagView: View { + @EnvironmentObject var coordinador: Coordinator @StateObject private var viewModel: ViewModel init(viewModel: ViewModel) { @@ -15,7 +16,14 @@ struct BagView: View { var body: some View { List { ForEach(viewModel.products) { product in - HorizontalProductCard(viewModel: viewModel.productCardViewModel(for: product)) + Button( + action: { coordinador.openDetails(for: product) }, + label: { + HorizontalProductCard(viewModel: viewModel.productCardViewModel(for: product)) + .contentShape(Rectangle()) + } + ) + .buttonStyle(.plain) .listRowInsets(EdgeInsets()) } .onDelete { offsets in diff --git a/Alfie/Alfie/Views/BagView/BagViewModel.swift b/Alfie/Alfie/Views/BagView/BagViewModel.swift index 90b558e9..676a39e2 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: [SelectionProduct] + @Published private(set) var products: [SelectedProduct] private let dependencies: BagDependencyContainer @@ -18,22 +18,22 @@ final class BagViewModel: BagViewModelProtocol { products = dependencies.bagService.getBagContent() } - func didSelectDelete(for product: SelectionProduct) { - dependencies.bagService.removeProduct(product) + func didSelectDelete(for selectedProduct: SelectedProduct) { + dependencies.bagService.removeProduct(selectedProduct) products = dependencies.bagService.getBagContent() - dependencies.analytics.trackRemoveFromBag(productID: product.id) + dependencies.analytics.trackRemoveFromBag(productID: selectedProduct.product.id) } - func productCardViewModel(for product: SelectionProduct) -> HorizontalProductCardViewModel { + func productCardViewModel(for selectedProduct: SelectedProduct) -> HorizontalProductCardViewModel { .init( - image: product.media.first?.asImage?.url, - designer: product.brand.name, - name: product.name, + image: selectedProduct.media.first?.asImage?.url, + designer: selectedProduct.brand.name, + name: selectedProduct.name, colorTitle: L10n.Product.Color.title + ":", - color: product.colour?.name ?? "", + color: selectedProduct.colour?.name ?? "", sizeTitle: L10n.Product.Size.title + ":", - size: product.size == nil ? L10n.Product.OneSize.title : product.sizeText, - priceType: product.priceType + size: selectedProduct.size == nil ? L10n.Product.OneSize.title : selectedProduct.sizeText, + priceType: selectedProduct.priceType ) } } diff --git a/Alfie/Alfie/Views/ProductDetails/ProductDetailsView.swift b/Alfie/Alfie/Views/ProductDetails/ProductDetailsView.swift index 15150a52..fd6e76a5 100644 --- a/Alfie/Alfie/Views/ProductDetails/ProductDetailsView.swift +++ b/Alfie/Alfie/Views/ProductDetails/ProductDetailsView.swift @@ -74,7 +74,7 @@ struct ProductDetailsView: View { .writingSize(to: $viewSize) } } - .withToolbar(for: .productDetails(.id(viewModel.productId))) + .withToolbar(for: .productDetails(configuration: .id(viewModel.productId))) .toolbar { if !viewModel.state.didFail { ToolbarItem(placement: .principal) { diff --git a/Alfie/Alfie/Views/ProductDetails/ProductDetailsViewModel.swift b/Alfie/Alfie/Views/ProductDetails/ProductDetailsViewModel.swift index f8bbdc31..f3a89ba6 100644 --- a/Alfie/Alfie/Views/ProductDetails/ProductDetailsViewModel.swift +++ b/Alfie/Alfie/Views/ProductDetails/ProductDetailsViewModel.swift @@ -16,6 +16,7 @@ final class ProductDetailsViewModel: ProductDetailsViewModelProtocol { private(set) var colorSelectionConfiguration: ColorAndSizingSelectorConfiguration = .init(items: []) private(set) var sizingSelectionConfiguration: ColorAndSizingSelectorConfiguration = .init(items: []) public let productId: String + private let initialSelectedProduct: SelectedProduct? private var product: Product? { guard case .success(let model) = state else { @@ -27,7 +28,7 @@ final class ProductDetailsViewModel: ProductDetailsViewModelProtocol { private var selectedVariant: Product.Variant? { guard case .success(let model) = state else { - return baseProduct?.defaultVariant + return initialSelectedProduct?.selectedVariant ?? baseProduct?.defaultVariant } return model.selectedVariant @@ -88,15 +89,36 @@ final class ProductDetailsViewModel: ProductDetailsViewModelProtocol { product?.priceType } - init(productId: String, product: Product?, dependencies: ProductDetailsDependencyContainer) { - self.productId = productId - baseProduct = product + init( + configuration: ProductDetailsConfiguration, + dependencies: ProductDetailsDependencyContainer + ) { self.dependencies = dependencies - if let baseProduct { + switch configuration { + case .id(let productId): + self.productId = productId + self.initialSelectedProduct = nil + self.baseProduct = nil + + case .product(let product): + self.productId = product.id + self.initialSelectedProduct = nil + self.baseProduct = product + buildColorAndSizingSelectionConfigurations( - product: baseProduct, - selectedVariant: baseProduct.defaultVariant + product: product, + selectedVariant: product.defaultVariant + ) + + case .selectedProduct(let selectedProduct): + self.productId = selectedProduct.product.id + self.initialSelectedProduct = selectedProduct + baseProduct = selectedProduct.product + + buildColorAndSizingSelectionConfigurations( + product: selectedProduct.product, + selectedVariant: selectedProduct.selectedVariant ) } } @@ -205,13 +227,14 @@ final class ProductDetailsViewModel: ProductDetailsViewModelProtocol { return } - buildColorAndSizingSelectionConfigurations(product: product, selectedVariant: product.defaultVariant) - state = .success(.init(product: product, selectedVariant: product.defaultVariant)) + let selectedVariant = initialSelectedProduct?.selectedVariant ?? product.defaultVariant + buildColorAndSizingSelectionConfigurations(product: product, selectedVariant: selectedVariant) + state = .success(.init(product: product, selectedVariant: selectedVariant)) } - private func buildColorAndSizingSelectionConfigurations(product: Product, selectedVariant: Product.Variant?) { - buildColorSelectionConfiguration(product: product, selectedVariant: product.defaultVariant) - buildSizingSelectionConfiguration(product: product, selectedVariant: product.defaultVariant) + private func buildColorAndSizingSelectionConfigurations(product: Product, selectedVariant: Product.Variant) { + buildColorSelectionConfiguration(product: product, selectedVariant: selectedVariant) + buildSizingSelectionConfiguration(product: product, selectedVariant: selectedVariant) } private func buildColorSelectionConfiguration(product: Product, selectedVariant: Product.Variant?) { @@ -364,14 +387,14 @@ final class ProductDetailsViewModel: ProductDetailsViewModelProtocol { state = .success(.init(product: product, selectedVariant: variant)) } - private var selectedProduct: SelectionProduct? { + private var selectedProduct: SelectedProduct? { guard let product, let selectedVariant else { - return nil + return initialSelectedProduct } - return SelectionProduct(product: product, selectedVariant: selectedVariant) + return SelectedProduct(product: product, selectedVariant: selectedVariant) } } diff --git a/Alfie/Alfie/Views/ProductListing/ProductListingViewModel.swift b/Alfie/Alfie/Views/ProductListing/ProductListingViewModel.swift index f5e9d687..7a7ebccb 100644 --- a/Alfie/Alfie/Views/ProductListing/ProductListingViewModel.swift +++ b/Alfie/Alfie/Views/ProductListing/ProductListingViewModel.swift @@ -14,7 +14,7 @@ final class ProductListingViewModel: ProductListingViewModelProtocol { @Published var style: ProductListingListStyle @Published var showRefine = false @Published var sortOption: String? - @Published private(set) var wishlistContent: [SelectionProduct] + @Published private(set) var wishlistContent: [SelectedProduct] @Published private(set) var state: PaginatedViewState private enum Constants { @@ -79,16 +79,17 @@ final class ProductListingViewModel: ProductListingViewModelProtocol { func didSelect(_: Product) {} func isFavoriteState(for product: Product) -> Bool { - wishlistContent.contains { $0.id == product.defaultVariant.sku } + wishlistContent.contains { $0.product.defaultVariant.sku == product.defaultVariant.sku } } func didTapAddToWishlist(for product: Product, isFavorite: Bool) { if !isFavorite { - let selectedProduct = SelectionProduct(product: product) + let selectedProduct = SelectedProduct(product: product) dependencies.wishlistService.addProduct(selectedProduct) - dependencies.analytics.trackAddToWishlist(productID: selectedProduct.id) + dependencies.analytics.trackAddToWishlist(productID: product.id) } else { - dependencies.wishlistService.removeProduct(product.defaultVariant.sku) + let selectedProduct = SelectedProduct(product: product) + dependencies.wishlistService.removeProduct(selectedProduct) dependencies.analytics.trackRemoveFromWishlist(productID: product.id) } wishlistContent = dependencies.wishlistService.getWishlistContent() diff --git a/Alfie/Alfie/Views/WishlistView/WishlistView.swift b/Alfie/Alfie/Views/WishlistView/WishlistView.swift index bae7008c..8948df21 100644 --- a/Alfie/Alfie/Views/WishlistView/WishlistView.swift +++ b/Alfie/Alfie/Views/WishlistView/WishlistView.swift @@ -6,6 +6,7 @@ import Mocks #endif struct WishlistView: View { + @EnvironmentObject var coordinador: Coordinator @StateObject private var viewModel: ViewModel init(viewModel: ViewModel) { @@ -22,11 +23,18 @@ struct WishlistView: View { spacing: Spacing.space200 ) { ForEach(viewModel.products) { product in - VerticalProductCard( - viewModel: viewModel.productCardViewModel(for: product) - ) { _, type in - handleUserAction(forProduct: product, actionType: type) - } + Button( + action: { coordinador.openDetails(for: product) }, + label: { + VerticalProductCard( + viewModel: viewModel.productCardViewModel(for: product) + ) { _, type in + handleUserAction(forProduct: product, actionType: type) + } + } + ) + .buttonStyle(.plain) + .listRowInsets(EdgeInsets()) } } .padding(.horizontal, Spacing.space200) @@ -41,7 +49,7 @@ struct WishlistView: View { // MARK: - Private Methods private extension WishlistView { - func handleUserAction(forProduct product: SelectionProduct, actionType: VerticalProductCard.ProductUserActionType) { + func handleUserAction(forProduct product: SelectedProduct, actionType: VerticalProductCard.ProductUserActionType) { // swiftlint:disable vertical_whitespace_between_cases switch actionType { case .remove: diff --git a/Alfie/Alfie/Views/WishlistView/WishlistViewModel.swift b/Alfie/Alfie/Views/WishlistView/WishlistViewModel.swift index fa6b3ca8..cbbbef72 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: [SelectionProduct] + @Published private(set) var products: [SelectedProduct] private let dependencies: WishlistDependencyContainer @@ -18,32 +18,32 @@ final class WishlistViewModel: WishlistViewModelProtocol { products = dependencies.wishlistService.getWishlistContent() } - func didSelectDelete(for product: SelectionProduct) { - dependencies.wishlistService.removeProduct(product.id) - dependencies.analytics.trackRemoveFromWishlist(productID: product.id) + func didSelectDelete(for selectedProduct: SelectedProduct) { + dependencies.wishlistService.removeProduct(selectedProduct) + dependencies.analytics.trackRemoveFromWishlist(productID: selectedProduct.product.id) products = dependencies.wishlistService.getWishlistContent() } - func didTapAddToBag(for product: SelectionProduct) { - dependencies.bagService.addProduct(product) - dependencies.analytics.trackAddToBag(productID: product.id) + func didTapAddToBag(for selectedProduct: SelectedProduct) { + dependencies.bagService.addProduct(selectedProduct) + dependencies.analytics.trackAddToBag(productID: selectedProduct.product.id) } - func productCardViewModel(for product: SelectionProduct) -> VerticalProductCardViewModel { + func productCardViewModel(for selectedProduct: SelectedProduct) -> VerticalProductCardViewModel { .init( configuration: .init(size: .medium, hideDetails: false, actionType: .remove), - productId: product.id, - image: product.media.first?.asImage?.url, - designer: product.brand.name, - name: product.name, - priceType: product.priceType, + productId: selectedProduct.id, + image: selectedProduct.media.first?.asImage?.url, + designer: selectedProduct.brand.name, + name: selectedProduct.name, + priceType: selectedProduct.priceType, colorTitle: L10n.Product.Color.title + ":", - color: product.colour?.name ?? "", + color: selectedProduct.colour?.name ?? "", sizeTitle: L10n.Product.Size.title + ":", - size: product.size == nil ? L10n.Product.OneSize.title : product.sizeText, + size: selectedProduct.size == nil ? L10n.Product.OneSize.title : selectedProduct.sizeText, addToBagTitle: L10n.Product.AddToBag.Button.cta, outOfStockTitle: L10n.Product.OutOfStock.Button.cta, - isAddToBagDisabled: product.stock == .zero + isAddToBagDisabled: selectedProduct.stock == .zero ) } } diff --git a/Alfie/AlfieKit/Sources/Core/Services/Bag/BagService.swift b/Alfie/AlfieKit/Sources/Core/Services/Bag/BagService.swift index fb86eec2..22501648 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: [SelectionProduct] = [] + private var products: [SelectedProduct] = [] public init() { } - public func addProduct(_ product: SelectionProduct) { + public func addProduct(_ product: SelectedProduct) { guard !products.contains(where: { $0.id == product.id }) else { return } products.append(product) } - public func removeProduct(_ product: SelectionProduct) { + public func removeProduct(_ product: SelectedProduct) { products = products.filter { $0.id != product.id } } - public func getBagContent() -> [SelectionProduct] { + public func getBagContent() -> [SelectedProduct] { products } } diff --git a/Alfie/AlfieKit/Sources/Core/Services/Wishlist/WishlistService.swift b/Alfie/AlfieKit/Sources/Core/Services/Wishlist/WishlistService.swift index 455bb51f..efa00e2e 100644 --- a/Alfie/AlfieKit/Sources/Core/Services/Wishlist/WishlistService.swift +++ b/Alfie/AlfieKit/Sources/Core/Services/Wishlist/WishlistService.swift @@ -3,21 +3,21 @@ import Models // TODO: Update with an actual implementation with storage public final class WishlistService: WishlistServiceProtocol { - private var products: [SelectionProduct] = [] + private var products: [SelectedProduct] = [] public init() { } - public func addProduct(_ product: SelectionProduct) { + public func addProduct(_ product: SelectedProduct) { guard !products.contains(where: { $0.id == product.id }) else { return } products.append(product) } - public func removeProduct(_ productId: String) { - products = products.filter { $0.id != productId } + public func removeProduct(_ product: SelectedProduct) { + products = products.filter { $0.id != product.id } } - public func getWishlistContent() -> [SelectionProduct] { + public func getWishlistContent() -> [SelectedProduct] { products } } diff --git a/Alfie/AlfieKit/Sources/Mocks/Core/Features/MockBagViewModel.swift b/Alfie/AlfieKit/Sources/Mocks/Core/Features/MockBagViewModel.swift index d53bb98c..b3a95e52 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: [SelectionProduct] + public var products: [SelectedProduct] - public init(products: [SelectionProduct] = []) { + public init(products: [SelectedProduct] = []) { self.products = products } @@ -12,14 +12,14 @@ public class MockBagViewModel: BagViewModelProtocol { onViewDidAppearCalled?() } - public var onDidSelectDeleteCalled: ((SelectionProduct) -> Void)? - public func didSelectDelete(for product: SelectionProduct) { - onDidSelectDeleteCalled?(product) + public var onDidSelectDeleteCalled: ((SelectedProduct) -> Void)? + public func didSelectDelete(for selectedProduct: SelectedProduct) { + onDidSelectDeleteCalled?(selectedProduct) } - public var onProductCardViewModelCalled: ((SelectionProduct) -> HorizontalProductCardViewModel)? - public func productCardViewModel(for product: SelectionProduct) -> HorizontalProductCardViewModel { - onProductCardViewModelCalled?(product) ?? .init( + public var onProductCardViewModelCalled: ((SelectedProduct) -> HorizontalProductCardViewModel)? + public func productCardViewModel(for selectedProduct: SelectedProduct) -> HorizontalProductCardViewModel { + onProductCardViewModelCalled?(selectedProduct) ?? .init( image: nil, designer: "Yves Saint Laurent", name: "Rouge Pur Couture", diff --git a/Alfie/AlfieKit/Sources/Mocks/Core/Features/MockProductListingViewModel.swift b/Alfie/AlfieKit/Sources/Mocks/Core/Features/MockProductListingViewModel.swift index c24c1075..0e4de353 100644 --- a/Alfie/AlfieKit/Sources/Mocks/Core/Features/MockProductListingViewModel.swift +++ b/Alfie/AlfieKit/Sources/Mocks/Core/Features/MockProductListingViewModel.swift @@ -6,7 +6,7 @@ import SwiftUI public class MockProductListingViewModel: ProductListingViewModelProtocol { public var state: PaginatedViewState public var products: [Product] - public var wishlistContent: [SelectionProduct] + public var wishlistContent: [SelectedProduct] public var title: String = "Title" public var totalNumberOfProducts: Int public var style: ProductListingListStyle = .grid @@ -17,7 +17,7 @@ public class MockProductListingViewModel: ProductListingViewModelProtocol { public init( state: PaginatedViewState, products: [Product] = [], - wishlistContent: [SelectionProduct] = [] + wishlistContent: [SelectedProduct] = [] ) { self.state = state self.products = products diff --git a/Alfie/AlfieKit/Sources/Mocks/Core/Services/MockBagService.swift b/Alfie/AlfieKit/Sources/Mocks/Core/Services/MockBagService.swift index a8289d36..e760f454 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: [SelectionProduct] = [] + private var products: [SelectedProduct] = [] public init() { } - public func addProduct(_ product: SelectionProduct) { + public func addProduct(_ product: SelectedProduct) { guard !products.contains(where: { $0.id == product.id }) else { return } products.append(product) } - public func removeProduct(_ product: SelectionProduct) { + public func removeProduct(_ product: SelectedProduct) { products = products.filter { $0.id != product.id } } - public func getBagContent() -> [SelectionProduct] { + public func getBagContent() -> [SelectedProduct] { products } } diff --git a/Alfie/AlfieKit/Sources/Mocks/Core/Services/MockWishlistService.swift b/Alfie/AlfieKit/Sources/Mocks/Core/Services/MockWishlistService.swift index 61fcf14a..dc255971 100644 --- a/Alfie/AlfieKit/Sources/Mocks/Core/Services/MockWishlistService.swift +++ b/Alfie/AlfieKit/Sources/Mocks/Core/Services/MockWishlistService.swift @@ -2,21 +2,21 @@ import Foundation import Models public final class MockWishlistService: WishlistServiceProtocol { - private var products: [SelectionProduct] = [] + private var products: [SelectedProduct] = [] public init() { } - public func addProduct(_ product: SelectionProduct) { + public func addProduct(_ product: SelectedProduct) { guard !products.contains(where: { $0.id == product.id }) else { return } products.append(product) } - public func removeProduct(_ productId: String) { - products = products.filter { $0.id != productId } + public func removeProduct(_ product: SelectedProduct) { + products = products.filter { $0.id != product.id } } - public func getWishlistContent() -> [SelectionProduct] { + public func getWishlistContent() -> [SelectedProduct] { products } } diff --git a/Alfie/AlfieKit/Sources/Models/Features/BagViewModelProtocol.swift b/Alfie/AlfieKit/Sources/Models/Features/BagViewModelProtocol.swift index 9b16448b..41fdb4a1 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: [SelectionProduct] { get } + var products: [SelectedProduct] { get } func viewDidAppear() - func didSelectDelete(for product: SelectionProduct) - func productCardViewModel(for product: SelectionProduct) -> HorizontalProductCardViewModel + func didSelectDelete(for selectedProduct: SelectedProduct) + func productCardViewModel(for selectedProduct: SelectedProduct) -> HorizontalProductCardViewModel } diff --git a/Alfie/AlfieKit/Sources/Models/Features/ProductListingViewModelProtocol.swift b/Alfie/AlfieKit/Sources/Models/Features/ProductListingViewModelProtocol.swift index ae497a09..2d03eef3 100644 --- a/Alfie/AlfieKit/Sources/Models/Features/ProductListingViewModelProtocol.swift +++ b/Alfie/AlfieKit/Sources/Models/Features/ProductListingViewModelProtocol.swift @@ -26,7 +26,7 @@ public enum ProductListingViewMode { public protocol ProductListingViewModelProtocol: ObservableObject { var state: PaginatedViewState { get } var products: [Product] { get } - var wishlistContent: [SelectionProduct] { get } + var wishlistContent: [SelectedProduct] { get } var style: ProductListingListStyle { get set } var showRefine: Bool { get set } var sortOption: String? { get set } diff --git a/Alfie/AlfieKit/Sources/Models/Features/WishlistViewModelProtocol.swift b/Alfie/AlfieKit/Sources/Models/Features/WishlistViewModelProtocol.swift index 61be342b..c69cabcf 100644 --- a/Alfie/AlfieKit/Sources/Models/Features/WishlistViewModelProtocol.swift +++ b/Alfie/AlfieKit/Sources/Models/Features/WishlistViewModelProtocol.swift @@ -1,10 +1,10 @@ import Foundation public protocol WishlistViewModelProtocol: ObservableObject { - var products: [SelectionProduct] { get } + var products: [SelectedProduct] { get } func viewDidAppear() - func didSelectDelete(for product: SelectionProduct) - func didTapAddToBag(for product: SelectionProduct) - func productCardViewModel(for product: SelectionProduct) -> VerticalProductCardViewModel + func didSelectDelete(for selectedProduct: SelectedProduct) + func didTapAddToBag(for selectedProduct: SelectedProduct) + func productCardViewModel(for selectedProduct: SelectedProduct) -> VerticalProductCardViewModel } diff --git a/Alfie/AlfieKit/Sources/Models/Models/Base/Brand.swift b/Alfie/AlfieKit/Sources/Models/Models/Base/Brand.swift index 428ecba2..4fd1e35b 100644 --- a/Alfie/AlfieKit/Sources/Models/Models/Base/Brand.swift +++ b/Alfie/AlfieKit/Sources/Models/Models/Base/Brand.swift @@ -1,6 +1,6 @@ import Foundation -public struct Brand: Identifiable, Equatable { +public struct Brand: Identifiable, Hashable { /// The ID for the brand public let id: String /// The display name of the brand diff --git a/Alfie/AlfieKit/Sources/Models/Models/Base/Media.swift b/Alfie/AlfieKit/Sources/Models/Models/Base/Media.swift index ea7fe468..8dea2824 100644 --- a/Alfie/AlfieKit/Sources/Models/Models/Base/Media.swift +++ b/Alfie/AlfieKit/Sources/Models/Models/Base/Media.swift @@ -1,6 +1,6 @@ import Foundation -public enum Media { +public enum Media: Hashable { case image(MediaImage) case video(MediaVideo) diff --git a/Alfie/AlfieKit/Sources/Models/Models/Base/MediaImage.swift b/Alfie/AlfieKit/Sources/Models/Models/Base/MediaImage.swift index bdebde91..2424622f 100644 --- a/Alfie/AlfieKit/Sources/Models/Models/Base/MediaImage.swift +++ b/Alfie/AlfieKit/Sources/Models/Models/Base/MediaImage.swift @@ -1,6 +1,6 @@ import Foundation -public struct MediaImage { +public struct MediaImage: Hashable { /// A description of the contents of the image for accessibility purposes. public let alt: String? /// The media content type. diff --git a/Alfie/AlfieKit/Sources/Models/Models/Base/MediaVideo.swift b/Alfie/AlfieKit/Sources/Models/Models/Base/MediaVideo.swift index fcbd3981..b8867753 100644 --- a/Alfie/AlfieKit/Sources/Models/Models/Base/MediaVideo.swift +++ b/Alfie/AlfieKit/Sources/Models/Models/Base/MediaVideo.swift @@ -1,6 +1,6 @@ import Foundation -public struct MediaVideo { +public struct MediaVideo: Hashable { /// A description of the contents of the video for accessibility purposes. public let alt: String? /// The media content type. @@ -18,7 +18,7 @@ public struct MediaVideo { } } -public struct VideoSource { +public struct VideoSource: Hashable { public enum VideoFormat: String { case mp4 = "MP4" case webm = "WEBM" diff --git a/Alfie/AlfieKit/Sources/Models/Models/Base/Price.swift b/Alfie/AlfieKit/Sources/Models/Models/Base/Price.swift index 2fbde665..70c1b4d1 100644 --- a/Alfie/AlfieKit/Sources/Models/Models/Base/Price.swift +++ b/Alfie/AlfieKit/Sources/Models/Models/Base/Price.swift @@ -1,6 +1,6 @@ import Foundation -public struct Price { +public struct Price: Hashable { /// The current price. public let amount: Money /// If discounted, the previous price. @@ -12,7 +12,7 @@ public struct Price { } } -public struct PriceRange { +public struct PriceRange: Hashable { /// The lowest price. public let low: Money /// The highest price if not a 'from' range. @@ -24,7 +24,7 @@ public struct PriceRange { } } -public struct Money { +public struct Money: Hashable { /// The 3-letter currency code e.g. AUD. public let currencyCode: String /// The amount in minor units (e.g. for $1.23 this will be 123). diff --git a/Alfie/AlfieKit/Sources/Models/Models/Base/SelectedProduct.swift b/Alfie/AlfieKit/Sources/Models/Models/Base/SelectedProduct.swift new file mode 100644 index 00000000..a8ece74d --- /dev/null +++ b/Alfie/AlfieKit/Sources/Models/Models/Base/SelectedProduct.swift @@ -0,0 +1,45 @@ +import Foundation + +public struct SelectedProduct { + public let product: Product + public let selectedVariant: Product.Variant + + 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 + self.selectedVariant = selectedVariant ?? product.defaultVariant + } +} + +extension SelectedProduct: Identifiable, Hashable { + public var id: String { + "\(product.id)-\(selectedVariant.sku)" + } +} diff --git a/Alfie/AlfieKit/Sources/Models/Models/Base/SelectionProduct.swift b/Alfie/AlfieKit/Sources/Models/Models/Base/SelectionProduct.swift deleted file mode 100644 index bb425f72..00000000 --- a/Alfie/AlfieKit/Sources/Models/Models/Base/SelectionProduct.swift +++ /dev/null @@ -1,27 +0,0 @@ -import Foundation - -public struct SelectionProduct: Identifiable { - public let id: String - public let name: String - public let brand: Brand - public let size: Product.ProductSize? - public let colour: Product.Colour? - public let stock: Int - public let price: Price - - public var media: [Media] { - colour?.media ?? [] - } - - public init(product: Product, selectedVariant: Product.Variant? = nil) { - let selectedVariant = selectedVariant ?? product.defaultVariant - - self.id = selectedVariant.sku - self.name = product.name - self.brand = product.brand - self.size = selectedVariant.size - self.colour = selectedVariant.colour - self.stock = selectedVariant.stock - self.price = selectedVariant.price - } -} diff --git a/Alfie/AlfieKit/Sources/Models/Models/Product/Product.swift b/Alfie/AlfieKit/Sources/Models/Models/Product/Product.swift index 3a25850a..99e7905f 100644 --- a/Alfie/AlfieKit/Sources/Models/Models/Product/Product.swift +++ b/Alfie/AlfieKit/Sources/Models/Models/Product/Product.swift @@ -1,6 +1,6 @@ import Foundation -public struct Product: Identifiable, Equatable, Hashable { +public struct Product: Identifiable, Hashable { /// Unique ID for the product and its variants. public let id: String /// App refers to products (including variants) as style numbers, so this is the product's unique identifier. @@ -54,20 +54,12 @@ public struct Product: Identifiable, Equatable, Hashable { self.variants = variants self.colours = colours } - - public static func == (lhs: Product, rhs: Product) -> Bool { - lhs.styleNumber == rhs.styleNumber - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(styleNumber) - } } // MARK: - Product Properties Type extension Product { - public struct Variant { + public struct Variant: Hashable { /// A unique identifier for the variant. public let sku: String /// Size, if applicable. @@ -103,7 +95,7 @@ extension Product { } } - public struct Colour { + public struct Colour: Hashable { /// Unique ID for the colour. public let id: String /// Image resolver for the colour swatch. @@ -121,7 +113,7 @@ extension Product { } } - public struct ProductSize { + public struct ProductSize: Hashable { /// Unique size ID. public let id: String /// The size value (e.g. XS). @@ -148,7 +140,7 @@ extension Product { } } - public struct SizeGuide { + public struct SizeGuide: Hashable { /// Unique size guide ID. public let id: String /// The name of the size guide (e.g. Men's shoes size guide). diff --git a/Alfie/AlfieKit/Sources/Models/Services/Bag/BagServiceProtocol.swift b/Alfie/AlfieKit/Sources/Models/Services/Bag/BagServiceProtocol.swift index 4ec49299..05912e09 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: SelectionProduct) - func removeProduct(_ product: SelectionProduct) - func getBagContent() -> [SelectionProduct] + func addProduct(_ product: SelectedProduct) + func removeProduct(_ product: SelectedProduct) + func getBagContent() -> [SelectedProduct] } diff --git a/Alfie/AlfieKit/Sources/Models/Services/Wishlist/WishlistServiceProtocol.swift b/Alfie/AlfieKit/Sources/Models/Services/Wishlist/WishlistServiceProtocol.swift index 57305abb..6b8bc58d 100644 --- a/Alfie/AlfieKit/Sources/Models/Services/Wishlist/WishlistServiceProtocol.swift +++ b/Alfie/AlfieKit/Sources/Models/Services/Wishlist/WishlistServiceProtocol.swift @@ -1,7 +1,7 @@ import Foundation public protocol WishlistServiceProtocol { - func addProduct(_ product: SelectionProduct) - func removeProduct(_ productId: String) - func getWishlistContent() -> [SelectionProduct] + func addProduct(_ product: SelectedProduct) + func removeProduct(_ product: SelectedProduct) + func getWishlistContent() -> [SelectedProduct] } diff --git a/Alfie/AlfieTests/Features/ProductDetailsViewModelTests.swift b/Alfie/AlfieTests/Features/ProductDetailsViewModelTests.swift index 0a52c66e..979b43df 100644 --- a/Alfie/AlfieTests/Features/ProductDetailsViewModelTests.swift +++ b/Alfie/AlfieTests/Features/ProductDetailsViewModelTests.swift @@ -53,7 +53,7 @@ final class ProductDetailsViewModelTests: XCTestCase { brand: .fixture(name: "Product Brand"), defaultVariant: variant, variants: [variant]) - initViewModel(product: product) + initViewModel(configuration: .product(product)) XCTAssertEqual(sut.productName, product.name) XCTAssertEqual(sut.productTitle, product.brand.name) let colorSelectionConfiguration = sut.colorSelectionConfiguration @@ -73,7 +73,7 @@ final class ProductDetailsViewModelTests: XCTestCase { let product = Product.fixture(name: "Product Name", brand: .fixture(name: "Product Brand"), variants: [variant]) - initViewModel(product: product) + initViewModel(configuration: .product(product)) XCTAssertEqual(sut.sizingSelectionConfiguration.items.count, 0) } @@ -84,7 +84,7 @@ final class ProductDetailsViewModelTests: XCTestCase { XCTAssertTrue(sut.productId.isEmpty) let productId = "1" - initViewModel(productId: productId) + initViewModel(configuration: .id(productId)) XCTAssertEqual(sut.productId, productId) } @@ -95,7 +95,7 @@ final class ProductDetailsViewModelTests: XCTestCase { func test_product_title_is_available_after_fetching_product() { let product = Product.fixture(brand: .fixture(name: "Product Brand")) - initViewModel(product: product) + initViewModel(configuration: .product(product)) XCTAssertEmitsValue(from: sut.$state.drop(while: \.isLoading), afterTrigger: { self.sut.viewDidAppear() }) @@ -109,7 +109,7 @@ final class ProductDetailsViewModelTests: XCTestCase { func test_product_name_is_available_after_fetching_product() { let product = Product.fixture(name: "Product Name") - initViewModel(product: product) + initViewModel(configuration: .product(product)) XCTAssertEmitsValue(from: sut.$state.drop(while: \.isLoading), afterTrigger: { self.sut.viewDidAppear() }) @@ -127,8 +127,10 @@ final class ProductDetailsViewModelTests: XCTestCase { .image(.fixture(url: URL(string: "http://some.media.url.2")!)), ]) let variant = Product.Variant.fixture(colour: color) - let product = Product.fixture(name: "Product Name", defaultVariant: variant, variants: [variant]) - initViewModel(product: product) + let product = Product.fixture(name: "Product Name", + defaultVariant: variant, + variants: [variant]) + initViewModel(configuration: .product(product)) XCTAssertEmitsValue(from: sut.$state.drop(while: \.isLoading), afterTrigger: { self.sut.viewDidAppear() }) @@ -153,7 +155,7 @@ final class ProductDetailsViewModelTests: XCTestCase { func test_product_description_is_available_after_fetching_product() { let product = Product.fixture(longDescription: "Product Description") - initViewModel(product: product) + initViewModel(configuration: .product(product)) XCTAssertEmitsValue(from: sut.$state.drop(while: \.isLoading), afterTrigger: { self.sut.viewDidAppear() }) @@ -166,7 +168,7 @@ final class ProductDetailsViewModelTests: XCTestCase { } func test_price_type_is_not_nil_with_sale_product() { - initViewModel(product: Product.blazer) + initViewModel(configuration: .product(Product.blazer)) guard case .sale(let fullPrice, let finalPrice) = sut.priceType else { XCTFail("Unexpected price type") return @@ -176,7 +178,7 @@ final class ProductDetailsViewModelTests: XCTestCase { } func test_price_type_is_not_nil_with_range_price_product() { - initViewModel(product: Product.hat) + initViewModel(configuration: .product(Product.hat)) guard case .range(let lowerBound, let upperBound, let separator) = sut.priceType else { XCTFail("Unexpected price type") return @@ -187,7 +189,7 @@ final class ProductDetailsViewModelTests: XCTestCase { } func test_price_type_is_not_nil_with_default_price_product() { - initViewModel(product: Product.necklace) + initViewModel(configuration: .product(Product.necklace)) guard case .default(let price) = sut.priceType else { XCTFail("Unexpected price type") return @@ -199,7 +201,7 @@ final class ProductDetailsViewModelTests: XCTestCase { func test_product_is_fetched_when_view_appears() { let productId = "1" - initViewModel(productId: productId) + initViewModel(configuration: .id(productId)) let expectation = expectation(description: "Wait for service call") mockProductService.onGetProductCalled = { id in @@ -214,7 +216,7 @@ final class ProductDetailsViewModelTests: XCTestCase { func test_product_is_not_fetched_when_view_appears_if_already_fetched() { let productId = "1" - initViewModel(productId: productId) + initViewModel(configuration: .id(productId)) let firstExpectation = expectation(description: "Wait for service call") let secondExpectation = expectation(description: "Wait for success state") @@ -336,7 +338,7 @@ final class ProductDetailsViewModelTests: XCTestCase { func test_does_not_report_title_section_loading_when_placeholder_available() { let product = Product.fixture(name: "Product Name") - initViewModel(product: product) + initViewModel(configuration: .product(product)) let result = sut.shouldShowLoading(for: .titleHeader) XCTAssertFalse(result) @@ -589,7 +591,7 @@ final class ProductDetailsViewModelTests: XCTestCase { let product = Product.fixture(name: "Product Name", defaultVariant: variant1, variants: [variant1, variant2]) - initViewModel(product: product) + initViewModel(configuration: .product(product)) mockProductService.onGetProductCalled = { _ in product @@ -807,7 +809,7 @@ final class ProductDetailsViewModelTests: XCTestCase { brand: .fixture(name: "Product Brand"), variants: (expectedMatchedColors + expectedNonMatchedColors).map { Product.Variant.fixture(colour: $0) }) - initViewModel(product: product) + initViewModel(configuration: .product(product)) let swatchesSearchResult = sut.colorSwatches(filteredBy: "Col") XCTAssertTrue(swatchesSearchResult.map(\.name).contains(expectedMatchedColors.map(\.name))) XCTAssertFalse(swatchesSearchResult.map(\.name).contains(expectedNonMatchedColors.map(\.name))) @@ -815,9 +817,7 @@ final class ProductDetailsViewModelTests: XCTestCase { // MARK: - Helper methods - private func initViewModel(productId: String = "", product: Product? = nil) { - sut = .init(productId: productId, - product: product, - dependencies: mockDependencies) + private func initViewModel(configuration: ProductDetailsConfiguration = .id("")) { + sut = .init(configuration: configuration, dependencies: mockDependencies) } } diff --git a/Alfie/AlfieTests/Navigation/Deeplink/Handlers/DeepLinkHandlerTests.swift b/Alfie/AlfieTests/Navigation/Deeplink/Handlers/DeepLinkHandlerTests.swift index abe840c5..5001ae83 100644 --- a/Alfie/AlfieTests/Navigation/Deeplink/Handlers/DeepLinkHandlerTests.swift +++ b/Alfie/AlfieTests/Navigation/Deeplink/Handlers/DeepLinkHandlerTests.swift @@ -218,7 +218,7 @@ final class DeepLinkHandlerTests: XCTestCase { let expectation = expectation(description: "wait for coordinator call") mockCoordinator.onNavigateToScreenCalled = { screen in - XCTAssertEqual(screen, .productDetails(.id(productId))) + XCTAssertEqual(screen, .productDetails(configuration: .id(productId))) expectation.fulfill() }