Untitled

 avatar
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