From 106c887196a8df1a2a59488c169729640a3f05d1 Mon Sep 17 00:00:00 2001 From: David Christiandy <1299411+dvdchr@users.noreply.github.com> Date: Thu, 2 May 2024 19:41:43 +0700 Subject: [PATCH 1/2] Add CardCellItem to prepare for empty cell state --- .../Reader/ReaderTagCardCellViewModel.swift | 85 ++++++++++++++----- 1 file changed, 66 insertions(+), 19 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderTagCardCellViewModel.swift b/WordPress/Classes/ViewRelated/Reader/ReaderTagCardCellViewModel.swift index 1c6db21d25ff..a75b8d8fdef2 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderTagCardCellViewModel.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderTagCardCellViewModel.swift @@ -6,8 +6,13 @@ protocol ReaderTagCardCellViewModelDelegate: NSObjectProtocol { class ReaderTagCardCellViewModel: NSObject { - private typealias DataSource = UICollectionViewDiffableDataSource - private typealias Snapshot = NSDiffableDataSourceSnapshot + enum CardCellItem: Hashable { + case empty + case post(id: NSManagedObjectID) + } + + private typealias DataSource = UICollectionViewDiffableDataSource + private typealias Snapshot = NSDiffableDataSourceSnapshot let slug: String weak var viewDelegate: ReaderTagCardCellViewModelDelegate? = nil @@ -22,29 +27,23 @@ class ReaderTagCardCellViewModel: NSObject { .init(coreDataStack: coreDataStack) }() - private lazy var dataSource: DataSource? = { - guard let collectionView else { + private lazy var dataSource: DataSource? = { [weak self] in + guard let self, + let collectionView else { return nil } - let dataSource = DataSource(collectionView: collectionView) { [weak self] collectionView, indexPath, objectID in - guard let post = try? ContextManager.shared.mainContext.existingObject(with: objectID) as? ReaderPost, - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ReaderTagCell.classNameWithoutNamespaces(), for: indexPath) as? ReaderTagCell else { - return UICollectionViewCell() - } - cell.configure(parent: self?.parentViewController, - post: post, - isLoggedIn: self?.isLoggedIn ?? AccountHelper.isLoggedIn) - return cell - } - dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in - guard let slug = self?.slug, + + let dataSource = DataSource(collectionView: collectionView, cellProvider: self.cardCellProvider) + dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in + guard let item = dataSource.itemIdentifier(for: indexPath), + item != .empty, kind == UICollectionView.elementKindSectionFooter, let view = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: ReaderTagFooterView.classNameWithoutNamespaces(), for: indexPath) as? ReaderTagFooterView else { return nil } - view.configure(with: slug) { [weak self] in + view.configure(with: self.slug) { [weak self] in self?.onTagButtonTapped() } return view @@ -122,13 +121,60 @@ class ReaderTagCardCellViewModel: NSObject { } +// MARK: - Private Methods + +private extension ReaderTagCardCellViewModel { + /// Configures and returns a collection view cell according to the index path and `CardCellItem`. + /// This method that satisfies the `UICollectionViewDiffableDataSource.CellProvider` closure signature. + func cardCellProvider(_ collectionView: UICollectionView, + _ indexPath: IndexPath, + _ item: CardCellItem) -> UICollectionViewCell? { + switch item { + case .empty: + return nil // TODO + + case .post(let objectID): + guard let post = try? ContextManager.shared.mainContext.existingObject(with: objectID) as? ReaderPost, + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ReaderTagCell.classNameWithoutNamespaces(), + for: indexPath) as? ReaderTagCell else { + return UICollectionViewCell() // TODO: will crash at this point. + } + + cell.configure(parent: parentViewController, post: post, isLoggedIn: isLoggedIn) + return cell + } + } + + /// Translates a diffable snapshot from `NSFetchedResultsController` to a snapshot that fits the collection view. + /// + /// Snapshots returned from `NSFetchedResultsController` always have the type ``, so + /// it needs to be converted to match the correct type required by the collection view.. + /// + /// - Parameter snapshotRef: The snapshot returned from the `NSFetchedResultsController` + /// - Returns: `Snapshot` + private func collectionViewSnapshot(from snapshotRef: NSDiffableDataSourceSnapshotReference) -> Snapshot { + let coreDataSnapshot = snapshotRef as NSDiffableDataSourceSnapshot + var snapshot = Snapshot() + + // there needs to be at least one section. + snapshot.appendSections([.zero]) + + if coreDataSnapshot.numberOfItems == .zero { + snapshot.appendItems([.empty]) + } else { + snapshot.appendItems(coreDataSnapshot.itemIdentifiers.map { .post(id: $0) }) + } + + return snapshot + } +} + // MARK: - NSFetchedResultsControllerDelegate extension ReaderTagCardCellViewModel: NSFetchedResultsControllerDelegate { - func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { - dataSource?.apply(snapshot as Snapshot, animatingDifferences: false) { [weak self] in + dataSource?.apply(collectionViewSnapshot(from: snapshot), animatingDifferences: false) { [weak self] in self?.viewDelegate?.hideLoading() } } @@ -156,6 +202,7 @@ extension ReaderTagCardCellViewModel: UICollectionViewDelegate { extension ReaderTagCardCellViewModel: UICollectionViewDelegateFlowLayout { func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + // TODO: Cell size for the empty state. return cellSize() ?? .zero } From e91bbb77379675804dd1d184ad29bac469e95885 Mon Sep 17 00:00:00 2001 From: David Christiandy <1299411+dvdchr@users.noreply.github.com> Date: Fri, 3 May 2024 02:51:22 +0700 Subject: [PATCH 2/2] Show an empty state cell when failing to load tag posts Also included: * Modified the leading constraint of the collection view in ReaderTagCardCell.xib. * Added AdaptiveCollectionViewFlowLayout to refresh the collection view on orientation change. * Added ReaderTagCardEmptyCell, a collection view cell for the empty state. It hosts a SwiftUI view. --- .../AdaptiveCollectionViewFlowLayout.swift | 18 +++ .../Reader/ReaderTagCardCell.swift | 1 + .../ViewRelated/Reader/ReaderTagCardCell.xib | 6 +- .../Reader/ReaderTagCardCellViewModel.swift | 64 +++++++--- .../Reader/ReaderTagCardEmptyCell.swift | 120 ++++++++++++++++++ WordPress/WordPress.xcodeproj/project.pbxproj | 20 +++ 6 files changed, 207 insertions(+), 22 deletions(-) create mode 100644 WordPress/Classes/Utility/CollectionView/AdaptiveCollectionViewFlowLayout.swift create mode 100644 WordPress/Classes/ViewRelated/Reader/ReaderTagCardEmptyCell.swift diff --git a/WordPress/Classes/Utility/CollectionView/AdaptiveCollectionViewFlowLayout.swift b/WordPress/Classes/Utility/CollectionView/AdaptiveCollectionViewFlowLayout.swift new file mode 100644 index 000000000000..58941b0345f9 --- /dev/null +++ b/WordPress/Classes/Utility/CollectionView/AdaptiveCollectionViewFlowLayout.swift @@ -0,0 +1,18 @@ +/// A flow layout that properly invalidates the layout when the collection view's bounds changed, +/// (e.g., orientation changes). +/// +/// This method ensures that we work with the latest/correct bounds after the size change, and potentially +/// avoids race conditions where we might get incorrect bounds while the view is still in transition. +/// +/// See: https://developer.apple.com/documentation/uikit/uicollectionviewlayout/1617781-shouldinvalidatelayout +class AdaptiveCollectionViewFlowLayout: UICollectionViewFlowLayout { + + override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { + // NOTE: Apparently we need to *manually* invalidate the layout because `invalidateLayout()` + // is NOT called after this method returns true. + if let collectionView, collectionView.bounds.size != newBounds.size { + invalidateLayout() + } + return super.shouldInvalidateLayout(forBoundsChange: newBounds) + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderTagCardCell.swift b/WordPress/Classes/ViewRelated/Reader/ReaderTagCardCell.swift index de7884ac049c..24d6f7639dac 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderTagCardCell.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderTagCardCell.swift @@ -123,6 +123,7 @@ private extension ReaderTagCardCell { func registerCells() { let tagCell = UINib(nibName: ReaderTagCell.classNameWithoutNamespaces(), bundle: nil) let footerView = UINib(nibName: ReaderTagFooterView.classNameWithoutNamespaces(), bundle: nil) + collectionView.register(ReaderTagCardEmptyCell.self, forCellWithReuseIdentifier: ReaderTagCardEmptyCell.defaultReuseID) collectionView.register(tagCell, forCellWithReuseIdentifier: ReaderTagCell.classNameWithoutNamespaces()) collectionView.register(footerView, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderTagCardCell.xib b/WordPress/Classes/ViewRelated/Reader/ReaderTagCardCell.xib index ed4a207514d1..210c8fdec766 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderTagCardCell.xib +++ b/WordPress/Classes/ViewRelated/Reader/ReaderTagCardCell.xib @@ -25,12 +25,12 @@ - + - + @@ -43,7 +43,7 @@ - + diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderTagCardCellViewModel.swift b/WordPress/Classes/ViewRelated/Reader/ReaderTagCardCellViewModel.swift index a75b8d8fdef2..cf3b182844f3 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderTagCardCellViewModel.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderTagCardCellViewModel.swift @@ -6,13 +6,18 @@ protocol ReaderTagCardCellViewModelDelegate: NSObjectProtocol { class ReaderTagCardCellViewModel: NSObject { + enum Section: Int { + case emptyState = 101 + case posts + } + enum CardCellItem: Hashable { case empty case post(id: NSManagedObjectID) } - private typealias DataSource = UICollectionViewDiffableDataSource - private typealias Snapshot = NSDiffableDataSourceSnapshot + private typealias DataSource = UICollectionViewDiffableDataSource + private typealias Snapshot = NSDiffableDataSourceSnapshot let slug: String weak var viewDelegate: ReaderTagCardCellViewModelDelegate? = nil @@ -35,9 +40,7 @@ class ReaderTagCardCellViewModel: NSObject { let dataSource = DataSource(collectionView: collectionView, cellProvider: self.cardCellProvider) dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in - guard let item = dataSource.itemIdentifier(for: indexPath), - item != .empty, - kind == UICollectionView.elementKindSectionFooter, + guard kind == UICollectionView.elementKindSectionFooter, let view = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: ReaderTagFooterView.classNameWithoutNamespaces(), for: indexPath) as? ReaderTagFooterView else { @@ -64,6 +67,8 @@ class ReaderTagCardCellViewModel: NSObject { return resultsController }() + // MARK: Methods + init(parent: UIViewController?, tag: ReaderTagTopic, collectionView: UICollectionView?, @@ -131,13 +136,22 @@ private extension ReaderTagCardCellViewModel { _ item: CardCellItem) -> UICollectionViewCell? { switch item { case .empty: - return nil // TODO + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ReaderTagCardEmptyCell.defaultReuseID, + for: indexPath) as? ReaderTagCardEmptyCell else { + return UICollectionViewCell() + } + + cell.configure(tagTitle: slug) { [weak self] in + self?.fetchTagPosts(syncRemotely: true) + } + + return cell case .post(let objectID): guard let post = try? ContextManager.shared.mainContext.existingObject(with: objectID) as? ReaderPost, let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ReaderTagCell.classNameWithoutNamespaces(), for: indexPath) as? ReaderTagCell else { - return UICollectionViewCell() // TODO: will crash at this point. + return UICollectionViewCell() } cell.configure(parent: parentViewController, post: post, isLoggedIn: isLoggedIn) @@ -148,22 +162,18 @@ private extension ReaderTagCardCellViewModel { /// Translates a diffable snapshot from `NSFetchedResultsController` to a snapshot that fits the collection view. /// /// Snapshots returned from `NSFetchedResultsController` always have the type ``, so - /// it needs to be converted to match the correct type required by the collection view.. + /// it needs to be converted to match the correct type required by the collection view. /// /// - Parameter snapshotRef: The snapshot returned from the `NSFetchedResultsController` /// - Returns: `Snapshot` private func collectionViewSnapshot(from snapshotRef: NSDiffableDataSourceSnapshotReference) -> Snapshot { let coreDataSnapshot = snapshotRef as NSDiffableDataSourceSnapshot + let isEmpty = coreDataSnapshot.numberOfItems == .zero var snapshot = Snapshot() - // there needs to be at least one section. - snapshot.appendSections([.zero]) - - if coreDataSnapshot.numberOfItems == .zero { - snapshot.appendItems([.empty]) - } else { - snapshot.appendItems(coreDataSnapshot.itemIdentifiers.map { .post(id: $0) }) - } + // there must be at least one section. + snapshot.appendSections([isEmpty ? .emptyState : .posts]) + snapshot.appendItems(isEmpty ? [.empty] : coreDataSnapshot.itemIdentifiers.map { .post(id: $0) }) return snapshot } @@ -186,7 +196,9 @@ extension ReaderTagCardCellViewModel: NSFetchedResultsControllerDelegate { extension ReaderTagCardCellViewModel: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - guard let sectionInfo = resultsController.sections?[safe: indexPath.section], + guard let section = dataSource?.sectionIdentifier(for: indexPath.section), + section == .posts, + let sectionInfo = resultsController.sections?[safe: indexPath.section], indexPath.row < sectionInfo.numberOfObjects else { return } @@ -202,11 +214,25 @@ extension ReaderTagCardCellViewModel: UICollectionViewDelegate { extension ReaderTagCardCellViewModel: UICollectionViewDelegateFlowLayout { func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { - // TODO: Cell size for the empty state. - return cellSize() ?? .zero + guard let section = dataSource?.sectionIdentifier(for: indexPath.section), + let size = cellSize() else { + return .zero + } + + switch section { + case .emptyState: + return CGSize(width: collectionView.frame.width, height: size.height) + case .posts: + return size + } } func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize { + guard let sectionIdentifier = dataSource?.sectionIdentifier(for: section), + sectionIdentifier == .posts else { + return .zero + } + var viewSize = cellSize() ?? .zero viewSize.width = Constants.footerWidth return viewSize diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderTagCardEmptyCell.swift b/WordPress/Classes/ViewRelated/Reader/ReaderTagCardEmptyCell.swift new file mode 100644 index 000000000000..3a9516e8f6c2 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/ReaderTagCardEmptyCell.swift @@ -0,0 +1,120 @@ +import SwiftUI +import DesignSystem + +class ReaderTagCardEmptyCell: UICollectionViewCell, Reusable { + + var tagTitle: String { + get { + swiftUIView.tagTitle + } + set { + swiftUIView.tagTitle = newValue + } + } + + var retryHandler: (() -> Void)? = nil + + private lazy var swiftUIView: ReaderTagCardEmptyCellView = { + ReaderTagCardEmptyCellView(buttonTapped: { [weak self] in + self?.retryHandler?() + }) + }() + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override init(frame: CGRect) { + super.init(frame: .zero) + + let viewToEmbed = UIView.embedSwiftUIView(swiftUIView) + contentView.addSubview(viewToEmbed) + contentView.pinSubviewToAllEdges(viewToEmbed) + } + + override func prepareForReuse() { + tagTitle = String() + retryHandler = nil + super.prepareForReuse() + } + + func configure(tagTitle: String, retryHandler: (() -> Void)?) { + self.tagTitle = tagTitle + self.retryHandler = retryHandler + } +} + +// MARK: - SwiftUI + +private struct ReaderTagCardEmptyCellView: View { + + var tagTitle = String() + var buttonTapped: (() -> Void)? = nil + + @ScaledMetric(relativeTo: Font.TextStyle.callout) + private var iconLength = 32.0 + + var body: some View { + VStack(spacing: .DS.Padding.double) { + Image(systemName: "wifi.slash") + .resizable() + .frame(width: iconLength, height: iconLength) + .foregroundStyle(Color.DS.Foreground.secondary) + + // added to double the padding between the Image and the VStack. + Spacer().frame(height: .hairlineBorderWidth) + + VStack(spacing: .DS.Padding.single) { + Text(Strings.title) + .font(.callout) + .fontWeight(.semibold) + + Text(Strings.body) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + + Button { + buttonTapped?() + } label: { + Text(Strings.buttonTitle) + .font(.callout) + .padding(.vertical, .DS.Padding.half) + .padding(.horizontal, .DS.Padding.single) + } + } + .padding(.DS.Padding.single) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + + private struct Strings { + static let title = NSLocalizedString( + "reader.tagStream.cards.emptyView.error.title", + value: "Posts failed to load", + comment: """ + The title of an empty state component for one of the tags in the tag stream. + This empty state component is displayed only when the app fails to load posts under this tag. + """ + ) + + static let body = NSLocalizedString( + "reader.tagStream.cards.emptyView.error.body", + value: "We couldn't load posts from this tag right now", + comment: """ + The body text of an empty state component for one of the tags in the tag stream. + This empty state component is displayed only when the app fails to load posts under this tag. + """ + ) + + static let buttonTitle = NSLocalizedString( + "reader.tagStream.cards.emptyView.button", + value: "Retry", + comment: """ + Verb. The button title of an empty state component for one of the tags in the tag stream. + This empty state component is displayed only when the app fails to load posts under this tag. + When tapped, the app will try to reload posts under this tag. + """ + ) + } +} diff --git a/WordPress/WordPress.xcodeproj/project.pbxproj b/WordPress/WordPress.xcodeproj/project.pbxproj index 16ec4c868263..fbcf1055732d 100644 --- a/WordPress/WordPress.xcodeproj/project.pbxproj +++ b/WordPress/WordPress.xcodeproj/project.pbxproj @@ -5651,6 +5651,8 @@ FE23EB4A26E7C91F005A1698 /* richCommentTemplate.html in Resources */ = {isa = PBXBuildFile; fileRef = FE23EB4726E7C91F005A1698 /* richCommentTemplate.html */; }; FE23EB4B26E7C91F005A1698 /* richCommentStyle.css in Resources */ = {isa = PBXBuildFile; fileRef = FE23EB4826E7C91F005A1698 /* richCommentStyle.css */; }; FE23EB4C26E7C91F005A1698 /* richCommentStyle.css in Resources */ = {isa = PBXBuildFile; fileRef = FE23EB4826E7C91F005A1698 /* richCommentStyle.css */; }; + FE2590992BE3F4E2005690E9 /* ReaderTagCardEmptyCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE2590982BE3F4E2005690E9 /* ReaderTagCardEmptyCell.swift */; }; + FE25909A2BE3F4E2005690E9 /* ReaderTagCardEmptyCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE2590982BE3F4E2005690E9 /* ReaderTagCardEmptyCell.swift */; }; FE25C235271F23000084E1DB /* ReaderCommentsNotificationSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE25C234271F23000084E1DB /* ReaderCommentsNotificationSheetViewController.swift */; }; FE25C236271F23000084E1DB /* ReaderCommentsNotificationSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE25C234271F23000084E1DB /* ReaderCommentsNotificationSheetViewController.swift */; }; FE29EFCD29A91160007CE034 /* WPAdminConvertibleRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE29EFCC29A91160007CE034 /* WPAdminConvertibleRouter.swift */; }; @@ -5732,6 +5734,8 @@ FEAA6F79298CE4A600ADB44C /* PluginJetpackProxyServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAA6F78298CE4A600ADB44C /* PluginJetpackProxyServiceTests.swift */; }; FEAC916E28001FC4005026E7 /* AvatarTrainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAC916D28001FC4005026E7 /* AvatarTrainView.swift */; }; FEAC916F28001FC4005026E7 /* AvatarTrainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAC916D28001FC4005026E7 /* AvatarTrainView.swift */; }; + FEC1B0CE2BE41A7400CB4A3D /* AdaptiveCollectionViewFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC1B0CD2BE41A7400CB4A3D /* AdaptiveCollectionViewFlowLayout.swift */; }; + FEC1B0CF2BE41E1C00CB4A3D /* AdaptiveCollectionViewFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC1B0CD2BE41A7400CB4A3D /* AdaptiveCollectionViewFlowLayout.swift */; }; FEC26030283FBA1A003D886A /* BloggingPromptCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC2602F283FBA1A003D886A /* BloggingPromptCoordinator.swift */; }; FEC26031283FBA1A003D886A /* BloggingPromptCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC2602F283FBA1A003D886A /* BloggingPromptCoordinator.swift */; }; FEC26033283FC902003D886A /* RootViewCoordinator+BloggingPrompt.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC26032283FC902003D886A /* RootViewCoordinator+BloggingPrompt.swift */; }; @@ -9582,6 +9586,7 @@ FE1E201D2A49D59400CE7C90 /* JetpackSocialServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackSocialServiceTests.swift; sourceTree = ""; }; FE23EB4726E7C91F005A1698 /* richCommentTemplate.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; name = richCommentTemplate.html; path = Resources/HTML/richCommentTemplate.html; sourceTree = ""; }; FE23EB4826E7C91F005A1698 /* richCommentStyle.css */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.css; name = richCommentStyle.css; path = Resources/HTML/richCommentStyle.css; sourceTree = ""; }; + FE2590982BE3F4E2005690E9 /* ReaderTagCardEmptyCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderTagCardEmptyCell.swift; sourceTree = ""; }; FE25C234271F23000084E1DB /* ReaderCommentsNotificationSheetViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderCommentsNotificationSheetViewController.swift; sourceTree = ""; }; FE29EFCC29A91160007CE034 /* WPAdminConvertibleRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WPAdminConvertibleRouter.swift; sourceTree = ""; }; FE2E3728281C839C00A1E82A /* BloggingPromptsServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BloggingPromptsServiceTests.swift; sourceTree = ""; }; @@ -9635,6 +9640,7 @@ FEAA6F78298CE4A600ADB44C /* PluginJetpackProxyServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginJetpackProxyServiceTests.swift; sourceTree = ""; }; FEAC916D28001FC4005026E7 /* AvatarTrainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarTrainView.swift; sourceTree = ""; }; FEB7A8922718852A00A8CF85 /* WordPress 134.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 134.xcdatamodel"; sourceTree = ""; }; + FEC1B0CD2BE41A7400CB4A3D /* AdaptiveCollectionViewFlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveCollectionViewFlowLayout.swift; sourceTree = ""; }; FEC2602F283FBA1A003D886A /* BloggingPromptCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BloggingPromptCoordinator.swift; sourceTree = ""; }; FEC26032283FC902003D886A /* RootViewCoordinator+BloggingPrompt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RootViewCoordinator+BloggingPrompt.swift"; sourceTree = ""; }; FECA442E28350B7800D01F15 /* PromptRemindersScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptRemindersScheduler.swift; sourceTree = ""; }; @@ -12800,6 +12806,7 @@ 83BF48BD2BD6FA3000C0E1A1 /* ReaderTagCellViewModel.swift */, 83A8A2922BDC557E001F9133 /* ReaderTagFooterView.swift */, 83A8A2932BDC557E001F9133 /* ReaderTagFooterView.xib */, + FE2590982BE3F4E2005690E9 /* ReaderTagCardEmptyCell.swift */, ); name = Cards; sourceTree = ""; @@ -14033,6 +14040,7 @@ 8584FDB4192437160019C02E /* Utility */ = { isa = PBXGroup; children = ( + FEC1B0CC2BE41A5F00CB4A3D /* CollectionView */, F4FF50E42B4D7D590076DB0C /* In-App Feedback */, FE6BB14129322798001E5F7A /* Migration */, 8B85AED8259230C500ADBEC9 /* AB Testing */, @@ -18883,6 +18891,14 @@ name = Detail; sourceTree = ""; }; + FEC1B0CC2BE41A5F00CB4A3D /* CollectionView */ = { + isa = PBXGroup; + children = ( + FEC1B0CD2BE41A7400CB4A3D /* AdaptiveCollectionViewFlowLayout.swift */, + ); + path = CollectionView; + sourceTree = ""; + }; FEC2602D283FB9D4003D886A /* Blogging Prompts */ = { isa = PBXGroup; children = ( @@ -22602,6 +22618,7 @@ E62CE58E26B1D14200C9D147 /* AccountService+Cookies.swift in Sources */, 3F4A4C232AD3FA2E00DE5DF8 /* MySiteViewModel.swift in Sources */, FACF66D02ADD6CD8008C3E13 /* PostListItemViewModel.swift in Sources */, + FE2590992BE3F4E2005690E9 /* ReaderTagCardEmptyCell.swift in Sources */, B0F2EFBF259378E600C7EB6D /* SiteSuggestionService.swift in Sources */, 4388FF0020A4E19C00783948 /* NotificationsViewController+PushPrimer.swift in Sources */, 800035BD291DD0D7007D2D26 /* JetpackFullscreenOverlayGeneralViewModel+Analytics.swift in Sources */, @@ -22625,6 +22642,7 @@ E1BEEC631C4E35A8000B4FA0 /* Animator.swift in Sources */, 981C3494218388CA00FC2683 /* SiteStatsDashboardViewController.swift in Sources */, E1C5457E1C6B962D001CEB0E /* MediaSettings.swift in Sources */, + FEC1B0CE2BE41A7400CB4A3D /* AdaptiveCollectionViewFlowLayout.swift in Sources */, 02BF30532271D7F000616558 /* DomainCreditRedemptionSuccessViewController.swift in Sources */, 93C4864F181043D700A24725 /* ActivityLogDetailViewController.m in Sources */, B57B99DE19A2DBF200506504 /* NSObject+Helpers.m in Sources */, @@ -25170,6 +25188,7 @@ FABB22FB2602FC2C00C8785C /* ReaderFollowAction.swift in Sources */, FABB22FC2602FC2C00C8785C /* SheetActions.swift in Sources */, FABB22FD2602FC2C00C8785C /* ReaderTracker.swift in Sources */, + FEC1B0CF2BE41E1C00CB4A3D /* AdaptiveCollectionViewFlowLayout.swift in Sources */, FABB22FE2602FC2C00C8785C /* PlanFeature.swift in Sources */, 17C1D7DD26735631006C8970 /* EmojiRenderer.swift in Sources */, FABB22FF2602FC2C00C8785C /* Spotlightable.swift in Sources */, @@ -25723,6 +25742,7 @@ 0CED200D2B68425A00E6DD52 /* WebKitView.swift in Sources */, FABB247F2602FC2C00C8785C /* StockPhotosPageable.swift in Sources */, FABB24802602FC2C00C8785C /* JetpackRestoreStatusViewController.swift in Sources */, + FE25909A2BE3F4E2005690E9 /* ReaderTagCardEmptyCell.swift in Sources */, B038A81E2BD70FCA00763731 /* StatsSubscribersChartCell.swift in Sources */, FABB24812602FC2C00C8785C /* BindableTapGestureRecognizer.swift in Sources */, FABB24822602FC2C00C8785C /* ReaderSearchSuggestion.swift in Sources */,