Untitled
unknown
plain_text
a year ago
26 kB
4
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