// // AddSourceViewController.swift // AltStore // // Created by Riley Testut on 9/26/23. // Copyright © 2023 Riley Testut. All rights reserved. // import UIKit import Combine import AltStoreCore import Roxas import Nuke private extension UIAction.Identifier { static let addSource = UIAction.Identifier("io.sidestore.AddSource") } private typealias SourcePreviewResult = (sourceURL: URL, result: Result, Error>) extension AddSourceViewController { private enum Section: Int { case add case preview case recommended } private enum ReuseID: String { case textFieldCell = "TextFieldCell" case placeholderFooter = "PlaceholderFooter" } private class ViewModel: ObservableObject { /* Pipeline */ @Published var sourceAddress: String = "" @Published var sourceURLs: [URL] = [] @Published var sourcePreviewResults: [SourcePreviewResult] = [] /* State */ @Published var isLoadingPreview: Bool = false @Published var isShowingPreviewStatus: Bool = false } } class AddSourceViewController: UICollectionViewController { private var stagedForAdd: [Source: Bool] = [:] private lazy var dataSource = self.makeDataSource() private lazy var addSourceDataSource = self.makeAddSourceDataSource() private lazy var sourcePreviewDataSource = self.makeSourcePreviewDataSource() private lazy var recommendedSourcesDataSource = self.makeRecommendedSourcesDataSource() private var fetchRecommendedSourcesOperation: UpdateKnownSourcesOperation? private var fetchRecommendedSourcesResult: Result? private var _fetchRecommendedSourcesContext: NSManagedObjectContext? private let viewModel = ViewModel() private var cancellables: Set = [] override func viewDidLoad() { super.viewDidLoad() self.navigationController?.isModalInPresentation = true self.navigationController?.view.tintColor = .altPrimary let layout = self.makeLayout() self.collectionView.collectionViewLayout = layout self.collectionView.register(AppBannerCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier) self.collectionView.register(AddSourceTextFieldCell.self, forCellWithReuseIdentifier: ReuseID.textFieldCell.rawValue) self.collectionView.register(UICollectionViewListCell.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: UICollectionView.elementKindSectionHeader) self.collectionView.register(UICollectionViewListCell.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: UICollectionView.elementKindSectionFooter) self.collectionView.register(PlaceholderCollectionReusableView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: ReuseID.placeholderFooter.rawValue) self.collectionView.backgroundColor = .altBackground self.collectionView.keyboardDismissMode = .onDrag self.collectionView.dataSource = self.dataSource self.collectionView.prefetchDataSource = self.dataSource self.startPipeline() } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) if self.fetchRecommendedSourcesOperation == nil { self.fetchRecommendedSources() } } } private extension AddSourceViewController { func makeLayout() -> UICollectionViewCompositionalLayout { let layoutConfig = UICollectionViewCompositionalLayoutConfiguration() layoutConfig.contentInsetsReference = .safeArea let layout = UICollectionViewCompositionalLayout(sectionProvider: { [weak self] (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in guard let self, let section = Section(rawValue: sectionIndex) else { return nil } switch section { case .add: let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44)) let item = NSCollectionLayoutItem(layoutSize: itemSize) let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44)) let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(20)) let headerItem = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top) let layoutSection = NSCollectionLayoutSection(group: group) layoutSection.interGroupSpacing = 10 layoutSection.boundarySupplementaryItems = [headerItem] return layoutSection case .preview: var configuration = UICollectionLayoutListConfiguration(appearance: .grouped) configuration.showsSeparators = false configuration.backgroundColor = .clear if !self.viewModel.sourceURLs.isEmpty && self.viewModel.isShowingPreviewStatus { for result in self.viewModel.sourcePreviewResults { switch result { case (_, .success): configuration.footerMode = .none case (_, .failure): configuration.footerMode = .supplementary break // case nil where self.viewModel.isLoadingPreview: configuration.footerMode = .supplementary // break // default: configuration.footerMode = .none } } } else { configuration.footerMode = .none } let layoutSection = NSCollectionLayoutSection.list(using: configuration, layoutEnvironment: layoutEnvironment) return layoutSection case .recommended: var configuration = UICollectionLayoutListConfiguration(appearance: .grouped) configuration.showsSeparators = false configuration.backgroundColor = .clear switch self.fetchRecommendedSourcesResult { case nil: configuration.headerMode = .supplementary configuration.footerMode = .supplementary case .failure: configuration.footerMode = .supplementary case .success: configuration.headerMode = .supplementary } let layoutSection = NSCollectionLayoutSection.list(using: configuration, layoutEnvironment: layoutEnvironment) return layoutSection } }, configuration: layoutConfig) return layout } func makeDataSource() -> RSTCompositeCollectionViewPrefetchingDataSource { let dataSource = RSTCompositeCollectionViewPrefetchingDataSource(dataSources: [self.addSourceDataSource, self.sourcePreviewDataSource, self.recommendedSourcesDataSource]) dataSource.proxy = self return dataSource } func makeAddSourceDataSource() -> RSTDynamicCollectionViewPrefetchingDataSource { let dataSource = RSTDynamicCollectionViewPrefetchingDataSource() dataSource.numberOfSectionsHandler = { 1 } dataSource.numberOfItemsHandler = { _ in 1 } dataSource.cellIdentifierHandler = { _ in ReuseID.textFieldCell.rawValue } dataSource.cellConfigurationHandler = { [weak self] cell, source, indexPath in guard let self else { return } let cell = cell as! AddSourceTextFieldCell cell.contentView.layoutMargins.left = self.view.layoutMargins.left cell.contentView.layoutMargins.right = self.view.layoutMargins.right cell.textField.delegate = self cell.setNeedsLayout() cell.layoutIfNeeded() NotificationCenter.default .publisher(for: UITextField.textDidChangeNotification, object: cell.textField) .map { ($0.object as? UITextField)?.text ?? "" } .assign(to: &self.viewModel.$sourceAddress) // Results in memory leak // .assign(to: \.viewModel.sourceAddress, on: self) } return dataSource } func makeSourcePreviewDataSource() -> RSTArrayCollectionViewPrefetchingDataSource { let dataSource = RSTArrayCollectionViewPrefetchingDataSource(items: []) dataSource.cellConfigurationHandler = { [weak self] cell, source, indexPath in guard let self else { return } let cell = cell as! AppBannerCollectionViewCell self.configure(cell, with: source) } dataSource.prefetchHandler = { (source, indexPath, completionHandler) in guard let imageURL = source.effectiveIconURL else { return nil } return RSTAsyncBlockOperation() { (operation) in ImagePipeline.shared.loadImage(with: imageURL, progress: nil) { result in guard !operation.isCancelled else { return operation.finish() } switch result { case .success(let response): completionHandler(response.image, nil) case .failure(let error): completionHandler(nil, error) } } } } dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in let cell = cell as! AppBannerCollectionViewCell cell.bannerView.iconImageView.isIndicatingActivity = false cell.bannerView.iconImageView.image = image if let error = error { print("Error loading image:", error) } } return dataSource } func makeRecommendedSourcesDataSource() -> RSTArrayCollectionViewPrefetchingDataSource { let dataSource = RSTArrayCollectionViewPrefetchingDataSource(items: []) dataSource.cellConfigurationHandler = { [weak self] cell, source, indexPath in guard let self else { return } let cell = cell as! AppBannerCollectionViewCell self.configure(cell, with: source) } dataSource.prefetchHandler = { (source, indexPath, completionHandler) in guard let imageURL = source.effectiveIconURL else { return nil } return RSTAsyncBlockOperation() { (operation) in ImagePipeline.shared.loadImage(with: imageURL, progress: nil) { result in guard !operation.isCancelled else { return operation.finish() } switch result { case .success(let response): completionHandler(response.image, nil) case .failure(let error): completionHandler(nil, error) } } } } dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in let cell = cell as! AppBannerCollectionViewCell cell.bannerView.iconImageView.isIndicatingActivity = false cell.bannerView.iconImageView.image = image if let error = error { print("Error loading image:", error) } } return dataSource } } private extension AddSourceViewController { func startPipeline() { /* Pipeline */ // Map UITextField text -> URLs self.viewModel.$sourceAddress .map { [weak self] in guard let self else { return [] } print("\n\nStarting pipeline processing...\n\n") let lines = $0.split(whereSeparator: { $0.isWhitespace }).map(String.init).compactMap(self.sourceURL) return lines } .assign(to: &self.viewModel.$sourceURLs) let showPreviewStatusPublisher = self.viewModel.$isShowingPreviewStatus .filter { $0 == true } let sourceURLsPublisher = self.viewModel.$sourceURLs .removeDuplicates() .debounce(for: 0.2, scheduler: RunLoop.main) .receive(on: RunLoop.main) .map { [weak self] sourceURLs in // Only set sourcePreviewResult to nil if sourceURL actually changes. self?.viewModel.sourcePreviewResults = [] return sourceURLs } // Map URL -> Source Preview Publishers.CombineLatest(sourceURLsPublisher, showPreviewStatusPublisher.prepend(false)) .receive(on: RunLoop.main) .map { $0.0 } .flatMap { [weak self] (sourceURLs: [URL]) -> AnyPublisher<[SourcePreviewResult?], Never> in guard let self else { return Just([]).eraseToAnyPublisher() } self.viewModel.isLoadingPreview = true let publishers = sourceURLs.map { sourceURL in print("Creating preview for source:", sourceURL, " ...") return self.fetchSourcePreview(sourceURL: sourceURL) .eraseToAnyPublisher() } return publishers.isEmpty ? Just([]).eraseToAnyPublisher() : Publishers.MergeMany(publishers) .collect() .eraseToAnyPublisher() } .sink { [weak self] sourcePreviewResults in self?.viewModel.isLoadingPreview = false self?.viewModel.sourcePreviewResults = sourcePreviewResults.compactMap{$0} } .store(in: &self.cancellables) /* Update UI */ Publishers.CombineLatest(self.viewModel.$isLoadingPreview.removeDuplicates(), self.viewModel.$isShowingPreviewStatus.removeDuplicates()) .sink { [weak self] _ in guard let self else { return } // @Published fires _before_ property is updated, so wait until next run loop. DispatchQueue.main.async { self.collectionView.performBatchUpdates { let indexPath = IndexPath(item: 0, section: Section.preview.rawValue) if let footerView = self.collectionView.supplementaryView(forElementKind: UICollectionView.elementKindSectionFooter, at: indexPath) as? PlaceholderCollectionReusableView { self.configure(footerView, with: self.viewModel.sourcePreviewResults) } let context = UICollectionViewLayoutInvalidationContext() context.invalidateSupplementaryElements(ofKind: UICollectionView.elementKindSectionFooter, at: [indexPath]) self.collectionView.collectionViewLayout.invalidateLayout(with: context) } } } .store(in: &self.cancellables) // self.viewModel.$sourcePreviewResults // .map { sourcePreviewResults -> [Source] in // // Process the full array: // // - For each tuple, extract the `result` (the second element) // // - For each result, convert it to a Managed if it's successful // // - Remove any nil values from failed results // let managedSources = sourcePreviewResults.compactMap { previewResult -> Managed? in // switch previewResult.result { // case .success(let source): // return source // case .failure: // return nil // } // } // // Optionally, remove duplicates based on identifier: // // (This groups by identifier and keeps the first occurrence.) // let uniqueManagedSources = Dictionary(grouping: managedSources, by: { $0.identifier }) // .compactMap { $0.value.first } // // // Unwrap Managed into Source (assuming Managed has a wrappedValue property) // let sources = uniqueManagedSources.map { $0.wrappedValue } // return sources // } // .receive(on: RunLoop.main) // .sink { [weak self] sources in // self?.updateSourcesPreview(for: sources) // } // .store(in: &self.cancellables) self.viewModel.$sourcePreviewResults .map { sourcePreviewResults -> [Source] in var seenIdentifiers = Set() let orderedSources = sourcePreviewResults.compactMap { previewResult -> Source? in switch previewResult.result { case .success(let managedSource): let id = managedSource.identifier guard !seenIdentifiers.contains(id) else { return nil } seenIdentifiers.insert(id) return managedSource.wrappedValue case .failure: return nil } } return orderedSources } .receive(on: RunLoop.main) .sink { [weak self] sources in self?.updateSourcesPreview(for: sources) } .store(in: &self.cancellables) let mergedNotificationPublisher = Publishers.Merge( NotificationCenter.default.publisher(for: AppManager.didAddSourceNotification), NotificationCenter.default.publisher(for: AppManager.didRemoveSourceNotification) ) .receive(on: RunLoop.main) .share() // Shares the upstream publisher with multiple subscribers // Update recommended sources section when sources are added/removed mergedNotificationPublisher .compactMap { notification -> String? in guard let source = notification.object as? Source, let context = source.managedObjectContext else { return nil } let sourceID = context.performAndWait { source.identifier } return sourceID } .compactMap { [dataSource = recommendedSourcesDataSource] sourceID -> IndexPath? in guard let index = dataSource.items.firstIndex(where: { $0.identifier == sourceID }) else { return nil } let indexPath = IndexPath(item: index, section: Section.recommended.rawValue) return indexPath } .sink { [weak self] indexPath in // Added or removed a recommended source, so make sure to update its state. self?.collectionView.reloadItems(at: [indexPath]) } .store(in: &self.cancellables) // Update previews section when sources are added/removed // mergedNotificationPublisher // .sink { [weak self] _ in // // reload the entire of previews section to get latest state // self?.collectionView.reloadSections(IndexSet(integer: Section.preview.rawValue)) // } // .store(in: &self.cancellables) mergedNotificationPublisher .compactMap { notification -> String? in guard let source = notification.object as? Source, let context = source.managedObjectContext else { return nil } return context.performAndWait { source.identifier } } .compactMap { [weak self] sourceID -> IndexPath? in guard let dataSource = self?.sourcePreviewDataSource, let index = dataSource.items.firstIndex(where: { $0.identifier == sourceID }) else { return nil } return IndexPath(item: index, section: Section.preview.rawValue) } .sink { [weak self] indexPath in self?.collectionView.reloadItems(at: [indexPath]) } .store(in: &self.cancellables) } func sourceURL(from address: String) -> URL? { guard let sourceURL = URL(string: address) else { return nil } // URLs without hosts are OK (e.g. localhost:8000) // guard sourceURL.host != nil else { return } guard let scheme = sourceURL.scheme else { let sanitizedURL = URL(string: "https://" + address) return sanitizedURL } guard scheme.lowercased() != "localhost" else { let sanitizedURL = URL(string: "http://" + address) return sanitizedURL } return sourceURL } func fetchSourcePreview(sourceURL: URL) -> some Publisher { let context = DatabaseManager.shared.persistentContainer.newBackgroundSavingViewContext() var fetchOperation: FetchSourceOperation? return Future { promise in fetchOperation = AppManager.shared.fetchSource(sourceURL: sourceURL, managedObjectContext: context) { result in promise(result) } } .map { source in let result = SourcePreviewResult(sourceURL, .success(Managed(wrappedValue: source))) return result } .catch { error in print("Failed to fetch source for URL \(sourceURL).", error.localizedDescription) let result = SourcePreviewResult(sourceURL, .failure(error)) return Just(result) } .handleEvents(receiveCancel: { fetchOperation?.cancel() }) } func updateSourcesPreview(for sources: [Source]) { // Calculate changes needed to go from current items to new items let currentItemCount = self.sourcePreviewDataSource.items.count let newItemCount = sources.count var changes: [RSTCellContentChange] = [] if currentItemCount == 0 && newItemCount > 0 { // Insert all items if we currently have none for i in 0.. 0 && newItemCount == 0 { // Delete all items if we're going to have none for i in 0..(priority: .userInitiated) { [weak cell] in guard let cell else { return } var isSourceAlreadyPersisted = false do { isSourceAlreadyPersisted = try await source.isAdded } catch { print("Failed to determine if source is added.", error) } // use the plus icon by default var buttonIcon = UIImage(systemName: "plus.circle.fill", withConfiguration: config)?.withTintColor(.white, renderingMode: .alwaysOriginal) // if the source is already added/staged for adding, use the checkmark icon let isStagedForAdd = self.stagedForAdd[source] == true if isStagedForAdd || isSourceAlreadyPersisted { buttonIcon = UIImage(systemName: "checkmark.circle.fill", withConfiguration: config)? .withTintColor(isSourceAlreadyPersisted ? .green : .white, renderingMode: .alwaysOriginal) } cell.bannerView.button.setImage(buttonIcon, for: .normal) cell.bannerView.button.isEnabled = !isSourceAlreadyPersisted } } // set the icon setButtonIcon() let action = UIAction(identifier: .addSource) { [weak self] _ in guard let self else { return } self.stagedForAdd[source, default: false].toggle() // update the button icon setButtonIcon() } cell.bannerView.button.addAction(action, for: .primaryActionTriggered) } func configure(_ footerView: PlaceholderCollectionReusableView, with sourcePreviewResults: [SourcePreviewResult?]) { footerView.placeholderView.stackView.isLayoutMarginsRelativeArrangement = false footerView.placeholderView.textLabel.textColor = .secondaryLabel footerView.placeholderView.textLabel.font = .preferredFont(forTextStyle: .subheadline) footerView.placeholderView.textLabel.textAlignment = .center footerView.placeholderView.detailTextLabel.isHidden = true var errorText: String? = nil var isError: Bool = false for result in sourcePreviewResults { switch result { case (let sourceURL, .failure(let previewError))? where (self.viewModel.sourceURLs.contains(sourceURL) && !self.viewModel.isLoadingPreview): // The current URL matches the error being displayed, and we're not loading another preview, so show error. errorText = (previewError as NSError).localizedDebugDescription ?? previewError.localizedDescription footerView.placeholderView.textLabel.text = errorText footerView.placeholderView.textLabel.isHidden = false isError = true default: // The current URL does not match the URL of the source/error being displayed, so show loading indicator. errorText = nil footerView.placeholderView.textLabel.isHidden = true } } footerView.placeholderView.textLabel.text = errorText if !isError{ footerView.placeholderView.activityIndicatorView.startAnimating() } else{ footerView.placeholderView.activityIndicatorView.stopAnimating() } } func fetchRecommendedSources() { // Closure instead of local function so we can capture `self` weakly. let finish: (Result<[Source], Error>) -> Void = { [weak self] result in self?.fetchRecommendedSourcesResult = result.map { _ in () } DispatchQueue.main.async { do { let sources = try result.get() print("Fetched recommended sources:", sources.map { $0.identifier }) let sectionUpdate = RSTCellContentChange(type: .update, sectionIndex: 0) self?.recommendedSourcesDataSource.setItems(sources, with: [sectionUpdate]) } catch { print("Error fetching recommended sources:", error) let sectionUpdate = RSTCellContentChange(type: .update, sectionIndex: 0) self?.recommendedSourcesDataSource.setItems([], with: [sectionUpdate]) } } } self.fetchRecommendedSourcesOperation = AppManager.shared.updateKnownSources { [weak self] result in switch result { case .failure(let error): finish(.failure(error)) case .success((let trustedSources, _)): // Don't show sources without a sourceURL. let featuredSourceURLs = trustedSources.compactMap { $0.sourceURL } // This context is never saved, but keeps the managed sources alive. let context = DatabaseManager.shared.persistentContainer.newBackgroundSavingViewContext() self?._fetchRecommendedSourcesContext = context let dispatchGroup = DispatchGroup() var sourcesByURL = [URL: Source]() var fetchError: Error? for sourceURL in featuredSourceURLs { dispatchGroup.enter() AppManager.shared.fetchSource(sourceURL: sourceURL, managedObjectContext: context) { result in // Serialize access to sourcesByURL. context.performAndWait { switch result { case .failure(let error): print("Failed to load recommended source \(sourceURL.absoluteString):", error.localizedDescription, error) fetchError = error case .success(let source): sourcesByURL[source.sourceURL] = source } dispatchGroup.leave() } } } dispatchGroup.notify(queue: .main) { let sources = featuredSourceURLs.compactMap { sourcesByURL[$0] } if let error = fetchError, sources.isEmpty { finish(.failure(error)) } else { finish(.success(sources)) } } } } } @IBAction func commitChanges(_ sender: UIBarButtonItem) { struct StagedSource: Hashable { @AsyncManaged var source: Source // Conformance for Equatable/Hashable by comparing the underlying source static func == (lhs: StagedSource, rhs: StagedSource) -> Bool { return lhs.source.identifier == rhs.source.identifier } func hash(into hasher: inout Hasher) { hasher.combine(source) } } Task { var isCancelled = false // OK: COMMIT the staged changes now // Convert the stagedForAdd dictionary into an array of StagedSource let stagedSources: [StagedSource] = self.stagedForAdd.filter { $0.value } .map { StagedSource(source: $0.key) } for staged in stagedSources { do { // Use the projected value to safely access isRecommended asynchronously let isRecommended = await staged.$source.isRecommended if isRecommended { try await AppManager.shared.add(staged.source, message: nil, presentingViewController: self) } else { // Use default message try await AppManager.shared.add(staged.source, presentingViewController: self) } // remove this kv pair self.stagedForAdd.removeValue(forKey: staged.source) } catch is CancellationError { isCancelled = true break } catch { let errorTitle = NSLocalizedString("Unable to Add Source", comment: "") await self.presentAlert(title: errorTitle, message: error.localizedDescription) } } if !isCancelled { // finally dismiss the sheet/viewcontroller self.dismiss() } } } func dismiss() { guard let navigationController = self.navigationController, let presentingViewController = navigationController.presentingViewController else { return } presentingViewController.dismiss(animated: true) } } private extension AddSourceViewController { @IBSegueAction func makeSourceDetailViewController(_ coder: NSCoder, sender: Any?) -> UIViewController? { guard let source = sender as? Source else { return nil } let sourceDetailViewController = SourceDetailViewController(source: source, coder: coder) sourceDetailViewController?.addedSourceHandler = { [weak self] _ in self?.dismiss() } return sourceDetailViewController } } extension AddSourceViewController { override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard Section(rawValue: indexPath.section) != .add else { return } let source = self.dataSource.item(at: indexPath) self.performSegue(withIdentifier: "showSourceDetails", sender: source) } } extension AddSourceViewController: UICollectionViewDelegateFlowLayout { override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { let section = Section(rawValue: indexPath.section)! switch (section, kind) { case (.add, UICollectionView.elementKindSectionHeader): let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: kind, for: indexPath) as! UICollectionViewListCell var configuation = UIListContentConfiguration.cell() configuation.text = NSLocalizedString("Enter a source's URL below, or add one of the recommended sources.", comment: "") configuation.textProperties.color = .secondaryLabel headerView.contentConfiguration = configuation return headerView case (.preview, UICollectionView.elementKindSectionFooter): let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: ReuseID.placeholderFooter.rawValue, for: indexPath) as! PlaceholderCollectionReusableView self.configure(footerView, with: self.viewModel.sourcePreviewResults) return footerView case (.recommended, UICollectionView.elementKindSectionHeader): let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: kind, for: indexPath) as! UICollectionViewListCell var configuation = UIListContentConfiguration.groupedHeader() configuation.text = NSLocalizedString("Recommended Sources", comment: "") configuation.textProperties.color = .secondaryLabel headerView.contentConfiguration = configuation return headerView case (.recommended, UICollectionView.elementKindSectionFooter): let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: ReuseID.placeholderFooter.rawValue, for: indexPath) as! PlaceholderCollectionReusableView footerView.placeholderView.stackView.spacing = 15 footerView.placeholderView.stackView.directionalLayoutMargins.top = 20 footerView.placeholderView.stackView.isLayoutMarginsRelativeArrangement = true if let result = self.fetchRecommendedSourcesResult, case .failure(let error) = result { footerView.placeholderView.textLabel.isHidden = false footerView.placeholderView.textLabel.font = UIFont.preferredFont(forTextStyle: .headline) footerView.placeholderView.textLabel.text = NSLocalizedString("Unable to Load Recommended Sources", comment: "") footerView.placeholderView.detailTextLabel.isHidden = false footerView.placeholderView.detailTextLabel.text = error.localizedDescription footerView.placeholderView.activityIndicatorView.stopAnimating() } else { footerView.placeholderView.textLabel.isHidden = true footerView.placeholderView.detailTextLabel.isHidden = true footerView.placeholderView.activityIndicatorView.startAnimating() } return footerView default: fatalError() } } } extension AddSourceViewController: UITextFieldDelegate { func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { self.viewModel.isShowingPreviewStatus = false return true } func textFieldShouldReturn(_ textField: UITextField) -> Bool { textField.resignFirstResponder() return false } func textFieldDidEndEditing(_ textField: UITextField) { self.viewModel.isShowingPreviewStatus = true } } @available(iOS 17.0, *) #Preview(traits: .portrait) { DatabaseManager.shared.startForPreview() let storyboard = UIStoryboard(name: "Sources", bundle: .main) let addSourceNavigationController = storyboard.instantiateViewController(withIdentifier: "addSourceNavigationController") return addSourceNavigationController }