Created
April 25, 2023 13:18
-
-
Save FlorianTousch/e0435260b4ed63aa9252a1d77091c8d2 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
struct PLPGridView: View { | |
@ObservedObject var viewModel: PLPViewModel | |
@Environment(\.colorScheme) var colorScheme | |
@EnvironmentObject var providers: ProviderFactory | |
@State var selectedProduct: MerchProduct? | |
@Binding private var toastContent: GLToastInformationContents? | |
private let enclosingWidth: CGFloat | |
init(viewModel: PLPViewModel, enclosingWidth: CGFloat, toastContent: Binding<GLToastInformationContents?>) { | |
self.viewModel = viewModel | |
self.enclosingWidth = enclosingWidth | |
_toastContent = toastContent | |
} | |
private func gridItemContent(indice: Int, parentWidth: CGFloat) -> some View { | |
Group { | |
let item = viewModel.productList[indice] | |
let tileViewModel = ProductTileViewModel(product: item.product, | |
wishListProvider: providers.wishlistProvider) | |
switch item.showMode { | |
case .tile: | |
ProductTileView(viewModel: tileViewModel, | |
toastContent: $toastContent, | |
onSelectedMerchProduct: { selectItem($0) }) | |
.background(colorScheme == .dark ? Color.black : Color.white) | |
.border(edges: self.borderEdges(for: indice), | |
color: .black) | |
case .full: | |
ProductTileView(viewModel: tileViewModel, | |
toastContent: $toastContent, | |
onSelectedMerchProduct: { selectItem($0) }) | |
.background(colorScheme == .dark ? Color.black : Color.white) | |
.frame(width: parentWidth) | |
.border(edges: [.bottom], color: .black) | |
Rectangle() | |
.foregroundColor(Color.clear) | |
} | |
} | |
} | |
var body: some View { | |
LazyVGrid(columns: getGrid(viewWidth: enclosingWidth), | |
alignment: .center, | |
spacing: .noSpacing) { | |
ForEach(viewModel.productList.indices, id: \.self) { indice in | |
gridItemContent(indice: indice, parentWidth: enclosingWidth) | |
.onAppear { | |
Task { | |
await viewModel.getMoreIfNeeded(index: indice) | |
} | |
} | |
} | |
.navigate(using: $selectedProduct) { _ in | |
ProductDetailsView(viewModel: ProductDetailsViewModel(code: "108803_heather_grey", | |
productProvider: providers.productProvider(), | |
wishlistProvider: providers.wishlistProvider, | |
cartProvider: providers.sharedCartProvider, | |
target2SellProvider: providers.target2SellProvider())) | |
} | |
.onAppear { | |
viewModel.onFirstPageAppear() | |
} | |
} | |
.border(edges: [.top], color: .black) | |
} | |
func selectItem(_ product: MerchProduct) { | |
selectedProduct = product | |
viewModel.select(product: product) | |
} | |
private func getGrid(viewWidth: CGFloat) -> [GridItem] { | |
return [GridItem(.adaptive(minimum: getGridWidth(viewWidth)), | |
spacing: .noSpacing, | |
alignment: .topLeading)] | |
} | |
private func getGridWidth(_ width: CGFloat) -> CGFloat { | |
return width / CGFloat(self.columnCount) - CGFloat(self.columnCount) | |
} | |
private var columnCount: Int { | |
UIDevice.isIpad ? 3 : 2 | |
} | |
private func borderEdges(for index: Int) -> [Edge] { | |
let indexIsTrailingElement = (index + 1) % columnCount == 0 | |
return indexIsTrailingElement ? [.bottom] : [.bottom, .trailing] | |
} | |
} | |
private struct K { | |
static let defaultMin = 0 | |
static let defaultMax = 10000 | |
static let euroSign = "€" | |
} | |
class ActiveFacetsViewModel: ObservableObject { | |
let activeFilters: [MerchFacetValue] | |
let priceRange: OptionalRange<Int> | |
init (activeFilters: [MerchFacetValue], priceRange: OptionalRange<Int>) { | |
self.activeFilters = activeFilters | |
self.priceRange = priceRange | |
} | |
var priceLabel: String { | |
String(format: "%d - %d %@", | |
priceRange.lowerBound ?? K.defaultMin, | |
priceRange.upperBound ?? K.defaultMax, | |
K.euroSign) | |
} | |
} | |
struct ActiveFacetsView: View { | |
let viewModel: ActiveFacetsViewModel | |
let style: UIStyle | |
let onFacetSelect: (MerchFacetValue) -> Void | |
let onPriceSelect: () -> Void | |
init(viewModel: ActiveFacetsViewModel, style: UIStyle = .light, onFacetSelect: @escaping (MerchFacetValue) -> Void, onPriceSelect: @escaping () -> Void) { | |
self.viewModel = viewModel | |
self.style = style | |
self.onFacetSelect = onFacetSelect | |
self.onPriceSelect = onPriceSelect | |
} | |
var body: some View { | |
ScrollView(.horizontal, showsIndicators: false) { | |
HStack(alignment: .center, spacing: .smallPadding) { | |
ForEach(viewModel.activeFilters) { facet in | |
chip(for: facet.label) { onFacetSelect(facet) } | |
} | |
if !viewModel.priceRange.isEmpty { | |
chip(for: viewModel.priceLabel) { onPriceSelect() } | |
} | |
} | |
.padding(.xSmallPadding) | |
} | |
.background(style.backgroundColor) | |
} | |
func chip(for label: String, onTap: @escaping () -> Void) -> some View { | |
Button { | |
onTap() | |
} label: { | |
HStack { | |
Text(label) | |
.font(Font.Facet.active) | |
.textCase(.uppercase) | |
NavigationIcon.xMark | |
} | |
.padding(.smallPadding) | |
.padding(.horizontal, .xSmallPadding) | |
.background(style.capsuleColor) | |
.clipShape(Capsule()) | |
} | |
.foregroundColor(style.foregroundColor) | |
} | |
} | |
struct PLPItemCountView: View { | |
let label: String | |
var body: some View { | |
HStack { | |
Text(label) | |
.font(Font.Results.totalCount) | |
.padding(Constants.UI.padding) | |
Spacer() | |
} | |
.background(Color.extraLightGray) | |
} | |
} | |
struct PLPItemCountView_Previews: PreviewProvider { | |
static var previews: some View { | |
PLPItemCountView(label: "123 Articles") | |
.previewLayout(.fixed(width: 375, height: 20)) | |
} | |
} | |
struct PLPView: View { | |
@StateObject private var viewModel: PLPViewModel | |
@Environment(\.presentationMode) private var mode: Binding<PresentationMode> | |
@State private var toastContent: GLToastInformationContents? | |
@Namespace private var topID | |
init(viewModel: PLPViewModel) { | |
_viewModel = StateObject(wrappedValue: viewModel) | |
} | |
var loadingStateView: some View { | |
HStack { | |
Spacer() | |
VStack(alignment: .center) { | |
switch viewModel.state { | |
case .loading: | |
ProgressView() | |
case .error(let error): | |
Text(error.localizedDescription) | |
.font(.error) | |
default: | |
if viewModel.productList.isEmpty { | |
Text("No product found") | |
.font(.error) | |
} | |
} | |
} | |
Spacer() | |
} | |
.padding(.vertical, 8) | |
} | |
var pinnedHeader: some View { | |
HStack { | |
Spacer() | |
Button(action: viewModel.openFilters) { | |
Text("Filter and sort") | |
.hint(text: viewModel.filtersHint) | |
} | |
.textCase(.uppercase) | |
.textStyle(.titleXS) | |
.padding(.vertical, .largePadding / 2) | |
.padding(.horizontal, .largePadding) | |
.foregroundColor(.white) | |
.background(Color.black) | |
.clipShape(Capsule()) | |
Spacer() | |
} | |
.padding(.vertical, .mediumPadding) | |
} | |
var body: some View { | |
GeometryReader { geometry in | |
ScrollViewReader { scrollView in | |
ScrollView { | |
LazyVStack(spacing: .noSpacing, pinnedViews: [.sectionHeaders]) { | |
Section(header: pinnedHeader) { | |
if viewModel.showChips { | |
ActiveFacetsView( | |
viewModel: ActiveFacetsViewModel(activeFilters: viewModel.activeFacets, priceRange: viewModel.priceRange), | |
onFacetSelect: { self.viewModel.removeFacet($0) }, | |
onPriceSelect: { self.viewModel.removePriceFilter() }) | |
} | |
if let itemCount = self.viewModel.itemCountText { | |
PLPItemCountView(label: itemCount) | |
} | |
if !viewModel.productList.isEmpty { | |
PLPGridView(viewModel: viewModel, enclosingWidth: geometry.size.width, toastContent: $toastContent) | |
} | |
loadingStateView | |
} | |
.id(topID) | |
} | |
} | |
.onReceive(viewModel.reloadPublisher) { _ in | |
scrollView.scrollTo(topID, anchor: .top) | |
} | |
} | |
.navigationBarTitleDisplayMode(.inline) | |
.navigationItem(title: viewModel.pageTitle, | |
style: .dark, | |
maxWidth: geometry.size.width * Constants.UI.PLP.NavigationItemWithRatio, | |
lineLimit: 2, | |
verticalOffset: true) | |
.navigationBarBackButtonHidden(true) | |
.navigationBarItems(leading: BackBarButtonItem(mode: mode)) | |
} | |
.track(screen: .plp(viewModel.trackingValue)) | |
.onLoad { | |
Task { | |
viewModel.initialize() | |
await viewModel.getFirstPage() | |
} | |
} | |
.sheet(item: $viewModel.filtersData) { | |
FiltersView(viewModel: FiltersViewModel(provider: $0.provider, | |
baseResult: $0.result, | |
filteredResult: $0.filteredResult, | |
searchQuery: $0.searchQuery, | |
priceRange: $viewModel.priceRange) { | |
viewModel.reloadPublisher.send() | |
}) | |
.foregroundColor(.almostBlack) | |
} | |
.toast(type: $toastContent) { content in | |
content.rawValue | |
} | |
} | |
} | |
struct ProductTileView: View { | |
struct K { | |
static let productInfoHeight: CGFloat = 173 | |
} | |
@ObservedObject var viewModel: ProductTileViewModel | |
@EnvironmentObject var providers: ProviderFactory | |
@Binding var toastContent: GLToastInformationContents? | |
var onSelectedMerchProduct: ((MerchProduct) -> Void)? | |
var onSelectedEcomProduct: ((EcomProductDetails) -> Void)? | |
private func onAddToWishList() { | |
viewModel.switchFavorite() | |
toastContent = .productAddedToFavorite | |
} | |
private func selectItem() { | |
switch viewModel.data { | |
case .merchProduct(let value): onSelectedMerchProduct?(value) | |
case .ecomProduct(let value): onSelectedEcomProduct?(value) | |
} | |
} | |
var body: some View { | |
Button(action: { selectItem() }, | |
label: { productView }) | |
} | |
var productView: some View { | |
VStack(spacing: .noSpacing) { | |
productDisplay | |
productInfo | |
} | |
} | |
var productDisplay: some View { | |
ZStack { | |
imageView | |
exclusivityAndAddToWishlistView | |
} | |
.aspectRatio(contentMode: .fit) | |
} | |
var productInfo: some View { | |
VStack(alignment: HorizontalAlignment.leading, spacing: .noSpacing) { | |
Text(viewModel.presenter.brandName) | |
.font(Font.Product.brand) | |
Text(viewModel.presenter.productName) | |
.font(Font.Product.name) | |
viewModel.presenter.booster.map { Text($0) } | |
.font(Font.Product.offer) | |
.foregroundColor(.darkGray) | |
.padding(.top, .mediumPadding) | |
Spacer() | |
HStack { | |
priceView | |
Spacer() | |
if viewModel.presenter.isGoForGood { | |
ProductImage.goForGood | |
} | |
} | |
} | |
.foregroundColor(Color.almostBlack) | |
.padding(.smallPadding) | |
.padding(.vertical, .xSmallPadding) | |
.frame(height: K.productInfoHeight) | |
.lineLimit(2) | |
.multilineTextAlignment(.leading) | |
} | |
var priceView: some View { | |
VStack(alignment: HorizontalAlignment.leading, spacing: .noSpacing) { | |
switch viewModel.presenter.price { | |
case .discount(let before, let discount, let final): | |
Text(before) | |
.font(Font.Product.priceBeforeDiscount) | |
.strikethrough() | |
HStack { | |
Text(final) | |
.font(Font.Product.priceFinal) | |
.foregroundColor(.red) | |
Text(discount) | |
.font(Font.Product.discount) | |
} | |
case .normal(let price): | |
Text(price) | |
.font(Font.Product.priceFinal) | |
case .none: | |
EmptyView() | |
} | |
} | |
} | |
var imageView: some View { | |
AsyncImage(url: viewModel.presenter.imageUrl, | |
contentMode: .fill, | |
idle: { imageIdleView }, | |
failed: { imageFailedView }) | |
.aspectRatio(.showcaseImageRatio, contentMode: .fill) | |
.background(Color.imageBackground) | |
} | |
var imageIdleView: some View { | |
ZStack { | |
Rectangle() | |
.foregroundColor(.extraLightGray) | |
ImagePlaceHolder.imageLoadingIdle | |
.aspectRatio(.showcaseImageRatio, contentMode: .fill) | |
} | |
} | |
var imageFailedView: some View { | |
ZStack { | |
Rectangle() | |
.foregroundColor(.extraLightGray) | |
ImagePlaceHolder.imageLoadingFailed | |
.aspectRatio(.showcaseImageRatio, contentMode: .fill) | |
} | |
} | |
var exclusivityAndAddToWishlistView: some View { | |
VStack { | |
HStack { | |
if viewModel.presenter.isExclusivity { | |
Text("Exclusivity") | |
.font(Font.Product.exclusivity) | |
.textCase(.uppercase) | |
.padding(.bottom, 1) | |
.padding(.horizontal, 2) | |
.cornerRadius(2) | |
.foregroundColor(.medGray) | |
.background(Color.lightGray) | |
} | |
Spacer() | |
Button { | |
onAddToWishList() | |
} label: { | |
WishListState(isWished: viewModel.liked) | |
} | |
} | |
.padding(.smallPadding) | |
Spacer() | |
} | |
} | |
} | |
private struct K { | |
static let defaultPriceRange = (0...10_000) | |
} | |
class FiltersViewModel: ObservableObject { | |
enum FiltersDetails: Identifiable { | |
case brands(brands: [MerchFacetValue], selectedBrands: [MerchFacetValue]) | |
case categories(provider: PLPProviderProtocol, baseResult: MerchProductResult, filters: ActiveFilters) | |
case discounts(values: [MerchFacetValue], selectedValues: [MerchFacetValue]) | |
case miscellaneous(facet: MerchFacet, selectedFacets: [MerchFacetValue]) | |
case price(range: OptionalRange<Int>) | |
var id: String { | |
switch self { | |
case .brands: return "brands" | |
case .categories: return "category" | |
case .discounts: return "discount_percentage" | |
case .miscellaneous(let facet, _): return "miscellaneous_\(facet.code)" | |
case .price: return "price" | |
} | |
} | |
} | |
enum FilterSection: Identifiable { | |
case colors(presenter: ColorSelectorViewPresenter) | |
case size(presenter: SizeSelectorViewPresenter) | |
case brands(hint: String?) | |
case categories(hint: String?) | |
case discounts | |
case miscellaneous(name: String, code: String, hint: String?) | |
var id: String { | |
switch self { | |
case .colors: return "colors" | |
case .size: return "size" | |
case .brands: return "brands" | |
case .categories: return "category" | |
case .discounts: return "discount_percentage" | |
case .miscellaneous(_, let code, _): return "miscellaneous_\(code)" | |
} | |
} | |
} | |
var provider: PLPProviderProtocol | |
var cancellables: Set<AnyCancellable> = [] | |
var searchQuery: String? | |
var onReload: () -> Void | |
let factory = QueryInputFactory() | |
var baseResult: MerchProductResult | |
@Published var openFiltersDetails: FiltersDetails? | |
@Published var selectedFilters: ActiveFilters = ActiveFilters() | |
@Published var filteredResult: MerchProductResult | |
@Published var isRefreshingResults: Bool = false | |
@Published var sorts: [MerchSort] | |
@Binding var priceRange: OptionalRange<Int> { | |
didSet { selectedFilters.priceRange = priceRange } | |
} | |
var sortBinding: Binding<MerchSort?> { | |
Binding(get: { self.selectedFilters.selectedSort }, | |
set: { self.selectedFilters.selectedSort = $0 }) | |
} | |
var sortFilterPresenter: SortSelectorView.Presenter? { | |
guard sorts.count > 0 else { return nil } | |
return SortSelectorView.Presenter(sorts: sorts, selectedSort: sortBinding) | |
} | |
var filters: [FilterSection] { | |
filteredResult.facets.compactMap { filterSection(from: $0) } | |
} | |
var seeResultsLabel: String { | |
switch filteredResult.totalItems { | |
case let nbOfItems where nbOfItems > 0: | |
return String(format: "See the %@ results".localized, nbOfItems.format(using: .basicIntFormatter) ?? String(nbOfItems)) | |
default: | |
return "No result".localized | |
} | |
} | |
var applyButtonEnabled: Bool { | |
filteredResult.totalItems != 0 && !isRefreshingResults | |
} | |
init(provider: PLPProviderProtocol, baseResult: MerchProductResult, filteredResult: MerchProductResult, searchQuery: String?, priceRange: Binding<OptionalRange<Int>>, onReload: @escaping () -> Void) { | |
self.provider = provider | |
self.baseResult = baseResult | |
self.filteredResult = filteredResult | |
self.selectedFilters = ActiveFilters(priceRange: priceRange.wrappedValue, result: filteredResult) | |
self.searchQuery = searchQuery | |
self.sorts = filteredResult.sorts | |
self._priceRange = priceRange | |
self.onReload = onReload | |
} | |
func filterSection(from facet: MerchFacet) -> FilterSection? { | |
guard !facet.values.isEmpty else { return nil } | |
switch facet.facetType { | |
case .color: | |
return .colors(presenter: ColorSelectorViewPresenter(values: facet.values, | |
selectedValues: Binding(get: { Set(self.selectedFilters.selectedColors) }, | |
set: { self.selectedFilters.selectedColors = Array($0) }))) | |
case .size: | |
return .size(presenter: SizeSelectorViewPresenter(values: facet.values, | |
selectedValues: Binding(get: { Set(self.selectedFilters.selectedSizes) }, | |
set: { self.selectedFilters.selectedSizes = Array($0) }))) | |
case .brand: | |
return .brands(hint: selectedFilters.selectedBrands.isEmpty ? nil : String(selectedFilters.selectedBrands.count)) | |
case .catalog: | |
let visibleCategories = selectedFilters.selectedCategories.filter { !$0.isFirstLevelOfCatalog } | |
return .categories(hint: visibleCategories.isEmpty ? nil : String(visibleCategories.count)) | |
case .discount: | |
return .discounts | |
case .none: | |
return .miscellaneous(name: facet.label, | |
code: facet.code, | |
hint: selectedFilters.selectedMiscellaneous.filter { $0.parentCode == facet.code }.isEmpty ? nil : selectedFilters.selectedMiscellaneous.filter { $0.parentCode == facet.code }.count.description) | |
case .tag, .responsible: | |
return nil | |
} | |
} | |
@MainActor | |
func onFilterChange() async { | |
guard selectedFilters != baseResult.activeFilters else { | |
filteredResult = baseResult | |
return | |
} | |
do { | |
isRefreshingResults = true | |
filteredResult = try await provider.getPageWithoutUpdatingResult(page: 0, | |
limit: Constants.UI.PLP.pageSize, | |
query: selectedFilters.queryInput(search: searchQuery), | |
sort: selectedFilters.sortInput()) | |
selectedFilters.hiddenFacets = filteredResult.facets.activeHiddenFacets | |
} catch { | |
} | |
isRefreshingResults = false | |
} | |
func applyFilters() { | |
provider.updateResult(filteredResult) | |
onReload() | |
} | |
func resetFilters() { | |
filteredResult = baseResult | |
selectedFilters = baseResult.activeFilters | |
sorts = baseResult.sorts | |
priceRange.reset() | |
} | |
func openBrandsFilter() { | |
openFiltersDetails = .brands(brands: filteredResult.brandsFacets.sorted(by: \.label), | |
selectedBrands: selectedFilters.selectedBrands) | |
} | |
func openCategoriesFilter() { | |
openFiltersDetails = .categories(provider: provider, | |
baseResult: filteredResult, | |
filters: selectedFilters) | |
} | |
func openDiscountsFilter() { | |
openFiltersDetails = .discounts(values: filteredResult.discountsFacets.sorted(by: \.label), | |
selectedValues: selectedFilters.selectedDiscounts) | |
} | |
func openMiscellaneousFilter(code: String) { | |
guard let facet = filteredResult.facets.first(where: { $0.code == code }) else { return } | |
openFiltersDetails = .miscellaneous(facet: facet, | |
selectedFacets: selectedFilters.selectedMiscellaneous.filter { $0.parentCode == code }) | |
} | |
func openPriceFilter() { | |
openFiltersDetails = .price(range: priceRange) | |
} | |
func selectBrands(_ brands: [MerchFacetValue]) { | |
selectedFilters.selectedBrands = brands | |
openFiltersDetails = nil | |
} | |
func selectCategories(result: MerchProductResult, categories: [MerchFacetValue]) { | |
filteredResult = result | |
selectedFilters.selectedCategories = categories | |
openFiltersDetails = nil | |
} | |
func selectDiscounts(_ discounts: [MerchFacetValue]) { | |
selectedFilters.selectedDiscounts = discounts | |
openFiltersDetails = nil | |
} | |
func selectMiscellaneous(code: String, selection facets: [MerchFacetValue]) { | |
selectedFilters.selectedMiscellaneous.removeAll { $0.parentCode == code } | |
selectedFilters.selectedMiscellaneous += facets | |
openFiltersDetails = nil | |
} | |
func selectPriceRange(_ range: OptionalRange<Int>) { | |
priceRange = range | |
openFiltersDetails = nil | |
} | |
var priceFilterHint: String? { | |
selectedFilters.priceRange.isEmpty ? nil : "1" | |
} | |
} | |
private extension MerchProductResult { | |
var activeFilters: ActiveFilters { | |
ActiveFilters(result: self) | |
} | |
} | |
class FilterPriceViewModel: ObservableObject { | |
@Published var range: OptionalRange<Int> | |
@Published var showError = false | |
init(range: OptionalRange<Int>) { | |
self.range = range | |
} | |
var lowerText: Binding<String> { | |
Binding(get: { return self.range.lowerBound.map { String($0) } ?? "" }, | |
set: { self.range.lowerBound = self.setData($0, oldValue: self.range.lowerBound) }) | |
} | |
var upperText: Binding<String> { | |
Binding(get: { return self.range.upperBound.map { String($0) } ?? "" }, | |
set: { self.range.upperBound = self.setData($0, oldValue: self.range.upperBound) }) | |
} | |
func setData(_ newValue: String, oldValue: Int?) -> Int? { | |
showError = false | |
guard !newValue.isEmpty else { return nil } | |
return Int(newValue) ?? oldValue | |
} | |
func isValid() -> Bool { | |
guard range.isValid else { | |
showError = true | |
return false | |
} | |
return true | |
} | |
} | |
class FiltersBrandsViewModel: ObservableObject { | |
private var brands: [MerchFacetValue] | |
@Published private(set) var selectedBrands: Set<MerchFacetValue> | |
@Published var searchText: String = "" | |
var displayedBrands: [MerchFacetValue] { | |
guard !searchText.isEmpty else { | |
return brands | |
} | |
return brands | |
.filter { $0.label.lowercased().contains(searchText.lowercased()) } | |
} | |
var hint: String? { | |
selectedBrands.isEmpty ? nil : String(selectedBrands.count) | |
} | |
init(brands: [MerchFacetValue], selectedBrands: [MerchFacetValue]) { | |
self.brands = brands | |
self.selectedBrands = Set(selectedBrands) | |
} | |
func change(brand: MerchFacetValue, to isSelected: Bool) { | |
if isSelected { | |
selectedBrands.insert(brand) | |
} else { | |
selectedBrands.remove(brand) | |
} | |
} | |
func brandBinding(_ brand: MerchFacetValue) -> Binding<Bool> { | |
Binding(get: { self.selectedBrands.contains(brand) }, | |
set: { self.change(brand: brand, to: $0) }) | |
} | |
} | |
class FilterDefaultSelectorViewModel: ObservableObject { | |
private(set) var facets: [MerchFacetValue] | |
@Published private(set) var selectedFacets: Set<MerchFacetValue> | |
var hint: String? { | |
selectedFacets.isEmpty ? nil : String(selectedFacets.count) | |
} | |
init(facets: [MerchFacetValue], selectedFacets: [MerchFacetValue]) { | |
self.facets = facets | |
self.selectedFacets = Set(selectedFacets) | |
} | |
func change(facet: MerchFacetValue, to isSelected: Bool) { | |
if isSelected { | |
selectedFacets.insert(facet) | |
} else { | |
selectedFacets.remove(facet) | |
} | |
} | |
func facetBinding(_ facet: MerchFacetValue) -> Binding<Bool> { | |
Binding(get: { self.selectedFacets.contains(facet) }, | |
set: { self.change(facet: facet, to: $0) }) | |
} | |
} | |
private struct K { | |
static let euroSign = "€" | |
} | |
struct FilterPriceView: View { | |
@Environment(\.presentationMode) var mode: Binding<PresentationMode> | |
@StateObject var viewModel: FilterPriceViewModel | |
var onApply: (OptionalRange<Int>) -> Void | |
var body: some View { | |
GLNavigationBarLayout(title: "Price".localized, | |
subtitle: "Filters".localized, | |
leadingView: backButton, | |
trailingView: resetButton) { | |
VStack(spacing: .border) { | |
prices | |
applyButton | |
} | |
.background(Color.almostBlack) | |
} | |
.track(screen: .filter(.price)) | |
.background(Color.almostBlack) | |
} | |
var backButton: some View { | |
Button { | |
mode.wrappedValue.dismiss() | |
} label: { | |
NavigationIcon.backArrow.foregroundColor(.almostBlack) | |
} | |
} | |
var resetButton: some View { | |
Button { | |
viewModel.range.reset() | |
} label: { | |
Text("reset".localized) | |
.textStyle(.paragraphSBold) | |
.foregroundColor(.almostBlack) | |
} | |
} | |
var prices: some View { | |
ScrollView { | |
VStack(alignment: .trailing) { | |
HStack { | |
GLTextField(placeholder: "Min (optional)".localized, text: viewModel.lowerText, fieldType: .decimal) { Text(K.euroSign) } | |
GLTextField(placeholder: "Max (optional)".localized, text: viewModel.upperText, fieldType: .decimal) { Text(K.euroSign) } | |
} | |
if viewModel.showError { | |
Text("Le minimum est supérieur au maximum.".localized) | |
.foregroundColor(.red) | |
.textStyle(.paragraphM) | |
} | |
} | |
.padding() | |
} | |
.background(Color.white) | |
} | |
var applyButton: some View { | |
LinkView(label: "Apply".localized, style: .dark) { | |
guard viewModel.isValid() else { return } | |
onApply(viewModel.range) | |
} | |
.textStyle(.paragraphLBold) | |
.background(Color.almostBlack) | |
.padding(.mediumPadding) | |
.background(Color.white) | |
} | |
} | |
struct FilterPriceView_Previews: PreviewProvider { | |
static var previews: some View { | |
FilterPriceView(viewModel: FilterPriceViewModel(range: OptionalRange())) { _ in } | |
} | |
} | |
struct FiltersBrandsView: View { | |
@Environment(\.presentationMode) var mode: Binding<PresentationMode> | |
@StateObject var viewModel: FiltersBrandsViewModel | |
var onApply: ([MerchFacetValue]) -> Void | |
var body: some View { | |
GLNavigationBarLayout(title: "Brands".localized, | |
subtitle: "Filters".localized, | |
leadingView: backButton) { | |
GLSearchTextField(searchText: $viewModel.searchText, placeholder: "Find a brand".localized) | |
.padding(.horizontal, .smallPadding) | |
.padding(.bottom, .xSmallPadding) | |
} content: { | |
VStack(spacing: .border) { | |
brands | |
applyButton | |
} | |
.background(Color.almostBlack) | |
} | |
.track(screen: .filter(.brands)) | |
.background(Color.almostBlack) | |
} | |
var backButton: some View { | |
Button { | |
mode.wrappedValue.dismiss() | |
} label: { | |
NavigationIcon.backArrow.foregroundColor(.almostBlack) | |
} | |
} | |
var brands: some View { | |
ScrollView { | |
LazyVStack(spacing: .border) { | |
ForEach(viewModel.displayedBrands) { brand in | |
CheckboxView(isSelected: viewModel.brandBinding(brand)) { | |
viewModel.change(brand: brand, to: $0) | |
} label: { | |
Text(brand.label) | |
} | |
.frame(maxWidth: .infinity, alignment: .leading) | |
.padding(.mediumPadding) | |
.background(Color.white) | |
} | |
} | |
.textStyle(.paragraphM) | |
.padding(.border) | |
.background(Color.almostBlack) | |
.padding(.mediumPadding) | |
.opacity(viewModel.displayedBrands.isEmpty ? 0 : 1) | |
} | |
.background(Color.white) | |
} | |
var applyButton: some View { | |
LinkView(label: "Apply".localized, | |
hint: viewModel.hint, | |
style: .dark) { | |
onApply(Array(viewModel.selectedBrands)) | |
} | |
.textStyle(.paragraphLBold) | |
.background(Color.almostBlack) | |
.padding(.mediumPadding) | |
.background(Color.white) | |
} | |
} | |
struct FilterDefaultSelectorView: View { | |
@Environment(\.presentationMode) var mode: Binding<PresentationMode> | |
@StateObject var viewModel: FilterDefaultSelectorViewModel | |
var title: String | |
var onApply: ([MerchFacetValue]) -> Void | |
var body: some View { | |
GLNavigationBarLayout(title: title, | |
subtitle: "Filters".localized, | |
leadingView: backButton) { | |
VStack(spacing: .border) { | |
facets | |
applyButton | |
} | |
.background(Color.almostBlack) | |
} | |
.track(screen: .filter(.other(title))) | |
.background(Color.almostBlack) | |
} | |
var backButton: some View { | |
Button { | |
mode.wrappedValue.dismiss() | |
} label: { | |
NavigationIcon.backArrow.foregroundColor(.almostBlack) | |
} | |
} | |
var facets: some View { | |
ScrollView { | |
LazyVStack(spacing: .border) { | |
ForEach(viewModel.facets) { facet in | |
CheckboxView(isSelected: viewModel.facetBinding(facet)) { | |
viewModel.change(facet: facet, to: $0) | |
} label: { | |
Text(facet.label) | |
} | |
.frame(maxWidth: .infinity, alignment: .leading) | |
.padding(.mediumPadding) | |
.background(Color.white) | |
} | |
} | |
.textStyle(.paragraphM) | |
.padding(.border) | |
.background(Color.almostBlack) | |
.padding(.mediumPadding) | |
} | |
.background(Color.white) | |
} | |
var applyButton: some View { | |
LinkView(label: "Apply".localized, | |
hint: viewModel.hint, | |
style: .dark) { | |
onApply(Array(viewModel.selectedFacets)) | |
} | |
.textStyle(.paragraphLBold) | |
.background(Color.almostBlack) | |
.padding(.mediumPadding) | |
.background(Color.white) | |
} | |
} | |
struct FiltersCategoriesView: View { | |
@Environment(\.presentationMode) var mode: Binding<PresentationMode> | |
@StateObject var viewModel: FiltersCategoriesViewModel | |
var onApply: ((result: MerchProductResult, categories: [MerchFacetValue])) -> Void | |
var body: some View { | |
GLNavigationBarLayout(title: "Categories".localized, | |
subtitle: "Filters".localized, | |
leadingView: backButton) { | |
VStack(spacing: .border) { | |
categories | |
applyButton | |
} | |
.background(Color.almostBlack) | |
} | |
.track(screen: .filter(.categories)) | |
.background(Color.almostBlack) | |
} | |
var backButton: some View { | |
Button { | |
mode.wrappedValue.dismiss() | |
} label: { | |
NavigationIcon.backArrow.foregroundColor(.almostBlack) | |
} | |
} | |
var categories: some View { | |
ScrollView { | |
LazyVStack(spacing: .border) { | |
ForEach(viewModel.categories.listed, id: \.element.value.id) { (index, category) in | |
CheckboxView(isSelected: $viewModel.categories[index].value.active) { selected in | |
Task { | |
await viewModel.change(category: category.value, to: selected) | |
} | |
} label: { | |
Text(category.value.label) | |
} | |
.disabled(viewModel.isLoading) | |
.frame(maxWidth: .infinity, alignment: .leading) | |
.padding(.mediumPadding) | |
.padding(.leading, CGFloat((viewModel.categories[index].level - 1)) * .mediumPadding) | |
.background(Color.white) | |
} | |
} | |
.textStyle(.paragraphM) | |
.padding(.border) | |
.background(Color.almostBlack) | |
.padding(.mediumPadding) | |
} | |
.background(Color.white) | |
} | |
var applyButton: some View { | |
LinkView(label: "Apply".localized, | |
disabled: viewModel.isLoading, | |
style: .dark) { | |
onApply((viewModel.result, Array(viewModel.selectedCategories))) | |
} | |
.textStyle(.paragraphLBold) | |
.background(Color.almostBlack) | |
.padding(.mediumPadding) | |
.background(Color.white) | |
} | |
} | |
struct FiltersView: View { | |
@Environment(\.presentationMode) var mode: Binding<PresentationMode> | |
@StateObject var viewModel: FiltersViewModel | |
@StateObject var keyboardHandler = KeyboardHandler() | |
var body: some View { | |
GLNavigationBarLayout(title: "Filters".localized, | |
leadingView: crossButton, | |
trailingView: resetButton) { | |
VStack(spacing: .noSpacing) { | |
filters | |
Divider() | |
.frame(height: .border) | |
.background(Color.almostBlack) | |
LinkView(label: viewModel.seeResultsLabel, | |
disabled: !viewModel.applyButtonEnabled, | |
style: .dark) { | |
viewModel.applyFilters() | |
mode.wrappedValue.dismiss() | |
} | |
.padding(.mediumPadding) | |
keyboardToolbar | |
} | |
.textStyle(.paragraphMBold) | |
} | |
.track(screen: .filters) | |
.sheet(item: $viewModel.openFiltersDetails) { | |
switch $0 { | |
case .brands(let brands, let selectedBrands): | |
FiltersBrandsView(viewModel: FiltersBrandsViewModel(brands: brands, selectedBrands: selectedBrands)) { | |
viewModel.selectBrands($0) | |
} | |
case .categories(let provider, let result, let filters): | |
FiltersCategoriesView(viewModel: FiltersCategoriesViewModel(provider: provider, result: result, filters: filters)) { | |
viewModel.selectCategories(result: $0, categories: $1) | |
} | |
case .discounts(let discounts, let selectedDiscounts): | |
FiltersDiscountsView(viewModel: FiltersDiscountsViewModel(discounts: discounts, selectedDiscounts: selectedDiscounts)) { | |
viewModel.selectDiscounts($0) | |
} | |
case .miscellaneous(let facet, let selectedFacets): | |
FilterDefaultSelectorView(viewModel: FilterDefaultSelectorViewModel(facets: facet.values, selectedFacets: selectedFacets), | |
title: facet.label) { | |
viewModel.selectMiscellaneous(code: facet.code, selection: $0) | |
} | |
case .price(let range): | |
FilterPriceView(viewModel: FilterPriceViewModel(range: range)) { | |
viewModel.selectPriceRange($0) | |
} | |
} | |
} | |
} | |
var keyboardToolbar: some View { | |
VStack(spacing: .noSpacing) { | |
if keyboardHandler.isOpened { | |
Divider() | |
.frame(height: .border) | |
.background(Color.almostBlack) | |
HStack { | |
Spacer() | |
Button("Done") { | |
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) | |
if #unavailable(iOS 15) { | |
Task { | |
await viewModel.onFilterChange() | |
} | |
} | |
} | |
.padding(.mediumPadding) | |
} | |
.background(Color.white) | |
} | |
} | |
} | |
var crossButton: some View { | |
Button { | |
mode.wrappedValue.dismiss() | |
} label: { | |
GLImage.cross | |
} | |
} | |
var resetButton: some View { | |
Button("reset") { | |
viewModel.resetFilters() | |
} | |
.textStyle(.titleXXS) | |
} | |
var filters: some View { | |
ScrollView { | |
VStack(spacing: .border) { | |
sortFilter | |
orderedFilters | |
priceFilter | |
} | |
.padding(.border) | |
.background(Color.almostBlack) | |
.padding(.mediumPadding) | |
.background(Color.white) | |
} | |
.onChange(of: viewModel.selectedFilters.allFilters) { _ in | |
Task { | |
await viewModel.onFilterChange() | |
} | |
} | |
.onChange(of: viewModel.selectedFilters.selectedSort) { _ in | |
Task { | |
await viewModel.onFilterChange() | |
} | |
} | |
.onChange(of: viewModel.selectedFilters.priceRange) { _ in | |
Task { | |
await viewModel.onFilterChange() | |
} | |
} | |
} | |
var orderedFilters: some View { | |
ForEach(viewModel.filters) { | |
switch $0 { | |
case .colors(let presenter): | |
ColorSelectorView(presenter: presenter) | |
case .size(let presenter): | |
SizeSelectorView(presenter: presenter) | |
case .brands(let hint): | |
LinkView(label: "Brands".localized, hint: hint) { | |
viewModel.openBrandsFilter() | |
} | |
case .categories(let hint): | |
LinkView(label: "Categories".localized, hint: hint) { | |
viewModel.openCategoriesFilter() | |
} | |
case .discounts: | |
LinkView(label: "Discount percentage".localized) { | |
viewModel.openDiscountsFilter() | |
} | |
case .miscellaneous(let name, let code, let hint): | |
LinkView(label: name, hint: hint) { | |
viewModel.openMiscellaneousFilter(code: code) | |
} | |
} | |
} | |
} | |
var sortFilter: some View { | |
viewModel.sortFilterPresenter.map { | |
SortSelectorView(presenter: $0) | |
} | |
.padding(.bottom, .smallPadding) | |
.background(Color.white) | |
} | |
var priceFilter: some View { | |
LinkView(label: "Price".localized, hint: viewModel.priceFilterHint) { | |
viewModel.openPriceFilter() | |
} | |
} | |
} | |
class FiltersCategoriesViewModel: ObservableObject { | |
private(set) var result: MerchProductResult | |
private(set) var provider: PLPProviderProtocol | |
private(set) var filters: ActiveFilters | |
@Published var categories: [(value: MerchFacetValue, level: Int)] | |
@Published var isLoading: Bool = false | |
var selectedCategories: [MerchFacetValue] { | |
result.categoryFacets.actives | |
} | |
init(provider: PLPProviderProtocol, result: MerchProductResult, filters: ActiveFilters) { | |
self.provider = provider | |
self.result = result | |
self.categories = result.categoryFacets.map { ($0, $0.level ?? 1) } | |
self.filters = filters | |
} | |
func change(category: MerchFacetValue, to isSelected: Bool) async { | |
if isSelected { | |
select(category: category) | |
} else { | |
unselect(category: category) | |
} | |
isLoading = true | |
do { | |
result = try await provider.getPageWithoutUpdatingResult(page: 0, limit: Constants.UI.PLP.pageSize, query: filters.queryInput(), sort: nil) | |
categories = result.categoryFacets.map { ($0, $0.level ?? 1) } | |
filters.selectedCategories = result.categoryFacets.actives | |
} catch { | |
} | |
isLoading = false | |
} | |
func select(category: MerchFacetValue) { | |
var categories = Set(filters.selectedCategories) | |
categories.insert(category) | |
filters.selectedCategories = Array(categories) | |
} | |
func unselect(category: MerchFacetValue) { | |
var categories = Set(filters.selectedCategories) | |
categories.remove(category) | |
filters.selectedCategories = Array(categories) | |
} | |
} | |
struct FiltersDiscountsView: View { | |
@Environment(\.presentationMode) var mode: Binding<PresentationMode> | |
@StateObject var viewModel: FiltersDiscountsViewModel | |
var onApply: ([MerchFacetValue]) -> Void | |
var body: some View { | |
GLNavigationBarLayout(title: "Discount percentage".localized, | |
subtitle: "Filters".localized, | |
leadingView: backButton) { | |
VStack(spacing: .border) { | |
discounts | |
applyButton | |
} | |
.background(Color.almostBlack) | |
} | |
.track(screen: .filter(.discounts)) | |
.background(Color.almostBlack) | |
} | |
var backButton: some View { | |
Button { | |
mode.wrappedValue.dismiss() | |
} label: { | |
NavigationIcon.backArrow.foregroundColor(.almostBlack) | |
} | |
} | |
var discounts: some View { | |
ScrollView { | |
LazyVStack(spacing: .border) { | |
ForEach(viewModel.discounts) { discount in | |
RadioButtonView(isSelected: viewModel.discountBinding(discount)) { | |
Text(discount.label) | |
} | |
.frame(maxWidth: .infinity, alignment: .leading) | |
.padding(.mediumPadding) | |
.background(Color.white) | |
} | |
} | |
.textStyle(.paragraphM) | |
.padding(.border) | |
.background(Color.almostBlack) | |
.padding(.mediumPadding) | |
.opacity(viewModel.discounts.isEmpty ? 0 : 1) | |
} | |
.background(Color.white) | |
} | |
var applyButton: some View { | |
LinkView(label: "Apply".localized, | |
style: .dark) { | |
onApply(Array(viewModel.selectedDiscounts)) | |
} | |
.textStyle(.paragraphLBold) | |
.background(Color.almostBlack) | |
.padding(.mediumPadding) | |
.background(Color.white) | |
} | |
} | |
class FiltersDiscountsViewModel: ObservableObject { | |
var discounts: [MerchFacetValue] | |
@Published private(set) var selectedDiscounts: Set<MerchFacetValue> | |
init(discounts: [MerchFacetValue], selectedDiscounts: [MerchFacetValue]) { | |
self.discounts = discounts | |
self.selectedDiscounts = Set(selectedDiscounts) | |
} | |
func change(discount: MerchFacetValue, to isSelected: Bool) { | |
if isSelected { | |
selectedDiscounts = [discount] | |
} else { | |
selectedDiscounts = [] | |
} | |
} | |
func discountBinding(_ discount: MerchFacetValue) -> Binding<Bool> { | |
Binding(get: { self.selectedDiscounts.contains(discount) }, | |
set: { self.change(discount: discount, to: $0) }) | |
} | |
} | |
struct ActiveFilters: Equatable { | |
var priceRange: OptionalRange<Int> | |
var isRedirectActive: Bool | |
var selectedSort: MerchSort? | |
var hiddenFacets: [MerchFacetValue] | |
var selectedColors: [MerchFacetValue] | |
var selectedSizes: [MerchFacetValue] | |
var selectedBrands: [MerchFacetValue] | |
var selectedCategories: [MerchFacetValue] | |
var selectedDiscounts: [MerchFacetValue] | |
var selectedMiscellaneous: [MerchFacetValue] | |
var allFilters: [MerchFacetValue] { | |
hiddenFacets + selectedFilters | |
} | |
var selectedFilters: [MerchFacetValue] { | |
selectedColors + selectedSizes + selectedBrands + selectedCategories + selectedDiscounts + selectedMiscellaneous | |
} | |
var visibleFilters: [MerchFacetValue] { | |
selectedFilters.filter { !$0.isHiddenForUser } | |
} | |
internal init(priceRange: OptionalRange<Int> = OptionalRange(), result: MerchProductResult? = nil) { | |
self.priceRange = priceRange | |
self.isRedirectActive = result?.redirect?.active == true | |
self.selectedSort = result?.sorts.first { $0.active } | |
self.hiddenFacets = result?.facets.activeHiddenFacets ?? [] | |
self.selectedColors = result?.colorsFacets.actives ?? [] | |
self.selectedSizes = result?.sizeFacets.actives ?? [] | |
self.selectedBrands = result?.brandsFacets.actives ?? [] | |
self.selectedCategories = result?.categoryFacets.actives ?? [] | |
self.selectedDiscounts = result?.discountsFacets.actives ?? [] | |
self.selectedMiscellaneous = result?.miscellaneousFacets.actives ?? [] | |
} | |
internal init(activeFacets facets: [MerchFacet]) { | |
self.priceRange = OptionalRange() | |
self.isRedirectActive = false | |
self.selectedSort = nil | |
self.hiddenFacets = facets.activeHiddenFacets | |
self.selectedColors = facets.color?.values.actives ?? [] | |
self.selectedSizes = facets.size?.values.actives ?? [] | |
self.selectedBrands = facets.brand?.values.actives ?? [] | |
self.selectedCategories = facets.category?.values.actives ?? [] | |
self.selectedDiscounts = facets.discount?.values.actives ?? [] | |
self.selectedMiscellaneous = facets.miscellaneousFacets.flatMap(\.values).actives | |
} | |
mutating func remove(facet: MerchFacetValue) { | |
selectedColors.remove(facet) | |
selectedSizes.remove(facet) | |
selectedBrands.remove(facet) | |
selectedCategories.remove(facet) | |
selectedDiscounts.remove(facet) | |
selectedMiscellaneous.remove(facet) | |
} | |
func queryInput(search: String? = nil) -> QueryInput? { | |
let searchSelector: [CriterionInput] | |
switch search { | |
case let search? where !isRedirectActive: searchSelector = [CriterionInput(operator: .EQUAL, type: .searchSelector, values: [search])] | |
default: searchSelector = [] | |
} | |
let criterions = Dictionary(grouping: Array(Set(allFilters.filter { !$0.isUnused })), by: \.parentCode) | |
.map { CriterionInput(operator: .IN, selector: $0, values: $1.criterionValues) } | |
.appending(contentsOf: priceSelectors() + searchSelector) | |
.filter { !$0.values.isEmpty } | |
switch criterions.count { | |
case 0: return nil | |
case 1: return .single(criterions[0]) | |
default: return .multiple(.AND, criterions.map { .single($0) }) | |
} | |
} | |
private func priceSelectors() -> [CriterionInput] { | |
var selector: [CriterionInput] = [] | |
if let min = priceRange.lowerBound { | |
selector.append(CriterionInput(operator: .GREATER_THAN_OR_EQUAL, type: .priceSelector, values: [String(min * 100)])) | |
} | |
if let max = priceRange.upperBound { | |
selector.append(CriterionInput(operator: .LESS_THAN, type: .priceSelector, values: [String(max * 100)])) | |
} | |
return selector | |
} | |
func sortInput() -> QueryInput? { | |
guard let sort = selectedSort else { return nil } | |
return .single(CriterionInput(operator: .EQUAL, type: .sortSelector, values: [sort.code])) | |
} | |
var queryTitle: String { | |
let lastCategoryFilter = self.selectedCategories.actives.last | |
let hiddenCatalogFilter = self.hiddenFacets.actives.filter { $0.isFromCatalog }.first | |
return lastCategoryFilter?.label ?? hiddenCatalogFilter?.label ?? .empty | |
} | |
} | |
class ProductTileViewModel: Identifiable, ViewModel { | |
enum Data { | |
case merchProduct(value: MerchProduct) | |
case ecomProduct(value: EcomProductDetails) | |
var moco: MOCO { | |
switch self { | |
case .merchProduct(let value): return value.moco | |
case .ecomProduct(let value): return value.moco | |
} | |
} | |
} | |
var data: ProductTileViewModel.Data | |
var presenter: ProductPreviewPresenter | |
@Published var liked: Bool = false | |
private let wishListProvider: WishListProviderProtocol | |
var cancellable: AnyCancellable? | |
init(product: MerchProduct, wishListProvider: WishListProviderProtocol) { | |
self.data = .merchProduct(value: product) | |
self.presenter = product.toProductTilePresenter() | |
self.wishListProvider = wishListProvider | |
initialize() | |
} | |
init(product: EcomProductDetails, wishListProvider: WishListProviderProtocol) { | |
self.data = .ecomProduct(value: product) | |
self.presenter = product.toProductTilePresenter() | |
self.wishListProvider = wishListProvider | |
initialize() | |
} | |
func initialize() { | |
cancellable = wishListProvider.result.sink { [data, weak self] result in | |
switch result { | |
case .ready(let wishList): | |
let newState = wishList.contains(data.moco) | |
if newState != self?.liked { | |
self?.liked = newState | |
} | |
default: | |
break | |
} | |
} | |
} | |
func switchFavorite() { | |
if !wishListProvider.contains(moco: data.moco), case let .merchProduct(product) = data { | |
Tracking.addToFavorite(merchProduct: product) | |
} | |
wishListProvider.toggleWishList(moco: data.moco) | |
} | |
} | |
struct ProductPreviewPresenter { | |
var brandName: String | |
var productName: String | |
var booster: String? | |
var price: PricePresenter | |
var imageUrl: URL? | |
var isExclusivity: Bool | |
var isGoForGood: Bool | |
} | |
extension SAPCCProduct { | |
func toProductPreviewPresenter() -> ProductPreviewPresenter { | |
ProductPreviewPresenter(brandName: brandName, | |
productName: productName, | |
booster: offerText, | |
price: pricePresenter, | |
imageUrl: imageUrl, | |
isExclusivity: exclusiveProduct ?? false, | |
isGoForGood: isGoForGood) | |
} | |
private var pricePresenter: PricePresenter { | |
switch (priceBeforeDiscount, finalPrice, discount) { | |
case (let before?, let final?, let discount?): | |
return .discount(before: before, discount: discount, after: final) | |
case (let before?, _, _): | |
return .normal(before) | |
case (_, let final?, _): | |
return .normal(final) | |
default: | |
return .none | |
} | |
} | |
private var priceBeforeDiscount: String? { | |
price?.regularRetailPrice?.value?.localized(currency: .EUR, showFractionDigits: true) | |
} | |
private var finalPrice: String? { | |
price?.value?.localized(currency: .EUR, showFractionDigits: true) | |
} | |
private var discount: String? { | |
switch price?.discountPercentage { | |
case let discount? where discount > 0: return String(format: "-%d %%", discount) | |
default: return nil | |
} | |
} | |
private var imageUrl: URL? { | |
getImages(type: .thumbnail).first | |
} | |
} | |
extension EcomProductDetails { | |
func toProductTilePresenter() -> ProductPreviewPresenter { | |
ProductPreviewPresenter(brandName: details.brand?.label ?? .empty, | |
productName: details.name, | |
booster: offer?.shortDescription, | |
price: pricePresenter, | |
imageUrl: images.first, | |
isExclusivity: false, | |
isGoForGood: !goForGood.isEmpty) | |
} | |
private var pricePresenter: PricePresenter { | |
switch (priceBeforeDiscount, priceWithDiscount, discount) { | |
case (let before?, let final?, let discount?): | |
return .discount(before: before, discount: discount, after: final) | |
case (let before?, _, _): | |
return .normal(before) | |
case (_, let final?, _): | |
return .normal(final) | |
default: | |
return .none | |
} | |
} | |
private var priceWithDiscount: String? { | |
price?.current.localizedCents(currency: .EUR) | |
} | |
private var priceBeforeDiscount: String? { | |
price?.regular?.localizedCents(currency: .EUR) | |
} | |
private var discount: String? { | |
switch price?.calculatedDiscount { | |
case let discount? where discount > 0: return String(format: "-%d %%", discount) | |
default: return nil | |
} | |
} | |
} | |
extension MerchProduct { | |
func toProductTilePresenter() -> ProductPreviewPresenter { | |
ProductPreviewPresenter(brandName: brand.label, | |
productName: title, | |
booster: articles.first?.promotion, | |
price: price, | |
imageUrl: imageUrl, | |
isExclusivity: pictos.contains(Constants.StaticData.exclusivity), | |
isGoForGood: pictos.contains(Constants.StaticData.goForGood)) | |
} | |
private var price: PricePresenter { | |
switch (priceBeforeDiscount, finalPrice, discount) { | |
case (let before?, let final?, let discount?): | |
return .discount(before: before, discount: discount, after: final) | |
case (let before?, _, _): | |
return .normal(before) | |
case (_, let final?, _): | |
return .normal(final) | |
default: | |
return .none | |
} | |
} | |
private var imageUrl: URL? { | |
guard let imageKey = articles.first?.imageKey else { | |
return nil | |
} | |
return images.first { $0.key == imageKey }?.urls.first | |
} | |
private var priceBeforeDiscount: String? { | |
articles.first?.price.localizedPrice | |
} | |
private var discount: String? { | |
switch articles.first?.price.discountPercent { | |
case let discount? where discount > 0: return String(format: "-%.0f %%", discount) | |
default: return nil | |
} | |
} | |
private var finalPrice: String? { | |
articles.first?.price.localizedFinalPrice | |
} | |
} | |
extension MerchPrice { | |
var localizedPrice: String? { | |
guard let unitPrice = self.unitPrice else { | |
return nil | |
} | |
return localizedAmount(value: unitPrice) | |
} | |
var localizedFinalPrice: String? { | |
guard let price = self.finalPrice else { | |
return nil | |
} | |
return localizedAmount(value: price) | |
} | |
private func localizedAmount(value: Int) -> String? { | |
CurrencyHelper.localizedAmount(value: value, currency: currency) | |
} | |
} | |
class PLPViewModel: ViewModel { | |
enum ShowMode { | |
case tile, full | |
} | |
struct PLPItem { | |
let product: MerchProduct | |
let showMode: ShowMode | |
} | |
struct FiltersData: Identifiable { | |
var id = "filtersData" | |
var provider: PLPProviderProtocol | |
var result: MerchProductResult | |
var filteredResult: MerchProductResult | |
var searchQuery: String? | |
} | |
var pageTitle: String { | |
switch providedData { | |
case .searchQuery: | |
return "Your search".localized | |
case .queryInput, .none: | |
return activeFilters.queryTitle | |
} | |
} | |
@Published private(set) var state: ViewModelState = .idle | |
@Published private(set) var productList: [PLPItem] = [] | |
@Published private(set) var itemCountText: String? | |
@Published private var providedData: ProvidedData? | |
@Published var filtersData: FiltersData? | |
private var result: MerchProductResult? | |
private var filteredResult: MerchProductResult? | |
@Published private(set) var activeFilters: ActiveFilters | |
@Published var canLoadMore: Bool = true | |
private var currentSorts: [MerchSort] = [] | |
@Published var priceRange: OptionalRange<Int> = OptionalRange() | |
let provider: PLPProviderProtocol | |
private var selectedRange: MerchPriceRange? | |
private var currentPage: Int = 0 | |
private var cancellables: Set<AnyCancellable> = Set() | |
private let pageSize: Int | |
private let refreshTriggerOffset: Int | |
let reloadPublisher: PassthroughSubject<Void, Never> = PassthroughSubject() | |
var searchQuery: String? { | |
switch providedData { | |
case .searchQuery(let value): return value | |
case .queryInput, .none: return nil | |
} | |
} | |
var showChips: Bool { | |
!activeFacets.isEmpty || !priceRange.isEmpty | |
} | |
var filtersHint: String? { | |
let count = activeFacets.count + (priceRange.isEmpty ? 0 : 1) | |
return count > 0 ? String(count) : nil | |
} | |
var activeFacets: [MerchFacetValue] { | |
activeFilters.visibleFilters.actives | |
} | |
enum ProvidedData { | |
case searchQuery(value: String) | |
case queryInput(value: QueryInput) | |
} | |
convenience init(pageSize: Int, refreshTriggerOffset: Int, provider: PLPProviderProtocol, activeFacets: [MerchFacet] = []) { | |
self.init(pageSize: pageSize, refreshTriggerOffset: refreshTriggerOffset, provider: provider, activeFacets: activeFacets, providedData: nil) | |
} | |
convenience init(pageSize: Int, refreshTriggerOffset: Int, provider: PLPProviderProtocol, queryInput: QueryInput) { | |
self.init(pageSize: pageSize, refreshTriggerOffset: refreshTriggerOffset, provider: provider, activeFacets: [], providedData: .queryInput(value: queryInput)) | |
} | |
convenience init(pageSize: Int, refreshTriggerOffset: Int, provider: PLPProviderProtocol, searchQuery: String?) { | |
self.init(pageSize: pageSize, refreshTriggerOffset: refreshTriggerOffset, provider: provider, activeFacets: [], providedData: searchQuery.map { .searchQuery(value: $0) }) | |
} | |
private init(pageSize: Int, refreshTriggerOffset: Int, provider: PLPProviderProtocol, activeFacets: [MerchFacet], providedData: ProvidedData?) { | |
self.provider = provider | |
self.pageSize = pageSize | |
self.refreshTriggerOffset = refreshTriggerOffset | |
self.activeFilters = ActiveFilters(activeFacets: activeFacets) | |
self.providedData = providedData | |
} | |
public func initialize() { | |
provider.result | |
.sink { [weak self] result in | |
switch result { | |
case .idle: | |
self?.state = .idle | |
case .loading: | |
self?.state = .loading | |
case .ready(let merchResults): | |
if self?.result == nil { | |
self?.result = merchResults | |
} | |
self?.filteredResult = merchResults | |
self?.refreshPublishedData(merchResult: merchResults) | |
self?.state = .idle | |
case .error(let error): | |
self?.state = .error(error) | |
} | |
} | |
.store(in: &cancellables) | |
} | |
private func refreshPublishedData(merchResult: MerchProductResult) { | |
self.currentPage = merchResult.page | |
self.canLoadMore = (merchResult.page + 1) * self.pageSize < merchResult.totalItems | |
let newItems: [PLPItem] = merchResult.products.map { product in | |
PLPItem(product: product, showMode: .tile) | |
} | |
if merchResult.page == 0 { | |
self.productList = newItems | |
} else { | |
self.productList += newItems | |
} | |
self.activeFilters = ActiveFilters(priceRange: priceRange, result: merchResult) | |
if merchResult.totalItems > 1 { | |
self.itemCountText = String(format: "%d Articles".localized, merchResult.totalItems) | |
} else { | |
self.itemCountText = String(format: "%d Article".localized, merchResult.totalItems) | |
} | |
self.currentSorts = merchResult.sorts | |
} | |
@MainActor | |
func getFirstPage(force: Bool = false) async { | |
guard productList.isEmpty || force else { | |
return | |
} | |
reloadPublisher.send() | |
await getPage(at: 0) | |
} | |
func getMoreIfNeeded(index: Int) async { | |
guard canLoadMore, productList.count - index < refreshTriggerOffset else { | |
return | |
} | |
await getNextPage() | |
} | |
private func getNextPage() async { | |
guard !isLoading else { | |
return | |
} | |
await getPage(at: currentPage + 1) | |
} | |
private func getPage(at pageIndex: Int) async { | |
switch providedData { | |
case .searchQuery(let value): | |
await provider.getPage(page: pageIndex, | |
limit: pageSize, | |
query: activeFilters.queryInput(search: value), | |
sort: activeFilters.sortInput()) | |
case .queryInput(let value): | |
await provider.getPage(page: pageIndex, | |
limit: pageSize, | |
query: value, | |
sort: activeFilters.sortInput()) | |
case .none: | |
await provider.getPage(page: pageIndex, | |
limit: pageSize, | |
query: activeFilters.queryInput(), | |
sort: activeFilters.sortInput()) | |
} | |
} | |
private var isLoading: Bool { | |
switch state { | |
case .loading: | |
return true | |
default: | |
return false | |
} | |
} | |
func removeFacet(_ facet: MerchFacetValue) { | |
activeFilters.remove(facet: facet) | |
Task { | |
await getFirstPage(force: true) | |
} | |
} | |
func removePriceFilter() { | |
activeFilters.priceRange.reset() | |
priceRange.reset() | |
Task { | |
await getFirstPage(force: true) | |
} | |
} | |
func select(product: MerchProduct) { | |
Tracking.plpSelectItem(product: product) | |
} | |
func openFilters() { | |
guard let result, let filteredResult = filteredResult else { | |
return | |
} | |
filtersData = FiltersData(provider: provider, result: result, filteredResult: filteredResult, searchQuery: searchQuery) | |
} | |
private var filtersObservers: Set<AnyCancellable> = Set() | |
func onFirstPageAppear() { | |
Tracking.plpView(products: productList.map { $0.product }, plpTitle: self.pageTitle) | |
} | |
} | |
extension PLPViewModel { | |
var trackingValue: [String] { | |
return activeFilters.selectedCategories.actives.map { $0.label } | |
} | |
} | |
struct SAPCCPLPItemCountView: View { | |
let productCount: String | |
init(count: Int) { | |
productCount = String(format: NSLocalizedString("productCount", comment: ""), count) | |
} | |
var body: some View { | |
HStack { | |
Text(productCount) | |
.font(.normalBold) | |
.padding(.smallPadding) | |
Spacer() | |
} | |
.background(Color.extraLightGray) | |
} | |
} | |
private struct K { | |
static let refreshTriggerOffset = 10 | |
} | |
class SAPCCPLPViewModel: ObservableObject { | |
private let provider: ProductsProviderProtocol | |
private let type: ProductsResult | |
@Published private(set) var state: ViewModelState = .idle | |
@Published private(set) var products: [SAPCCProduct] = [] | |
@Published private(set) var totalResults: Int? | |
@Published private var canLoadMore: Bool = true | |
private var currentPage: Int = -1 | |
private var cancellables: Set<AnyCancellable> = Set() | |
var pageTitle: String { | |
switch type { | |
case .search: | |
return "Your search".localized | |
case .catalog(let catalog): | |
return catalog | |
} | |
} | |
init(provider: ProductsProviderProtocol, type: ProductsResult) { | |
self.provider = provider | |
self.type = type | |
} | |
func initialize() { | |
provider.result | |
.receive(on: RunLoop.main) | |
.sink { [weak self] result in | |
guard let self else { return } | |
switch result { | |
case .idle: | |
self.state = .idle | |
case .loading: | |
self.state = .loading | |
case .ready(let result): | |
self.refreshProducts(result) | |
self.state = .idle | |
case .error(let error): | |
self.state = .error(error) | |
} | |
} | |
.store(in: &cancellables) | |
} | |
private func refreshProducts(_ result: SAPCCProductSearchPage) { | |
guard let newCurrentPage = result.pagination?.currentPage, currentPage < newCurrentPage else { return } | |
self.currentPage = newCurrentPage | |
self.products = currentPage > 0 ? products + result.products : result.products | |
self.totalResults = products.isEmpty ? 0 : result.pagination?.totalResults | |
self.canLoadMore = canLoadMore(result) | |
} | |
private func canLoadMore(_ result: SAPCCProductSearchPage) -> Bool { | |
guard let currentPage = result.pagination?.currentPage, | |
let totalPages = result.pagination?.totalPages else { return false } | |
return currentPage + 1 < totalPages | |
} | |
func getFirstPage(force: Bool = false) async { | |
guard products.isEmpty || force else { return } | |
await getPage(at: 0) | |
} | |
func getMoreIfNeeded(index: Int) async { | |
guard canLoadMore, products.count - index < K.refreshTriggerOffset else { return } | |
await getNextPage() | |
} | |
private func getNextPage() async { | |
guard !isLoading else { return } | |
await getPage(at: currentPage + 1) | |
} | |
@MainActor | |
private func getPage(at pageIndex: Int) async { | |
try? await provider.getProducts(currentPage: pageIndex, for: type) | |
} | |
private var isLoading: Bool { | |
switch state { | |
case .loading: return true | |
default: return false | |
} | |
} | |
var hasProducts: Bool { | |
!products.isEmpty | |
} | |
} | |
class ProductPreviewModel: ObservableObject { | |
var product: SAPCCProduct | |
var presenter: ProductPreviewPresenter | |
@Published var liked: Bool = false | |
private let wishListProvider: WishListProviderProtocol | |
var cancellable: AnyCancellable? | |
init(product: SAPCCProduct, wishListProvider: WishListProviderProtocol) { | |
self.product = product | |
self.presenter = product.toProductPreviewPresenter() | |
self.wishListProvider = wishListProvider | |
initialize() // onLoad | |
} | |
func initialize() { | |
} | |
func switchFavorite() { | |
} | |
} | |
struct ProductPreview: View { | |
struct K { | |
static let productInfoHeight: CGFloat = 173 | |
} | |
@ObservedObject var viewModel: ProductPreviewModel | |
@EnvironmentObject var providers: ProviderFactory | |
@Binding var toastContent: GLToastInformationContents? | |
var onSelectedProduct: (SAPCCProduct) -> Void | |
private func onAddToWishList() { | |
viewModel.switchFavorite() | |
toastContent = .productAddedToFavorite | |
} | |
private func selectItem() { | |
onSelectedProduct(viewModel.product) | |
} | |
var body: some View { | |
Button(action: selectItem) { productView } | |
} | |
var productView: some View { | |
VStack(spacing: .noSpacing) { | |
productDisplay | |
productInfo | |
} | |
} | |
var productDisplay: some View { | |
ZStack { | |
imageView | |
exclusivityAndAddToWishlistView | |
} | |
.aspectRatio(contentMode: .fit) | |
} | |
var productInfo: some View { | |
VStack(alignment: .leading, spacing: .noSpacing) { | |
Text(viewModel.presenter.brandName) | |
.font(.normalBold) | |
Text(viewModel.presenter.productName) | |
.font(.normal) | |
viewModel.presenter.booster.map { Text($0) } | |
.font(Font.Product.offer) | |
.foregroundColor(.darkGray) | |
.padding(.top, .mediumPadding) | |
Spacer() | |
HStack { | |
priceView | |
Spacer() | |
if viewModel.presenter.isGoForGood { | |
ProductImage.goForGood | |
} | |
} | |
} | |
.foregroundColor(.almostBlack) | |
.padding(.smallPadding) | |
.padding(.vertical, .xSmallPadding) | |
.frame(height: K.productInfoHeight) | |
.lineLimit(2) | |
.multilineTextAlignment(.leading) | |
} | |
var priceView: some View { | |
VStack(alignment: HorizontalAlignment.leading, spacing: .noSpacing) { | |
switch viewModel.presenter.price { | |
case .discount(let before, let discount, let final): | |
Text(before) | |
.font(Font.Product.priceBeforeDiscount) | |
.strikethrough() | |
HStack { | |
Text(final) | |
.font(Font.Product.priceFinal) | |
.foregroundColor(.red) | |
Text(discount) | |
.font(Font.Product.discount) | |
} | |
case .normal(let price): | |
Text(price) | |
.font(Font.Product.priceFinal) | |
case .none: | |
EmptyView() | |
} | |
} | |
} | |
var imageView: some View { | |
AsyncImage(url: viewModel.presenter.imageUrl, | |
contentMode: .fill, | |
sizeFor: .productList, | |
idle: { imageIdleView }, | |
failed: { imageFailedView }) | |
.aspectRatio(.showcaseImageRatio, contentMode: .fill) | |
.background(Color.imageBackground) | |
} | |
var imageIdleView: some View { | |
ZStack { | |
Rectangle() | |
.foregroundColor(.extraLightGray) | |
ImagePlaceHolder.imageLoadingIdle | |
.aspectRatio(.showcaseImageRatio, contentMode: .fill) | |
} | |
} | |
var imageFailedView: some View { | |
ZStack { | |
Rectangle() | |
.foregroundColor(.extraLightGray) | |
ImagePlaceHolder.imageLoadingFailed | |
.aspectRatio(.showcaseImageRatio, contentMode: .fill) | |
} | |
} | |
var exclusivityAndAddToWishlistView: some View { | |
VStack { | |
HStack { | |
if viewModel.presenter.isExclusivity { | |
Text("Exclusivity") | |
.font(Font.Product.exclusivity) | |
.textCase(.uppercase) | |
.padding(.bottom, 1) | |
.padding(.horizontal, 2) | |
.cornerRadius(2) | |
.foregroundColor(.medGray) | |
.background(Color.lightGray) | |
} | |
Spacer() | |
Button { | |
onAddToWishList() | |
} label: { | |
WishListState(isWished: viewModel.liked) | |
} | |
} | |
.padding(.smallPadding) | |
Spacer() | |
} | |
} | |
} | |
struct SAPCCPLPGridView: View { | |
@EnvironmentObject var providers: ProviderFactory | |
@ObservedObject var viewModel: SAPCCPLPViewModel | |
@State var selectedProduct: SAPCCProduct? | |
@Binding private var toastContent: GLToastInformationContents? | |
let enclosingWidth: CGFloat | |
init(viewModel: SAPCCPLPViewModel, toastContent: Binding<GLToastInformationContents?>, enclosingWidth: CGFloat) { | |
self.viewModel = viewModel | |
_toastContent = toastContent | |
self.enclosingWidth = enclosingWidth | |
} | |
var body: some View { | |
LazyVGrid(columns: getGrid(with: enclosingWidth), | |
alignment: .center, | |
spacing: .noSpacing) { | |
ForEach(viewModel.products.indices, id: \.self) { indice in | |
gridItemContent(indice: indice) | |
.onAppear { | |
Task { | |
await viewModel.getMoreIfNeeded(index: indice) | |
} | |
} | |
} | |
} | |
.border(edges: [.top], color: .black) | |
.navigate(using: $selectedProduct) { | |
if let code = $0.baseProduct { | |
ProductDetailsView(viewModel: ProductDetailsViewModel(code: code, | |
productProvider: providers.productProvider(), | |
wishlistProvider: providers.wishlistProvider, | |
cartProvider: providers.sharedCartProvider, | |
target2SellProvider: providers.target2SellProvider())) | |
} | |
} | |
} | |
private func selectItem(_ product: SAPCCProduct) { | |
selectedProduct = product | |
} | |
private func gridItemContent(indice: Int) -> some View { | |
Group { | |
let item = viewModel.products[indice] | |
let productPreviewModel = ProductPreviewModel(product: item, wishListProvider: providers.wishlistProvider) | |
ProductPreview(viewModel: productPreviewModel, toastContent: $toastContent) { | |
selectItem($0) | |
} | |
} | |
.border(edges: self.borderEdges(for: indice), color: .black) | |
} | |
private func getGrid(with enclosingWidth: CGFloat) -> [GridItem] { | |
[GridItem(.adaptive(minimum: getGridWidth(enclosingWidth)), | |
spacing: .noSpacing, | |
alignment: .topLeading)] | |
} | |
private func getGridWidth(_ width: CGFloat) -> CGFloat { | |
width / CGFloat(columnCount) - CGFloat(columnCount) | |
} | |
private var columnCount: Int { | |
UIDevice.isIpad ? 3 : 2 | |
} | |
private func borderEdges(for index: Int) -> [Edge] { | |
let indexIsTrailingElement = (index + 1) % columnCount == 0 | |
return indexIsTrailingElement ? [.bottom] : [.bottom, .trailing] | |
} | |
} | |
struct SAPCCPLPView: View { | |
@StateObject var viewModel: SAPCCPLPViewModel | |
@Environment(\.presentationMode) private var mode: Binding<PresentationMode> | |
@State private var toastContent: GLToastInformationContents? | |
var loadingStateView: some View { | |
HStack { | |
Spacer() | |
VStack(alignment: .center) { | |
switch viewModel.state { | |
case .loading: | |
ProgressView() | |
case .error(let error): | |
Text(error.localizedDescription) | |
.font(.error) | |
default: | |
if viewModel.products.isEmpty { | |
Text("No product found") | |
.font(.error) | |
} | |
} | |
} | |
Spacer() | |
} | |
.padding(.vertical, 8) | |
} | |
var body: some View { | |
GeometryReader { geometry in | |
ScrollView { | |
LazyVStack(spacing: .noSpacing, pinnedViews: [.sectionHeaders]) { | |
Section { | |
viewModel.totalResults.map { SAPCCPLPItemCountView(count: $0) } | |
if viewModel.hasProducts { | |
SAPCCPLPGridView(viewModel: viewModel, | |
toastContent: $toastContent, | |
enclosingWidth: geometry.size.width) | |
} | |
loadingStateView | |
} | |
} | |
} | |
} | |
.navigationBarTitleDisplayMode(.inline) | |
.navigationItem(title: viewModel.pageTitle, style: .dark) | |
.navigationBarBackButtonHidden(true) | |
.navigationBarItems(leading: BackBarButtonItem(mode: mode)) | |
.onLoad { | |
Task { | |
viewModel.initialize() | |
await viewModel.getFirstPage() | |
} | |
} | |
.toast(type: $toastContent) { $0.rawValue } | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment