Untitled
unknown
plain_text
a year ago
26 kB
5
Indexable
//
// ProductsCategoryViewModel.swift
// Super app
//
// Created by Tako Metonidze on 25.03.24.
// Copyright © 2024 TNET. All rights reserved.
//
import UIKit
import RxSwift
import RxCocoa
import Presentation
import Resolver
import Domain
import Networking
import XCoordinator
//swiftlint:disable:next type_body_length
class ProductsCategoryViewModel: BaseViewModel, ProductCategoryViewModelInputs, ProductCategoryViewModelOutputs {
typealias CollectionViewDataSource = TNETCollectionViewDataSource<ListSectionV2<String>, ListItemV2<String>>
typealias ListSection = ListSectionV2<String>
typealias ListItem = ListItemV2<String>
typealias SnapshotType = NSDiffableDataSourceSnapshot<ListSection, ListItem>
typealias ItemsType = [Product]
let actions: Driver<ProductsCategoryScene.ViewActions>
/// data or ui related actions dispatched by viewmodel
private let actionsSubject: PublishSubject<ProductsCategoryScene.ViewActions> = PublishSubject<ProductsCategoryScene.ViewActions>()
private var dataSource: CollectionViewDataSource?
private var filtersDataSource: CollectionViewDataSource?
@Injected private var repository: ProductsRepositoring
private var sections: [ProductsDashboardSection] = []
private var productsCategoryRequestOptions: ProductsCategoryRequestParams?
private var lastContentOffsetY: CGFloat = .zero
static private var productsPerPage: Int = 20
private let productsDashboardFetcherState: ApiDataFetchState<[ProductsDashboardSection]> = ApiDataFetchState<[ProductsDashboardSection]>()
private let productsListState: ApiDataFetchState<ItemsType> = ApiDataFetchState<ItemsType>(disableOnEmptyResponse: true)
private let filtersState: ApiDataFetchState<FiltersData> = ApiDataFetchState<FiltersData>()
private var requestOptions: ProductsRequestParams = .initial()
private let paginationIndicatorSection = ListSection(id: "pagination-indicator-section", title: "", type: .paginationActivityIndicator)
private let emptyStateItemID = "empty-state"
let sceneOptions: ProductListingScene.Options
@LazyInjected private var appMainNavigator: UnownedRouter<AppMainTabBarRoute>
private let productsListingNavigator: UnownedRouter<ProductsListingRoute>
private var filtersSections = FiltersSections()
// MARK: Lifecycle
init(
withDataOptions options: ProductsRequestParams,
andSceneOptions sceneOptions: ProductListingScene.Options,
productsCategoryOptions: ProductsCategoryRequestParams? = nil,
router: UnownedRouter<ProductsListingRoute>
) {
productsListingNavigator = router
requestOptions = options
self.sceneOptions = sceneOptions
self.productsCategoryRequestOptions = productsCategoryOptions
actions = actionsSubject
.asDriver(onErrorJustReturn: .idle)
super.init()
}
func viewDidLoad() {
Task.detached(priority: .userInitiated) { [weak self] in
guard let self else {
return
}
await self.configureDashboardState()
await self.configureProductsList()
}
}
func setCollectionView(collectionView: TNETCollectionView) {
dataSource = makeDataSource(for: collectionView)
fetchDashboardState()
}
func didSelectItem(at indexPath: IndexPath) {
guard sections.indices.contains(indexPath.section) else {
return
}
let section = sections[indexPath.section]
switch section.data {
case .categories(let items):
guard items.indices.contains(indexPath.row) else {
return
}
let category = items[indexPath.row]
productsListingNavigator.trigger(
.productCategory(
options: .initial(categories: [category.id]),
sceneOptions: sceneOptions,
productsCategoryOptions: .init(categoryID: category.id)
)
)
case .products(let items):
guard items.indices.contains(indexPath.row) else {
return
}
let product = items[indexPath.row]
appMainNavigator.trigger(.productDetails(product: product, title: product.title))
default:
break
}
}
func sectionIndentifier(at index: Int) -> ListSection? {
guard let snapshot = dataSource?.snapshot(), snapshot.sectionIdentifiers.indices.contains(index) else {
return nil
}
return snapshot.sectionIdentifiers[index]
}
func getItemCount() -> Int {
guard let product = self.sections.first(where: { $0.type == .categories }) else {
return 0
}
var items: [ProductCategories] = []
if case .categories(let data) = product.data {
items = data
}
return items.count
}
func navigateToFilters() {
productsListingNavigator.trigger(
.filters(
params: filtersParams(),
repository: repository,
sections: filtersSections,
delegate: self
)
)
}
func willDisplayCell(in collectionView: UICollectionView, at indexPath: IndexPath) {
defer {
lastContentOffsetY = collectionView.contentOffset.y
}
if collectionView.contentOffset.y <= lastContentOffsetY {
return
}
guard case .products(let products) = self.sections.first(where: { $0.type == .products })?.data else {
return
}
if products.isEmpty {
return
}
guard products.count - indexPath.row <= ProductsCategoryViewModel.productsPerPage / 2 else {
return
}
loadNextPage()
}
func loadNextPage() {
Task.detached(priority: .userInitiated) { [weak self] in
guard let self, await self.productsListState.fetchingEnabled else {
return
}
var params = self.requestOptions
params.page += 1
self.requestOptions = params
await self.productsListState.load()
}
}
func getListSectionType(for index: Int) -> ProductsDashboardSection.SectionType? {
guard let snapshot = dataSource?.snapshot() else {
return nil
}
guard snapshot.sectionIdentifiers.indices.contains(index) else {
return nil
}
let section = snapshot.sectionIdentifiers[index]
guard let section = sections.first(where: { $0.id == section.id }) else {
return nil
}
return section.type
}
func fetchDashboardState() {
Task.detached(priority: .userInitiated) { [weak self] in
guard let self else {
return
}
await self.productsDashboardFetcherState.reset()
await self.productsDashboardFetcherState.load()
await self.configureFilter()
}
}
func resetDashboard() {
sections = []
Task.detached(priority: .userInitiated) { [weak self] in
guard let self else {
return
}
await self.resetDashboard(reset: true)
}
}
private func refreshSections(reset: Bool = false) async {
if reset {
await productsDashboardFetcherState.reset()
}
await productsDashboardFetcherState.load()
}
private func configureFilter() async {
await filtersState.set(dataFetcher: { [weak self] _ in
guard let self else {
throw TNETError(reason: .general, code: 0)
}
return try await self.repository.getFiltersData(params: filtersParams(), resetCache: true)
})
await filtersState.set(fetchedNotifier: { [weak self] data, _, _ in
guard let self else {
return
}
self.filtersSections = data.sections
self.actionsSubject.onNext(.filterEnabled(isEnabled: data.hasFilters))
})
await fetchFilter()
}
private func fetchFilter() async {
await filtersState.reset()
await filtersState.load()
}
private func provideCategoryIDForFilters() -> String? {
let categories = requestOptions.safeCategories
guard categories.count == 1 else {
return nil
}
return categories.first
}
private func configureDashboardState() async {
await productsDashboardFetcherState.set(dataFetcher: { [weak self] _ in
guard let self, let productsCategoryRequestOptions else {
throw TNETError(reason: .general, code: 0)
}
return try await self.repository.getProductsCategory(with: productsCategoryRequestOptions)
})
await productsDashboardFetcherState.set(fetchedNotifier: { [weak self] data, _, _ in
guard let self else {
return
}
self.sections = data
let isFilterResultEmpty = !requestOptions.additionalParams.isEmpty
let visualStyle: EmptyStateContentViewConfiguration.VisualStyle = isFilterResultEmpty ? .fullScreenNoFilters : .fullScreen
self.updateEmptyContentViewConfiguration(
contentType: .defaultContentView(visualStyle: visualStyle),
visualStyle: .fullScreen,
visibility: self.sections.isEmpty
)
await self.updateDataSource(with: sections)
})
await productsDashboardFetcherState.set(fetchingStateNotifier: { [weak self] initialFetchDone, state in
guard let self else {
return
}
self.actionsSubject.onNext(.changeEmptyViewVisibility(isVisible: false))
self.actionsSubject.onNext(.endRefreshing)
switch state {
case .fetching:
if !initialFetchDone {
self.actionsSubject.onNext(.skeletonVisible(visible: true))
self.actionsSubject.onNext(.scrollToTop)
}
case .error:
self.actionsSubject.onNext(.skeletonVisible(visible: false))
self.actionsSubject.onNext(.updateEmptyContentViewConfiguration(contentViewType: .error(visualStyle: .fullScreen), visualStyle: .fullScreen))
default:
break
}
})
}
//swiftlint:disable:next function_body_length cyclomatic_complexity
private func configureProductsList() async {
await productsListState.set(dataFetcher: { [weak self] _ in
guard let self else {
throw TNETError(reason: .general, code: 0)
}
self.requestOptions.vendors = ["\(self.sceneOptions.vendorID ?? "")"]
if let categoryID = self.productsCategoryRequestOptions?.categoryID {
self.requestOptions.categories = [categoryID]
}
return try await self.repository.getProducts(with: self.requestOptions)
})
await productsListState.set(fetchedNotifier: { [weak self] newItems, _, wasInitialFetch in
guard let self else {
return
}
guard let productIndex = self.sections.firstIndex(where: { $0.type == .products }) else {
return
}
var product = self.sections[productIndex]
if case .products(var data) = product.data {
data.appendReplace(contentsOf: newItems) {
$0.id == $1.id
}
product.data = .products(data)
self.sections[productIndex] = product
DispatchQueue.main.asyncAfter(deadline: wasInitialFetch ? .now() + 1.0 : .now()) { [weak self] in
guard let self else {
return
}
Task.detached { [weak self] in
guard let self else {
return
}
await self.didFetch(products: newItems)
}
}
}
})
await productsListState.set(fetchingStateNotifier: { [weak self] _, state in
guard let self else {
return
}
self.actionsSubject.onNext(.changeEmptyViewVisibility(isVisible: false))
switch state {
case .fetching:
await self.beginRefreshing()
default:
break
}
})
await productsListState.set(resetNotifier: { [weak self] in
guard let self else {
return
}
var currentOptions = self.requestOptions
currentOptions.page = 1
self.sections = []
self.requestOptions = currentOptions
})
// await refreshLists()
}
private func refreshLists(reset: Bool = false) async {
if reset {
await productsListState.reset()
}
await productsListState.load()
}
private func resetDashboard(reset: Bool = false) async {
if reset {
await productsDashboardFetcherState.reset()
}
await productsDashboardFetcherState.load()
}
private func makeDataSource(for collectionView: TNETCollectionView) -> CollectionViewDataSource? {
let cellRegistrations: CollectionViewDataSource.CellRegistrations = [
.productCategories: getProductsCategoryCellRegistration(),
.banner: getBannersCellRegistration(),
.product: getProductsCellRegistration(),
.refreshControl: getRefreshControlCellRegistration(),
.productListingCompactEmptyState: getCompactEmptyCellRegistration(),
.skeleton: getSkeletonCellRegistration()
]
dataSource = CollectionViewDataSource(collectionView: collectionView, cellRegisrations: cellRegistrations)
dataSource?.numberOfSkeletonsSections = {
return 1
}
dataSource?.numberOfSkeletonsItemsInSection = { _ in
return 1
}
var snapshot = SnapshotType()
snapshot.appendSections([ListSection(id: "placeholder", title: "")])
dataSource?.apply(snapshot, animatingDifferences: false)
return dataSource
}
private func configureEmptyStateView() -> SceneStateView.Configuration {
let isFilterResultEmpty = !requestOptions.additionalParams.isEmpty
let title = isFilterResultEmpty ? "filter_result_empty_title".localized : "product_listing_empty_state_title".localized
let description: String? = isFilterResultEmpty ? "product_category_result_empty_description".localized : nil
let buttonTitle = isFilterResultEmpty ? "filter_result_empty_primary_button_title".localized : "update".localized
let buttonWidth = isFilterResultEmpty ? 200.0.scaledWidth : 125.0.scaledWidth
let titleFont: UIFont = isFilterResultEmpty ? .h3 : .body3
let emptyView: SceneStateView.Configuration = .empty(
title: title,
description: description,
imageSource: .image(image: UIImage(image: .list).withTintColor(UIColor[.error], renderingMode: .alwaysOriginal)),
style: .compact(buttonSize: .init(width: .absolute(buttonWidth), height: .absolute(40.0.scaledWidth)), sizePriority: .required),
fontType: .custom(font: titleFont, descriptionFont: .body4),
buttonTitle: buttonTitle,
imageTopPadding: .zero,
titleTopPadding: CGFloat.spacing8.scaledWidth,
descriptionTopPadding: CGFloat.spacing8.scaledWidth,
buttonPosition: .nearDescription,
iconSize: .init(width: .absolute(34.0.scaledWidth), height: .absolute(34.0.scaledWidth)),
action: UIAction { [weak self] _ in
guard let self else {
return
}
if isFilterResultEmpty {
self.navigateToFilters()
return
}
self.resetDashboard()
}
)
return emptyView
}
private func getCompactEmptyCellRegistration() -> CollectionViewDataSource.CellRegistration {
CollectionViewDataSource.CellRegistration { [weak self] cell, _, _ in
guard let self else {
return
}
let config = EmptyStateContentConfigurationV2(
emptyStateConfiguration: self.configureEmptyStateView()
)
cell.contentConfiguration = config
}
}
private func getRefreshControlCellRegistration() -> CollectionViewDataSource.CellRegistration {
CollectionViewDataSource.CellRegistration { [weak self] cell, _, _ in
guard let self else {
return
}
let configurationBuilder = TNETCellContentConfigurationProvider()
let config = configurationBuilder.provideRefreshControl() as? RefreshControlContentViewConfiguration
cell.contentConfiguration = config
}
}
private func getSkeletonCellRegistration() -> CollectionViewDataSource.CellRegistration {
CollectionViewDataSource.CellRegistration { [weak self] cell, _, _ in
guard let self else {
return
}
let configuration = ProductsCategorySkeletonContentConfiguration()
cell.contentConfiguration = configuration
}
}
private func getProductsCategoryCellRegistration() -> CollectionViewDataSource.CellRegistration {
CollectionViewDataSource.CellRegistration { [weak self] cell, _, identifier in
guard let self else {
return
}
guard let product = self.sections.first(where: { $0.type == .categories }) else {
return
}
var items: [ProductCategories] = []
if case .categories(let data) = product.data {
items = data
}
guard let item = items.first(where: { $0.id == identifier.id }) else {
return
}
let configuration = ProductsCategoryContentConfiguration(productCategory: item)
cell.contentConfiguration = configuration
}
}
private func getBannersCellRegistration() -> CollectionViewDataSource.CellRegistration {
CollectionViewDataSource.CellRegistration { [weak self] cell, _, _ in
guard let self else {
return
}
guard let product = self.sections.first(where: { $0.type == .banners }) else {
return
}
var items: [Banner] = []
if case .banners(let data) = product.data {
items = data
}
let viewModel = BannersViewModel(withDashboardSection: DashboardSection(
id: "",
sectionType: .horizontalBanners,
contentType: .bannersItem,
icon: nil,
destination: .productListing,
displayName: "banners_section".localized,
description: "banners_section".localized,
hasAllButton: false,
additionalInfo: .init(
pagination: .init(hasPaginationBullets: items.count < 1),
filters: [],
vendors: []
),
itemRatio: nil,
items: .banners(items: items),
categories: [],
badges: []
))
var configuration = BannersContentConfiguration(viewModel: viewModel)
configuration.onButtonTap = { [weak self] banner in
guard let self else {
return
}
self.didSelectBanner(banner)
}
configuration.onErrorCallback = { [weak self] id in
guard let self else {
return
}
self.deleteSection(with: id)
}
cell.contentConfiguration = configuration
}
}
private func getProductsCellRegistration() -> CollectionViewDataSource.CellRegistration {
CollectionViewDataSource.CellRegistration { [weak self] cell, _, identifier in
guard let self else {
return
}
guard let product = self.sections.first(where: { $0.type == .products }) else {
return
}
var items: [Product] = []
if case .products(let data) = product.data {
items = data
}
guard let item = items.first(where: { $0.id == identifier.id }) else {
return
}
let configurationBuilder = TNETCellContentConfigurationProvider()
var config = configurationBuilder.provideProduct(for: item) as? ProductContentViewConfiguration
config?.imageRatio = self.sceneOptions.itemRatio == .landscape ? .landscape : .portrait
config?.dynamicImageRation = self.sceneOptions.itemRatio == .landscape
config?.pinToBottom = true
cell.contentConfiguration = config
}
}
@MainActor
private func didFetch(products: [Product], reset: Bool = false) {
guard var snapshot = dataSource?.snapshot() else {
return
}
snapshot.deleteSections([paginationIndicatorSection])
if let item = snapshot.itemIdentifiers.first(where: { $0.id == emptyStateItemID }) {
snapshot.deleteItems([item])
self.actionsSubject.onNext(.compactEmptyStateVisibility(isVisible: false))
}
guard let productSection = sections.first(where: { $0.type == .products }) else {
return
}
guard let snapshotSection = snapshot.sectionIdentifiers.first(where: { $0.id == productSection.id }) else {
return
}
let allSectionitems = snapshot.itemIdentifiers(inSection: snapshotSection).map { $0.id }
let desiredItems = products.map { ListItem(id: $0.id, sectionIdentifier: snapshotSection.id, type: .product) }
let existingItems = desiredItems.filter { allSectionitems.contains($0.id) }
let newItems = desiredItems.filter { !allSectionitems.contains($0.id) }
snapshot.reconfigureItems(existingItems)
snapshot.appendItems(newItems, toSection: snapshotSection)
dataSource?.apply(snapshot, animatingDifferences: false)
}
@MainActor
private func beginRefreshing() {
guard var snapshot = dataSource?.snapshot() else {
return
}
if !snapshot.sectionIdentifiers.contains(paginationIndicatorSection) {
snapshot.appendSections([paginationIndicatorSection])
}
if let emptyState = snapshot.itemIdentifiers.first(where: { $0.id == emptyStateItemID }) {
snapshot.deleteItems([emptyState])
actionsSubject.onNext(.compactEmptyStateVisibility(isVisible: false))
}
let refreshControlItem = ListItem(id: "refresh-control", sectionIdentifier: paginationIndicatorSection.id, type: .refreshControl)
snapshot.appendItems([refreshControlItem], toSection: paginationIndicatorSection)
actionsSubject.onNext(.skeletonVisible(visible: false))
dataSource?.apply(snapshot, animatingDifferences: false)
}
@MainActor
private func updateDataSource(with sections: [ProductsDashboardSection], reset: Bool = false) {
var snapshot = SnapshotType()
let newSections: [ListSection] = sections.map { ListSection(id: $0.id, title: "", type: .regular) }
snapshot.appendSections(newSections)
for dataSection in sections {
guard let listSection = snapshot.sectionIdentifiers.first(where: { $0.id == dataSection.id }) else {
continue
}
var listItems: [ListItem] = []
let isAllSectionEmpty = allSectionsAreEmpty(sections: sections)
switch dataSection.data {
case .categories(let items):
if !items.isEmpty {
listItems = items.map { ListItem(id: $0.id, sectionIdentifier: listSection.id, type: .productCategories) }
}
case .banners(let items):
if !items.isEmpty {
listItems = [ListItem(id: dataSection.id, sectionIdentifier: listSection.id, type: .banner)]
}
case .products(let items):
if items.isEmpty && !isAllSectionEmpty {
listItems = [ListItem(id: emptyStateItemID, sectionIdentifier: listSection.id, type: .productListingCompactEmptyState)]
} else {
listItems = items.map { ListItem(id: $0.id, sectionIdentifier: listSection.id, type: .product) }
}
self.actionsSubject.onNext(.compactEmptyStateVisibility(isVisible: items.isEmpty && !isAllSectionEmpty))
case .unknown:
continue
}
let isFilterResultEmpty = !requestOptions.additionalParams.isEmpty
let visualStyle: EmptyStateContentViewConfiguration.VisualStyle = isFilterResultEmpty ? .fullScreenNoFilters : .fullScreen
self.updateEmptyContentViewConfiguration(
contentType: .defaultContentView(visualStyle: visualStyle),
visualStyle: visualStyle,
visibility: isAllSectionEmpty
)
snapshot.appendItems(listItems, toSection: listSection)
}
actionsSubject.onNext(.skeletonVisible(visible: false))
actionsSubject.onNext(.endRefreshing)
dataSource?.apply(snapshot, animatingDifferences: false)
}
private func allSectionsAreEmpty(sections: [ProductsDashboardSection]) -> Bool {
var empty: [Bool] = []
for section in sections {
empty.append(section.data.identifiers.isEmpty)
}
return !empty.contains(false)
}
private func deleteSection(with sectionId: String) {
guard var snapshot = dataSource?.snapshot() else {
return
}
guard let itemIndex = snapshot.itemIdentifiers.firstIndex(where: { $0.id == sectionId }) else {
return
}
let item = snapshot.itemIdentifiers[itemIndex]
snapshot.deleteItems([item])
if itemIndex > 0 {
let previousItem = snapshot.itemIdentifiers[itemIndex - 1]
snapshot.reconfigureItems([previousItem])
}
sections = sections.filter({ $0.id != sectionId })
dataSource?.apply(snapshot)
}
internal func didSelectBanner(_ banner: Banner) {
guard let url = banner.link.asURL else {
return
}
switch banner.link.target {
case .blank:
guard UIApplication.shared.canOpenURL(url) else {
return
}
UIApplication.shared.open(url)
case .deep:
actionsSubject.onNext(.isLoading(true))
let handler = Resolver.resolve(DeepLinkDriver.self)
handler.didRecieve(url: url) { [weak self] in
guard let self else {
return
}
self.actionsSubject.onNext(.isLoading(false))
}
case .dynamic:
actionsSubject.onNext(.isLoading(true))
FirebaseDynamicLinkHandler.shared.handle(safeInternal: url) { [weak self] in
guard let self else {
return
}
self.actionsSubject.onNext(.isLoading(false))
}
}
}
private func updateEmptyContentViewConfiguration(
contentType: EmptyStateContentViewConfiguration.EmptyStateContentViewType,
visualStyle: EmptyStateContentViewConfiguration.VisualStyle,
visibility: Bool
) {
self.actionsSubject.onNext(.updateEmptyContentViewConfiguration(contentViewType: contentType, visualStyle: visualStyle))
self.actionsSubject.onNext(.skeletonVisible(visible: false))
self.actionsSubject.onNext(.changeEmptyViewVisibility(isVisible: visibility))
}
private func filtersParams() -> FiltersParams {
.init(
serviceParams: [
"category_id": productsCategoryRequestOptions?.categoryID,
"section_id": productsCategoryRequestOptions?.sectionId
].compactMapValues { $0 }
)
}
}
extension ProductsCategoryViewModel: FiltersDelegate {
func filters(_ sections: FiltersSections) {
// if NSDictionary(dictionary: filterParams).isEqual(to: requestOptions.additionalParams) {
// return
// }
self.filtersSections = sections
requestOptions.page = 1
// requestOptions.categories = nil
requestOptions.additionalParams = sections.toQueryItems()
productsCategoryRequestOptions?.additionalParams = sections.toQueryItems()
Task { [weak self] in
guard let self else {
return
}
await self.refreshSections(reset: true)
}
}
}
Editor is loading...
Leave a Comment