Skip to content

Instantly share code, notes, and snippets.

@FlorianTousch
Created April 25, 2023 13:18
Show Gist options
  • Save FlorianTousch/e0435260b4ed63aa9252a1d77091c8d2 to your computer and use it in GitHub Desktop.
Save FlorianTousch/e0435260b4ed63aa9252a1d77091c8d2 to your computer and use it in GitHub Desktop.
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