diff --git a/EssentialApp/EssentialApp/CombineHelpers.swift b/EssentialApp/EssentialApp/CombineHelpers.swift index 518eefc..ff872f1 100644 --- a/EssentialApp/EssentialApp/CombineHelpers.swift +++ b/EssentialApp/EssentialApp/CombineHelpers.swift @@ -7,6 +7,33 @@ import Foundation import Combine import EssentialFeed +public extension Paginated { + init(items: [Item], loadMorePublisher: (() -> AnyPublisher)?) { + self.init(items: items, loadMore: loadMorePublisher.map { publisher in + return { completion in + publisher().subscribe(Subscribers.Sink(receiveCompletion: { result in + if case let .failure(error) = result { + completion(.failure(error)) + } + }, receiveValue: { result in + completion(.success(result)) + })) + } + }) + } + + var loadMorePublisher: (() -> AnyPublisher)? { + guard let loadMore = loadMore else { return nil } + + return { + Deferred { + Future(loadMore) + } + .eraseToAnyPublisher() + } + } +} + public extension HTTPClient { typealias Publisher = AnyPublisher<(Data, HTTPURLResponse), Error> @@ -70,8 +97,12 @@ extension Publisher { } } -extension Publisher where Output == [FeedImage] { - func caching(to cache: FeedCache) -> AnyPublisher { +extension Publisher { + func caching(to cache: FeedCache) -> AnyPublisher where Output == [FeedImage] { + handleEvents(receiveOutput: cache.saveIgnoringResult).eraseToAnyPublisher() + } + + func caching(to cache: FeedCache) -> AnyPublisher where Output == Paginated { handleEvents(receiveOutput: cache.saveIgnoringResult).eraseToAnyPublisher() } } @@ -80,6 +111,10 @@ private extension FeedCache { func saveIgnoringResult(_ feed: [FeedImage]) { save(feed) { _ in } } + + func saveIgnoringResult(_ page: Paginated) { + saveIgnoringResult(page.items) + } } extension Publisher { diff --git a/EssentialApp/EssentialApp/FeedUIComposer.swift b/EssentialApp/EssentialApp/FeedUIComposer.swift index 63b37eb..d67ce00 100644 --- a/EssentialApp/EssentialApp/FeedUIComposer.swift +++ b/EssentialApp/EssentialApp/FeedUIComposer.swift @@ -11,10 +11,10 @@ import EssentialFeediOS public final class FeedUIComposer { private init() {} - private typealias FeedPresentationAdapter = LoadResourcePresentationAdapter<[FeedImage], FeedViewAdapter> + private typealias FeedPresentationAdapter = LoadResourcePresentationAdapter, FeedViewAdapter> public static func feedComposedWith( - feedLoader: @escaping () -> AnyPublisher<[FeedImage], Error>, + feedLoader: @escaping () -> AnyPublisher, Error>, imageLoader: @escaping (URL) -> FeedImageDataLoader.Publisher, selection: @escaping (FeedImage) -> Void = { _ in } ) -> ListViewController { @@ -29,8 +29,7 @@ public final class FeedUIComposer { imageLoader: imageLoader, selection: selection), loadingView: WeakRefVirtualProxy(feedController), - errorView: WeakRefVirtualProxy(feedController), - mapper: FeedPresenter.map) + errorView: WeakRefVirtualProxy(feedController)) return feedController } diff --git a/EssentialApp/EssentialApp/FeedViewAdapter.swift b/EssentialApp/EssentialApp/FeedViewAdapter.swift index 7270b1d..8116a9a 100644 --- a/EssentialApp/EssentialApp/FeedViewAdapter.swift +++ b/EssentialApp/EssentialApp/FeedViewAdapter.swift @@ -13,6 +13,7 @@ final class FeedViewAdapter: ResourceView { private let selection: (FeedImage) -> Void private typealias ImageDataPresentationAdapter = LoadResourcePresentationAdapter> + private typealias LoadMorePresentationAdapter = LoadResourcePresentationAdapter, FeedViewAdapter> init(controller: ListViewController? = nil, imageLoader: @escaping (URL) -> FeedImageDataLoader.Publisher, selection: @escaping (FeedImage) -> Void) { self.controller = controller @@ -20,8 +21,8 @@ final class FeedViewAdapter: ResourceView { self.selection = selection } - func display(_ viewModel: FeedViewModel) { - controller?.display(viewModel.feed.map { model in + func display(_ viewModel: Paginated) { + let feed: [CellController] = viewModel.items.map { model in let adapter = ImageDataPresentationAdapter(loader: { [imageLoader] in imageLoader(model.url) }) @@ -40,7 +41,24 @@ final class FeedViewAdapter: ResourceView { mapper: UIImage.tryMake) return CellController(id: model, view) - }) + } + + guard let loadMorePublisher = viewModel.loadMorePublisher else { + controller?.display(feed) + return + } + + let loadMoreAdapter = LoadMorePresentationAdapter(loader: loadMorePublisher) + let loadMore = LoadMoreCellController(callback: loadMoreAdapter.loadResource) + + loadMoreAdapter.presenter = LoadResourcePresenter( + resourceView: self, + loadingView: WeakRefVirtualProxy(loadMore), + errorView: WeakRefVirtualProxy(loadMore)) + + let loadMoreSection = [CellController(id: UUID(), loadMore)] + + controller?.display(feed, loadMoreSection) } } diff --git a/EssentialApp/EssentialApp/SceneDelegate.swift b/EssentialApp/EssentialApp/SceneDelegate.swift index 1c3d667..72ef3eb 100644 --- a/EssentialApp/EssentialApp/SceneDelegate.swift +++ b/EssentialApp/EssentialApp/SceneDelegate.swift @@ -71,14 +71,40 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } } - private func makeRemoteFeedLoaderWithLocalFallback() -> AnyPublisher<[FeedImage], Error> { - let url = FeedEndpoint.get.url(baseURL: baseURL) + private func makeRemoteFeedLoaderWithLocalFallback() -> AnyPublisher, Error> { + makeRemoteFeedLoader() + .caching(to: localFeedLoader) + .fallback(to: localFeedLoader.loadPublisher) + .map(makeFirstPage) + .eraseToAnyPublisher() + } + + private func makeRemoteLoadMoreLoader(last: FeedImage?) -> AnyPublisher, Error> { + localFeedLoader.loadPublisher() + .zip(makeRemoteFeedLoader(after: last)) + .map { (cachedItems, newItems) in + (cachedItems + newItems, newItems.last) + }.map(makePage) + .caching(to: localFeedLoader) + } + + private func makeRemoteFeedLoader(after: FeedImage? = nil) -> AnyPublisher<[FeedImage], Error> { + let url = FeedEndpoint.get(after: after).url(baseURL: baseURL) return httpClient .getPublisher(url: url) .tryMap(FeedItemsMapper.map) - .caching(to: localFeedLoader) - .fallback(to: localFeedLoader.loadPublisher) + .eraseToAnyPublisher() + } + + private func makeFirstPage(items: [FeedImage]) -> Paginated { + makePage(items: items, last: items.last) + } + + private func makePage(items: [FeedImage], last: FeedImage?) -> Paginated { + Paginated(items: items, loadMorePublisher: last.map { last in + { self.makeRemoteLoadMoreLoader(last: last) } + }) } private func makeLocalImageLoaderWithRemoteFallback(url: URL) -> FeedImageDataLoader.Publisher { diff --git a/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift index 85cc6d0..1cad5c2 100644 --- a/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift +++ b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift @@ -14,21 +14,42 @@ class FeedAcceptanceTests: XCTestCase { let feed = launch(httpClient: .online(response), store: .empty) XCTAssertEqual(feed.numberOfRenderedFeedImageViews(), 2) - XCTAssertEqual(feed.renderedFeedImageData(at: 0), makeImageData()) - XCTAssertEqual(feed.renderedFeedImageData(at: 1), makeImageData()) + XCTAssertEqual(feed.renderedFeedImageData(at: 0), makeImageData0()) + XCTAssertEqual(feed.renderedFeedImageData(at: 1), makeImageData1()) + XCTAssertTrue(feed.canLoadMoreFeed) + + feed.simulateLoadMoreFeedAction() + + XCTAssertEqual(feed.numberOfRenderedFeedImageViews(), 3) + XCTAssertEqual(feed.renderedFeedImageData(at: 0), makeImageData0()) + XCTAssertEqual(feed.renderedFeedImageData(at: 1), makeImageData1()) + XCTAssertEqual(feed.renderedFeedImageData(at: 2), makeImageData2()) + XCTAssertTrue(feed.canLoadMoreFeed) + + feed.simulateLoadMoreFeedAction() + + XCTAssertEqual(feed.numberOfRenderedFeedImageViews(), 3) + XCTAssertEqual(feed.renderedFeedImageData(at: 0), makeImageData0()) + XCTAssertEqual(feed.renderedFeedImageData(at: 1), makeImageData1()) + XCTAssertEqual(feed.renderedFeedImageData(at: 2), makeImageData2()) + XCTAssertFalse(feed.canLoadMoreFeed) } func test_onLaunch_displaysCachedRemoteFeedWhenCustomerHasNoConnectivity() { let sharedStore = InMemoryFeedStore.empty + let onlineFeed = launch(httpClient: .online(response), store: sharedStore) onlineFeed.simulateFeedImageViewVisible(at: 0) onlineFeed.simulateFeedImageViewVisible(at: 1) + onlineFeed.simulateLoadMoreFeedAction() + onlineFeed.simulateFeedImageViewVisible(at: 2) let offlineFeed = launch(httpClient: .offline, store: sharedStore) - XCTAssertEqual(offlineFeed.numberOfRenderedFeedImageViews(), 2) - XCTAssertEqual(offlineFeed.renderedFeedImageData(at: 0), makeImageData()) - XCTAssertEqual(offlineFeed.renderedFeedImageData(at: 1), makeImageData()) + XCTAssertEqual(offlineFeed.numberOfRenderedFeedImageViews(), 3) + XCTAssertEqual(offlineFeed.renderedFeedImageData(at: 0), makeImageData0()) + XCTAssertEqual(offlineFeed.renderedFeedImageData(at: 1), makeImageData1()) + XCTAssertEqual(offlineFeed.renderedFeedImageData(at: 2), makeImageData2()) } func test_onLaunch_displaysEmptyFeedWhenCustomerHasNoConnectivityAndNoCache() { @@ -100,11 +121,18 @@ class FeedAcceptanceTests: XCTestCase { private func makeData(for url: URL) -> Data { switch url.path { - case "/image-1", "/image-2": - return makeImageData() + case "/image-0": return makeImageData0() + case "/image-1": return makeImageData1() + case "/image-2": return makeImageData2() + + case "/essential-feed/v1/feed" where url.query?.contains("after_id") == false: + return makeFirstFeedPageData() - case "/essential-feed/v1/feed": - return makeFeedData() + case "/essential-feed/v1/feed" where url.query?.contains("after_id=A28F5FE3-27A7-44E9-8DF5-53742D0E4A5A") == true: + return makeSecondFeedPageData() + + case "/essential-feed/v1/feed" where url.query?.contains("after_id=166FCDD7-C9F4-420A-B2D6-CE2EAFA3D82F") == true: + return makeLastEmptyFeedPageData() case "/essential-feed/v1/image/2AB2AE66-A4B7-4A16-B374-51BBAC8DB086/comments": return makeCommentsData() @@ -114,17 +142,27 @@ class FeedAcceptanceTests: XCTestCase { } } - private func makeImageData() -> Data { - return UIImage.make(withColor: .red).pngData()! + private func makeImageData0() -> Data { UIImage.make(withColor: .red).pngData()! } + private func makeImageData1() -> Data { UIImage.make(withColor: .green).pngData()! } + private func makeImageData2() -> Data { UIImage.make(withColor: .blue).pngData()! } + + private func makeFirstFeedPageData() -> Data { + return try! JSONSerialization.data(withJSONObject: ["items": [ + ["id": "2AB2AE66-A4B7-4A16-B374-51BBAC8DB086", "image": "http://feed.com/image-0"], + ["id": "A28F5FE3-27A7-44E9-8DF5-53742D0E4A5A", "image": "http://feed.com/image-1"] + ]]) } - private func makeFeedData() -> Data { + private func makeSecondFeedPageData() -> Data { return try! JSONSerialization.data(withJSONObject: ["items": [ - ["id": "2AB2AE66-A4B7-4A16-B374-51BBAC8DB086", "image": "http://feed.com/image-1"], - ["id": "A28F5FE3-27A7-44E9-8DF5-53742D0E4A5A", "image": "http://feed.com/image-2"] + ["id": "166FCDD7-C9F4-420A-B2D6-CE2EAFA3D82F", "image": "http://feed.com/image-2"], ]]) } + private func makeLastEmptyFeedPageData() -> Data { + return try! JSONSerialization.data(withJSONObject: ["items": []]) + } + private func makeCommentsData() -> Data { return try! JSONSerialization.data(withJSONObject: ["items": [ [ diff --git a/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift b/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift index ae76108..5420a6d 100644 --- a/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift +++ b/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift @@ -86,12 +86,16 @@ class FeedUIIntegrationTests: XCTestCase { sut.simulateAppearance() assertThat(sut, isRendering: []) - loader.completeFeedLoading(with: [image0], at: 0) - assertThat(sut, isRendering: [image0]) + loader.completeFeedLoading(with: [image0, image1], at: 0) + assertThat(sut, isRendering: [image0, image1]) - sut.simulateUserInitiatedReload() - loader.completeFeedLoading(with: [image0, image1, image2, image3], at: 1) + sut.simulateLoadMoreFeedAction() + loader.completeLoadMore(with: [image0, image1, image2, image3], at: 0) assertThat(sut, isRendering: [image0, image1, image2, image3]) + + sut.simulateUserInitiatedReload() + loader.completeFeedLoading(with: [image0, image1], at: 1) + assertThat(sut, isRendering: [image0, image1]) } func test_loadFeedCompletion_rendersSuccessfullyLoadedEmptyFeedAfterNonEmptyFeed() { @@ -100,7 +104,11 @@ class FeedUIIntegrationTests: XCTestCase { let (sut, loader) = makeSUT() sut.simulateAppearance() - loader.completeFeedLoading(with: [image0, image1], at: 0) + loader.completeFeedLoading(with: [image0], at: 0) + assertThat(sut, isRendering: [image0]) + + sut.simulateLoadMoreFeedAction() + loader.completeLoadMore(with: [image0, image1], at: 0) assertThat(sut, isRendering: [image0, image1]) sut.simulateUserInitiatedReload() @@ -119,6 +127,10 @@ class FeedUIIntegrationTests: XCTestCase { sut.simulateUserInitiatedReload() loader.completeFeedLoadingWithError(at: 1) assertThat(sut, isRendering: [image0]) + + sut.simulateLoadMoreFeedAction() + loader.completeLoadMoreWithError(at: 0) + assertThat(sut, isRendering: [image0]) } func test_loadFeedCompletion_dispatchesFromBackgroundToMainThread() { @@ -159,6 +171,101 @@ class FeedUIIntegrationTests: XCTestCase { XCTAssertEqual(sut.errorMessage, nil) } + // MARK: - Load More Tests + + func test_loadMoreActions_requestMoreFromLoader() { + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + loader.completeFeedLoading() + XCTAssertEqual(loader.loadMoreCallCount, 0, "Expected no requests before until load more action") + + sut.simulateLoadMoreFeedAction() + XCTAssertEqual(loader.loadMoreCallCount, 1, "Expected load more request") + + sut.simulateLoadMoreFeedAction() + XCTAssertEqual(loader.loadMoreCallCount, 1, "Expected no request while loading more") + + loader.completeLoadMore(lastPage: false, at: 0) + sut.simulateLoadMoreFeedAction() + XCTAssertEqual(loader.loadMoreCallCount, 2, "Expected request after load more completed with more pages") + + loader.completeLoadMoreWithError(at: 1) + sut.simulateLoadMoreFeedAction() + XCTAssertEqual(loader.loadMoreCallCount, 3, "Expected request after load more failure") + + loader.completeLoadMore(lastPage: true, at: 2) + sut.simulateLoadMoreFeedAction() + XCTAssertEqual(loader.loadMoreCallCount, 3, "Expected no request after loading all pages") + } + + func test_loadingMoreIndicator_isVisibleWhileLoadingMore() { + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + XCTAssertFalse(sut.isShowingLoadMoreFeedIndicator, "Expected no loading indicator once view is loaded") + + loader.completeFeedLoading(at: 0) + XCTAssertFalse(sut.isShowingLoadMoreFeedIndicator, "Expected no loading indicator once loading completes successfully") + + sut.simulateLoadMoreFeedAction() + XCTAssertTrue(sut.isShowingLoadMoreFeedIndicator, "Expected loading indicator on load more action") + + loader.completeLoadMore(at: 0) + XCTAssertFalse(sut.isShowingLoadMoreFeedIndicator, "Expected no loading indicator once user initiated loading completes successfully") + + sut.simulateLoadMoreFeedAction() + XCTAssertTrue(sut.isShowingLoadMoreFeedIndicator, "Expected loading indicator on second load more action") + + loader.completeLoadMoreWithError(at: 1) + XCTAssertFalse(sut.isShowingLoadMoreFeedIndicator, "Expected no loading indicator once user initiated loading completes with error") + } + + func test_loadMoreCompletion_dispatchesFromBackgroundToMainThread() { + let (sut, loader) = makeSUT() + sut.simulateAppearance() + loader.completeFeedLoading(at: 0) + sut.simulateLoadMoreFeedAction() + + let exp = expectation(description: "Wait for background queue") + DispatchQueue.global().async { + loader.completeLoadMore() + exp.fulfill() + } + wait(for: [exp], timeout: 1.0) + } + + func test_loadMoreCompletion_rendersErrorMessageOnError() { + let (sut, loader) = makeSUT() + sut.simulateAppearance() + loader.completeFeedLoading() + + sut.simulateLoadMoreFeedAction() + XCTAssertEqual(sut.loadMoreFeedErrorMessage, nil) + + loader.completeLoadMoreWithError() + XCTAssertEqual(sut.loadMoreFeedErrorMessage, loadError) + + sut.simulateLoadMoreFeedAction() + XCTAssertEqual(sut.loadMoreFeedErrorMessage, nil) + } + + func test_tapOnLoadMoreErrorView_loadsMore() { + let (sut, loader) = makeSUT() + sut.simulateAppearance() + loader.completeFeedLoading() + + sut.simulateLoadMoreFeedAction() + XCTAssertEqual(loader.loadMoreCallCount, 1) + + sut.simulateTapOnLoadMoreFeedError() + XCTAssertEqual(loader.loadMoreCallCount, 1) + + loader.completeLoadMoreWithError() + sut.simulateTapOnLoadMoreFeedError() + XCTAssertEqual(loader.loadMoreCallCount, 2) + } + // MARK: - Image View Tests func test_feedImageView_loadsImageURLWhenVisible() { diff --git a/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+LoaderSpy.swift b/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+LoaderSpy.swift index 05fc370..49e4cb5 100644 --- a/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+LoaderSpy.swift +++ b/EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+LoaderSpy.swift @@ -14,25 +14,52 @@ extension FeedUIIntegrationTests { // MARK: - FeedLoader - private var feedRequests = [PassthroughSubject<[FeedImage], Error>]() + private var feedRequests = [PassthroughSubject, Error>]() var loadFeedCallCount: Int { return feedRequests.count } - func loadPublisher() -> AnyPublisher<[FeedImage], Error> { - let publisher = PassthroughSubject<[FeedImage], Error>() + func loadPublisher() -> AnyPublisher, Error> { + let publisher = PassthroughSubject, Error>() feedRequests.append(publisher) return publisher.eraseToAnyPublisher() } func completeFeedLoading(with feed: [FeedImage] = [], at index: Int = 0) { - feedRequests[index].send(feed) + feedRequests[index].send(Paginated(items: feed, loadMorePublisher: { [weak self] in + self?.loadMorePublisher() ?? Empty().eraseToAnyPublisher() + })) } func completeFeedLoadingWithError(at index: Int = 0) { - let error = NSError(domain: "an error", code: 404) - feedRequests[index].send(completion: .failure(error)) + feedRequests[index].send(completion: .failure(anyNSError())) + } + + // MARK: - LoadMoreFeedLoader + + private var loadMoreRequests = [PassthroughSubject, Error>]() + + var loadMoreCallCount: Int { + return loadMoreRequests.count + } + + func loadMorePublisher() -> AnyPublisher, Error> { + let publisher = PassthroughSubject, Error>() + loadMoreRequests.append(publisher) + return publisher.eraseToAnyPublisher() + } + + func completeLoadMore(with feed: [FeedImage] = [], lastPage: Bool = false, at index: Int = 0) { + loadMoreRequests[index].send(Paginated( + items: feed, + loadMorePublisher: lastPage ? nil : { [weak self] in + self?.loadMorePublisher() ?? Empty().eraseToAnyPublisher() + })) + } + + func completeLoadMoreWithError(at index: Int = 0) { + loadMoreRequests[index].send(completion: .failure(anyNSError())) } // MARK: - FeedImageDataLoader diff --git a/EssentialApp/EssentialAppTests/Helpers/ListViewController+TestHelpers.swift b/EssentialApp/EssentialAppTests/Helpers/ListViewController+TestHelpers.swift index fa96c48..77652b6 100644 --- a/EssentialApp/EssentialAppTests/Helpers/ListViewController+TestHelpers.swift +++ b/EssentialApp/EssentialAppTests/Helpers/ListViewController+TestHelpers.swift @@ -140,6 +140,36 @@ extension ListViewController { ds?.tableView?(tableView, cancelPrefetchingForRowsAt: [index]) } + func simulateLoadMoreFeedAction() { + guard let view = loadMoreFeedCell() else { return } + + let delegate = tableView.delegate + let index = IndexPath(row: 0, section: feedLoadMoreSection) + delegate?.tableView?(tableView, willDisplay: view, forRowAt: index) + } + + func simulateTapOnLoadMoreFeedError() { + let delegate = tableView.delegate + let index = IndexPath(row: 0, section: feedLoadMoreSection) + delegate?.tableView?(tableView, didSelectRowAt: index) + } + + var isShowingLoadMoreFeedIndicator: Bool { + return loadMoreFeedCell()?.isLoading == true + } + + var loadMoreFeedErrorMessage: String? { + return loadMoreFeedCell()?.message + } + + var canLoadMoreFeed: Bool { + loadMoreFeedCell() != nil + } + + private func loadMoreFeedCell() -> LoadMoreCell? { + cell(row: 0, section: feedLoadMoreSection) as? LoadMoreCell + } + func renderedFeedImageData(at index: Int) -> Data? { return simulateFeedImageViewVisible(at: index)?.renderedImage } @@ -153,4 +183,5 @@ extension ListViewController { } private var feedImagesSection: Int { 0 } + private var feedLoadMoreSection: Int { 1 } } diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 806685b..78300dd 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -83,7 +83,6 @@ 5B7AB8D72BF8BCE60034C68B /* FeedItemsMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B7AB8D62BF8BCE60034C68B /* FeedItemsMapperTests.swift */; }; 5B8829032D6A7401006E0BD7 /* FeedPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8829022D6A7401006E0BD7 /* FeedPresenter.swift */; }; 5B8829042D6A7527006E0BD7 /* Feed.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5B8828FF2D6A7137006E0BD7 /* Feed.xcstrings */; }; - 5B8829062D6A7A9A006E0BD7 /* FeedViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8829052D6A7A9A006E0BD7 /* FeedViewModel.swift */; }; 5B8829082D6A7B12006E0BD7 /* ResourceLoadingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8829072D6A7B12006E0BD7 /* ResourceLoadingViewModel.swift */; }; 5B88290A2D6A7B76006E0BD7 /* ResourceErrorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8829092D6A7B76006E0BD7 /* ResourceErrorViewModel.swift */; }; 5B88290B2D6A8133006E0BD7 /* FeedLocalizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8AB3732D5ECCBF00CDDDEB /* FeedLocalizationTests.swift */; }; @@ -112,6 +111,14 @@ 5BA598BE2CE1998D007B1795 /* XCTestCase+MemoryLeakTracking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8BB9982C02719F00D40D42 /* XCTestCase+MemoryLeakTracking.swift */; }; 5BA598BF2CE19E50007B1795 /* FeedCacheTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B034B422CA3A0C800FB65F8 /* FeedCacheTestHelpers.swift */; }; 5BA598C02CE19EE0007B1795 /* SharedTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B034B442CA3A1A100FB65F8 /* SharedTestHelpers.swift */; }; + 5BA75FCE2D8E59590003DE6C /* LoadMoreCellController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BA75FCD2D8E59590003DE6C /* LoadMoreCellController.swift */; }; + 5BA75FD02D8E5D720003DE6C /* LoadMoreCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BA75FCF2D8E5D720003DE6C /* LoadMoreCell.swift */; }; + 5BA75FD32D8E5FE90003DE6C /* FEED_WITH_LOAD_MORE_INDICATOR_dark.png in Resources */ = {isa = PBXBuildFile; fileRef = 5BA75FD12D8E5FE90003DE6C /* FEED_WITH_LOAD_MORE_INDICATOR_dark.png */; }; + 5BA75FD42D8E5FE90003DE6C /* FEED_WITH_LOAD_MORE_INDICATOR_light.png in Resources */ = {isa = PBXBuildFile; fileRef = 5BA75FD22D8E5FE90003DE6C /* FEED_WITH_LOAD_MORE_INDICATOR_light.png */; }; + 5BA75FD82D8E65CC0003DE6C /* FEED_WITH_LOAD_MORE_ERROR_light.png in Resources */ = {isa = PBXBuildFile; fileRef = 5BA75FD72D8E65CC0003DE6C /* FEED_WITH_LOAD_MORE_ERROR_light.png */; }; + 5BA75FD92D8E65CC0003DE6C /* FEED_WITH_LOAD_MORE_ERROR_extraExtraExtraLarge.png in Resources */ = {isa = PBXBuildFile; fileRef = 5BA75FD62D8E65CC0003DE6C /* FEED_WITH_LOAD_MORE_ERROR_extraExtraExtraLarge.png */; }; + 5BA75FDA2D8E65CC0003DE6C /* FEED_WITH_LOAD_MORE_ERROR_dark.png in Resources */ = {isa = PBXBuildFile; fileRef = 5BA75FD52D8E65CC0003DE6C /* FEED_WITH_LOAD_MORE_ERROR_dark.png */; }; + 5BA75FDC2D8E6EE80003DE6C /* Paginated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BA75FDB2D8E6EE80003DE6C /* Paginated.swift */; }; 5BB735132D7CD33B00189186 /* UIImage+TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB735122D7CD33B00189186 /* UIImage+TestHelpers.swift */; }; 5BB735152D7CD9F900189186 /* UITableView+HeaderSizing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB735142D7CD9F900189186 /* UITableView+HeaderSizing.swift */; }; 5BB735172D7D0BEE00189186 /* UIViewController+Snapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB735162D7D0BEE00189186 /* UIViewController+Snapshot.swift */; }; @@ -261,7 +268,6 @@ 5B7AB8D62BF8BCE60034C68B /* FeedItemsMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItemsMapperTests.swift; sourceTree = ""; }; 5B8828FF2D6A7137006E0BD7 /* Feed.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Feed.xcstrings; sourceTree = ""; }; 5B8829022D6A7401006E0BD7 /* FeedPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedPresenter.swift; sourceTree = ""; }; - 5B8829052D6A7A9A006E0BD7 /* FeedViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedViewModel.swift; sourceTree = ""; }; 5B8829072D6A7B12006E0BD7 /* ResourceLoadingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceLoadingViewModel.swift; sourceTree = ""; }; 5B8829092D6A7B76006E0BD7 /* ResourceErrorViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceErrorViewModel.swift; sourceTree = ""; }; 5B88290C2D6A82D3006E0BD7 /* FeedImagePresenterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImagePresenterTests.swift; sourceTree = ""; }; @@ -288,6 +294,14 @@ 5BA598BA2CE194BC007B1795 /* EssentialFeedCacheIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EssentialFeedCacheIntegrationTests.swift; sourceTree = ""; }; 5BA598BD2CE1954B007B1795 /* EssentialFeedCacheIntegrationTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = EssentialFeedCacheIntegrationTests.xctestplan; sourceTree = ""; }; 5BA598EA2CFABE45007B1795 /* EssentialFeedAPIEndToEndTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = EssentialFeedAPIEndToEndTests.xctestplan; sourceTree = ""; }; + 5BA75FCD2D8E59590003DE6C /* LoadMoreCellController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreCellController.swift; sourceTree = ""; }; + 5BA75FCF2D8E5D720003DE6C /* LoadMoreCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreCell.swift; sourceTree = ""; }; + 5BA75FD12D8E5FE90003DE6C /* FEED_WITH_LOAD_MORE_INDICATOR_dark.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = FEED_WITH_LOAD_MORE_INDICATOR_dark.png; sourceTree = ""; }; + 5BA75FD22D8E5FE90003DE6C /* FEED_WITH_LOAD_MORE_INDICATOR_light.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = FEED_WITH_LOAD_MORE_INDICATOR_light.png; sourceTree = ""; }; + 5BA75FD52D8E65CC0003DE6C /* FEED_WITH_LOAD_MORE_ERROR_dark.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = FEED_WITH_LOAD_MORE_ERROR_dark.png; sourceTree = ""; }; + 5BA75FD62D8E65CC0003DE6C /* FEED_WITH_LOAD_MORE_ERROR_extraExtraExtraLarge.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = FEED_WITH_LOAD_MORE_ERROR_extraExtraExtraLarge.png; sourceTree = ""; }; + 5BA75FD72D8E65CC0003DE6C /* FEED_WITH_LOAD_MORE_ERROR_light.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = FEED_WITH_LOAD_MORE_ERROR_light.png; sourceTree = ""; }; + 5BA75FDB2D8E6EE80003DE6C /* Paginated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Paginated.swift; sourceTree = ""; }; 5BB735122D7CD33B00189186 /* UIImage+TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+TestHelpers.swift"; sourceTree = ""; }; 5BB735142D7CD9F900189186 /* UITableView+HeaderSizing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITableView+HeaderSizing.swift"; sourceTree = ""; }; 5BB735162D7D0BEE00189186 /* UIViewController+Snapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Snapshot.swift"; sourceTree = ""; }; @@ -513,6 +527,7 @@ isa = PBXGroup; children = ( 5B06826D2D0A5E59009749F3 /* FeedImageCellController.swift */, + 5BA75FCD2D8E59590003DE6C /* LoadMoreCellController.swift */, ); path = Controllers; sourceTree = ""; @@ -522,6 +537,7 @@ children = ( 5B74FD1B2D64A6DE007478DC /* Helpers */, 5B6992D62D03F23700DD47E9 /* FeedImageCell.swift */, + 5BA75FCF2D8E5D720003DE6C /* LoadMoreCell.swift */, 5B8AB34F2D51AA1B00CDDDEB /* Feed.storyboard */, 5B8AB3652D51AB6200CDDDEB /* Feed.xcassets */, ); @@ -549,6 +565,7 @@ isa = PBXGroup; children = ( 5B0E220A2BFE2FEA009FC3EB /* HTTPClient.swift */, + 5BA75FDB2D8E6EE80003DE6C /* Paginated.swift */, ); path = "Shared API"; sourceTree = ""; @@ -692,6 +709,11 @@ 5B1926262D89031F006C9C65 /* FEED_WITH_CONTENT_light_extraExtraExtraLarge.png */, 5B1926272D89031F006C9C65 /* FEED_WITH_FAILED_IMAGE_LOADING_dark.png */, 5B1926282D89031F006C9C65 /* FEED_WITH_FAILED_IMAGE_LOADING_light.png */, + 5BA75FD52D8E65CC0003DE6C /* FEED_WITH_LOAD_MORE_ERROR_dark.png */, + 5BA75FD72D8E65CC0003DE6C /* FEED_WITH_LOAD_MORE_ERROR_light.png */, + 5BA75FD62D8E65CC0003DE6C /* FEED_WITH_LOAD_MORE_ERROR_extraExtraExtraLarge.png */, + 5BA75FD12D8E5FE90003DE6C /* FEED_WITH_LOAD_MORE_INDICATOR_dark.png */, + 5BA75FD22D8E5FE90003DE6C /* FEED_WITH_LOAD_MORE_INDICATOR_light.png */, ); path = snapshots; sourceTree = ""; @@ -788,7 +810,6 @@ children = ( 5B8829022D6A7401006E0BD7 /* FeedPresenter.swift */, 5B88290E2D6A94C3006E0BD7 /* FeedImagePresenter.swift */, - 5B8829052D6A7A9A006E0BD7 /* FeedViewModel.swift */, 5B8829102D6A964F006E0BD7 /* FeedImageViewModel.swift */, 5B8828FF2D6A7137006E0BD7 /* Feed.xcstrings */, ); @@ -1128,6 +1149,11 @@ 5B1926312D89033F006C9C65 /* IMAGE_COMMENTS_light.png in Resources */, 5B1926322D89033F006C9C65 /* IMAGE_COMMENTS_light_extraExtraExtraLarge.png in Resources */, 5B1926332D89033F006C9C65 /* IMAGE_COMMENTS_dark.png in Resources */, + 5BA75FD32D8E5FE90003DE6C /* FEED_WITH_LOAD_MORE_INDICATOR_dark.png in Resources */, + 5BA75FD42D8E5FE90003DE6C /* FEED_WITH_LOAD_MORE_INDICATOR_light.png in Resources */, + 5BA75FD82D8E65CC0003DE6C /* FEED_WITH_LOAD_MORE_ERROR_light.png in Resources */, + 5BA75FD92D8E65CC0003DE6C /* FEED_WITH_LOAD_MORE_ERROR_extraExtraExtraLarge.png in Resources */, + 5BA75FDA2D8E65CC0003DE6C /* FEED_WITH_LOAD_MORE_ERROR_dark.png in Resources */, 5B1926292D89031F006C9C65 /* FEED_WITH_CONTENT_dark.png in Resources */, 5B19262A2D89031F006C9C65 /* FEED_WITH_CONTENT_light.png in Resources */, 5B19262B2D89031F006C9C65 /* FEED_WITH_CONTENT_light_extraExtraExtraLarge.png in Resources */, @@ -1162,12 +1188,12 @@ 5B73493E2D84FA14007F7D5D /* ResourceErrorView.swift in Sources */, 5B7349282D829960007F7D5D /* FeedImageDataMapper.swift in Sources */, 5BE36BA62CD5845700ACC57C /* FeedCachePolicy.swift in Sources */, + 5BA75FDC2D8E6EE80003DE6C /* Paginated.swift in Sources */, 5B7349192D824CC8007F7D5D /* ImageComment.swift in Sources */, 5BF9F30D2CDAD64700C8DB96 /* FeedStore.xcdatamodeld in Sources */, 5BDE3C672D6C225A005D520D /* CoreDataFeedStore+FeedStore.swift in Sources */, 5B73494A2D85337A007F7D5D /* ImageCommentsPresenter.swift in Sources */, 5B034B392C9BD2C000FB65F8 /* LocalFeedImage.swift in Sources */, - 5B8829062D6A7A9A006E0BD7 /* FeedViewModel.swift in Sources */, 5B034B352C9A819900FB65F8 /* FeedStore.swift in Sources */, 5B034B332C9A804C00FB65F8 /* LocalFeedLoader.swift in Sources */, 5B7349162D81A28D007F7D5D /* ImageCommentsMapper.swift in Sources */, @@ -1251,7 +1277,9 @@ 5BB735152D7CD9F900189186 /* UITableView+HeaderSizing.swift in Sources */, 5B74FD1A2D649D0E007478DC /* ErrorView.swift in Sources */, 5B74FD1F2D64A821007478DC /* UIRefreshControl+Helpers.swift in Sources */, + 5BA75FD02D8E5D720003DE6C /* LoadMoreCell.swift in Sources */, 5B19263F2D891F7F006C9C65 /* UIView+Container.swift in Sources */, + 5BA75FCE2D8E59590003DE6C /* LoadMoreCellController.swift in Sources */, 5B73498B2D87CF38007F7D5D /* CellController.swift in Sources */, 5B8AB3682D52D73200CDDDEB /* UITableView+Dequeueing.swift in Sources */, 5B6992D92D0662B200DD47E9 /* UIView+Shimmering.swift in Sources */, diff --git a/EssentialFeed/EssentialFeed/Feed API/FeedEndpoint.swift b/EssentialFeed/EssentialFeed/Feed API/FeedEndpoint.swift index 53bdd9f..bf59fbc 100644 --- a/EssentialFeed/EssentialFeed/Feed API/FeedEndpoint.swift +++ b/EssentialFeed/EssentialFeed/Feed API/FeedEndpoint.swift @@ -6,12 +6,20 @@ import Foundation public enum FeedEndpoint { - case get + case get(after: FeedImage? = nil) public func url(baseURL: URL) -> URL { switch self { - case .get: - return baseURL.appendingPathComponent("/v1/feed") + case let .get(image): + var components = URLComponents() + components.scheme = baseURL.scheme + components.host = baseURL.host + components.path = baseURL.path + "/v1/feed" + components.queryItems = [ + URLQueryItem(name: "limit", value: "10"), + image.map { URLQueryItem(name: "after_id", value: $0.id.uuidString) }, + ].compactMap { $0 } + return components.url! } } } diff --git a/EssentialFeed/EssentialFeed/Feed Presentation/FeedPresenter.swift b/EssentialFeed/EssentialFeed/Feed Presentation/FeedPresenter.swift index b8f6900..064c6bc 100644 --- a/EssentialFeed/EssentialFeed/Feed Presentation/FeedPresenter.swift +++ b/EssentialFeed/EssentialFeed/Feed Presentation/FeedPresenter.swift @@ -13,8 +13,4 @@ public final class FeedPresenter { bundle: Bundle(for: FeedPresenter.self), comment: "Title for the feed view") } - - public static func map(_ feed: [FeedImage]) -> FeedViewModel { - FeedViewModel(feed: feed) - } } diff --git a/EssentialFeed/EssentialFeed/Feed Presentation/FeedViewModel.swift b/EssentialFeed/EssentialFeed/Feed Presentation/FeedViewModel.swift deleted file mode 100644 index 67302e7..0000000 --- a/EssentialFeed/EssentialFeed/Feed Presentation/FeedViewModel.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// Created by Rodrigo Porto. -// Copyright © 2025 PortoCode. All Rights Reserved. -// - -public struct FeedViewModel { - public let feed: [FeedImage] -} diff --git a/EssentialFeed/EssentialFeed/Shared API/Paginated.swift b/EssentialFeed/EssentialFeed/Shared API/Paginated.swift new file mode 100644 index 0000000..4a9aff1 --- /dev/null +++ b/EssentialFeed/EssentialFeed/Shared API/Paginated.swift @@ -0,0 +1,18 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import Foundation + +public struct Paginated { + public typealias LoadMoreCompletion = (Result) -> Void + + public let items: [Item] + public let loadMore: ((@escaping LoadMoreCompletion) -> Void)? + + public init(items: [Item], loadMore: ((@escaping LoadMoreCompletion) -> Void)? = nil) { + self.items = items + self.loadMore = loadMore + } +} diff --git a/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift b/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift index 2cdbeaf..449555b 100644 --- a/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift +++ b/EssentialFeed/EssentialFeed/Shared Presentation/LoadResourcePresenter.swift @@ -34,6 +34,13 @@ public final class LoadResourcePresenter { self.mapper = mapper } + public init(resourceView: View, loadingView: ResourceLoadingView, errorView: ResourceErrorView) where Resource == View.ResourceViewModel { + self.resourceView = resourceView + self.loadingView = loadingView + self.errorView = errorView + self.mapper = { $0 } + } + public func didStartLoading() { errorView.display(.noError) loadingView.display(ResourceLoadingViewModel(isLoading: true)) diff --git a/EssentialFeed/EssentialFeedTests/Feed API/FeedEndpointTests.swift b/EssentialFeed/EssentialFeedTests/Feed API/FeedEndpointTests.swift index 6e7c714..45cbbaf 100644 --- a/EssentialFeed/EssentialFeedTests/Feed API/FeedEndpointTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed API/FeedEndpointTests.swift @@ -11,10 +11,25 @@ class FeedEndpointTests: XCTestCase { func test_feed_endpointURL() { let baseURL = URL(string: "http://base-url.com")! - let received = FeedEndpoint.get.url(baseURL: baseURL) - let expected = URL(string: "http://base-url.com/v1/feed")! + let received = FeedEndpoint.get().url(baseURL: baseURL) - XCTAssertEqual(received, expected) + XCTAssertEqual(received.scheme, "http", "scheme") + XCTAssertEqual(received.host, "base-url.com", "host") + XCTAssertEqual(received.path, "/v1/feed", "path") + XCTAssertEqual(received.query, "limit=10", "query") + } + + func test_feed_endpointURLAfterGivenImage() { + let image = uniqueImage() + let baseURL = URL(string: "http://base-url.com")! + + let received = FeedEndpoint.get(after: image).url(baseURL: baseURL) + + XCTAssertEqual(received.scheme, "http", "scheme") + XCTAssertEqual(received.host, "base-url.com", "host") + XCTAssertEqual(received.path, "/v1/feed", "path") + XCTAssertEqual(received.query?.contains("limit=10"), true, "limit query param") + XCTAssertEqual(received.query?.contains("after_id=\(image.id)"), true, "after_id query param") } } diff --git a/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift b/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift index f9e496b..e9825ee 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Presentation/FeedPresenterTests.swift @@ -12,14 +12,6 @@ class FeedPresenterTests: XCTestCase { XCTAssertEqual(FeedPresenter.title, localized("FEED_VIEW_TITLE")) } - func test_map_createsViewModel() { - let feed = uniqueImageFeed().models - - let viewModel = FeedPresenter.map(feed) - - XCTAssertEqual(viewModel.feed, feed) - } - // MARK: - Helpers private func localized(_ key: String, file: StaticString = #file, line: UInt = #line) -> String { diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/LoadMoreCellController.swift b/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/LoadMoreCellController.swift new file mode 100644 index 0000000..de4d4ed --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/LoadMoreCellController.swift @@ -0,0 +1,60 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import UIKit +import EssentialFeed + +public class LoadMoreCellController: NSObject, UITableViewDataSource, UITableViewDelegate { + private let cell = LoadMoreCell() + private let callback: () -> Void + private var offsetObserver: NSKeyValueObservation? + + public init(callback: @escaping () -> Void) { + self.callback = callback + } + + public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + 1 + } + + public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + cell.selectionStyle = .none + return cell + } + + public func tableView(_ tableView: UITableView, willDisplay: UITableViewCell, forRowAt indexPath: IndexPath) { + reloadIfNeeded() + + offsetObserver = tableView.observe(\.contentOffset, options: .new) { [weak self] (tableView, _) in + guard tableView.isDragging else { return } + + self?.reloadIfNeeded() + } + } + + public func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { + offsetObserver = nil + } + + public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + reloadIfNeeded() + } + + private func reloadIfNeeded() { + guard !cell.isLoading else { return } + + callback() + } +} + +extension LoadMoreCellController: ResourceLoadingView, ResourceErrorView { + public func display(_ viewModel: ResourceLoadingViewModel) { + cell.isLoading = viewModel.isLoading + } + + public func display(_ viewModel: EssentialFeed.ResourceErrorViewModel) { + cell.message = viewModel.message + } +} diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Views/LoadMoreCell.swift b/EssentialFeed/EssentialFeediOS/Feed UI/Views/LoadMoreCell.swift new file mode 100644 index 0000000..d203da0 --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/Feed UI/Views/LoadMoreCell.swift @@ -0,0 +1,60 @@ +// +// Created by Rodrigo Porto. +// Copyright © 2025 PortoCode. All Rights Reserved. +// + +import UIKit + +public class LoadMoreCell: UITableViewCell { + + private lazy var spinner: UIActivityIndicatorView = { + let spinner = UIActivityIndicatorView(style: .medium) + contentView.addSubview(spinner) + + spinner.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + spinner.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + spinner.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + contentView.heightAnchor.constraint(greaterThanOrEqualToConstant: 40) + ]) + + return spinner + }() + + private lazy var messageLabel: UILabel = { + let label = UILabel() + label.textColor = .tertiaryLabel + label.font = .preferredFont(forTextStyle: .footnote) + label.numberOfLines = 0 + label.textAlignment = .center + label.adjustsFontForContentSizeCategory = true + contentView.addSubview(label) + + label.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8), + contentView.trailingAnchor.constraint(equalTo: label.trailingAnchor, constant: 8), + label.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), + contentView.bottomAnchor.constraint(equalTo: label.bottomAnchor, constant: 8) + ]) + + return label + }() + + public var isLoading: Bool { + get { spinner.isAnimating } + set { + if newValue { + spinner.startAnimating() + } else { + spinner.stopAnimating() + } + } + } + + public var message: String? { + get { messageLabel.text } + set { messageLabel.text = newValue } + } + +} diff --git a/EssentialFeed/EssentialFeediOS/Shared UI/Controllers/CellController.swift b/EssentialFeed/EssentialFeediOS/Shared UI/Controllers/CellController.swift index 220fc03..1fd5778 100644 --- a/EssentialFeed/EssentialFeediOS/Shared UI/Controllers/CellController.swift +++ b/EssentialFeed/EssentialFeediOS/Shared UI/Controllers/CellController.swift @@ -11,18 +11,11 @@ public struct CellController { let delegate: UITableViewDelegate? let dataSourcePrefetching: UITableViewDataSourcePrefetching? - public init(id: AnyHashable, _ dataSource: UITableViewDataSource & UITableViewDelegate & UITableViewDataSourcePrefetching) { - self.id = id - self.dataSource = dataSource - self.delegate = dataSource - self.dataSourcePrefetching = dataSource - } - public init(id: AnyHashable, _ dataSource: UITableViewDataSource) { self.id = id self.dataSource = dataSource - self.delegate = nil - self.dataSourcePrefetching = nil + self.delegate = dataSource as? UITableViewDelegate + self.dataSourcePrefetching = dataSource as? UITableViewDataSourcePrefetching } } diff --git a/EssentialFeed/EssentialFeediOS/Shared UI/Controllers/ListViewController.swift b/EssentialFeed/EssentialFeediOS/Shared UI/Controllers/ListViewController.swift index f391c67..ca2aa90 100644 --- a/EssentialFeed/EssentialFeediOS/Shared UI/Controllers/ListViewController.swift +++ b/EssentialFeed/EssentialFeediOS/Shared UI/Controllers/ListViewController.swift @@ -63,10 +63,12 @@ public final class ListViewController: UITableViewController, UITableViewDataSou onRefresh?() } - public func display(_ cellControllers: [CellController]) { + public func display(_ sections: [CellController]...) { var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([0]) - snapshot.appendItems(cellControllers, toSection: 0) + sections.enumerated().forEach { section, cellControllers in + snapshot.appendSections([section]) + snapshot.appendItems(cellControllers, toSection: section) + } dataSource.applySnapshotUsingReloadData(snapshot) } diff --git a/EssentialFeed/EssentialFeediOSTests/Feed UI/FeedSnapshotTests.swift b/EssentialFeed/EssentialFeediOSTests/Feed UI/FeedSnapshotTests.swift index b4a259b..d3404c7 100644 --- a/EssentialFeed/EssentialFeediOSTests/Feed UI/FeedSnapshotTests.swift +++ b/EssentialFeed/EssentialFeediOSTests/Feed UI/FeedSnapshotTests.swift @@ -28,6 +28,25 @@ class FeedSnapshotTests: XCTestCase { assert(snapshot: sut.snapshot(for: .iPhone(style: .dark)), named: "FEED_WITH_FAILED_IMAGE_LOADING_dark") } + func test_feedWithLoadMoreIndicator() { + let sut = makeSUT() + + sut.display(feedWithLoadMoreIndicator()) + + assert(snapshot: sut.snapshot(for: .iPhone(style: .light)), named: "FEED_WITH_LOAD_MORE_INDICATOR_light") + assert(snapshot: sut.snapshot(for: .iPhone(style: .dark)), named: "FEED_WITH_LOAD_MORE_INDICATOR_dark") + } + + func test_feedWithLoadMoreError() { + let sut = makeSUT() + + sut.display(feedWithLoadMoreError()) + + assert(snapshot: sut.snapshot(for: .iPhone(style: .light)), named: "FEED_WITH_LOAD_MORE_ERROR_light") + assert(snapshot: sut.snapshot(for: .iPhone(style: .dark)), named: "FEED_WITH_LOAD_MORE_ERROR_dark") + assert(snapshot: sut.snapshot(for: .iPhone(style: .light, contentSize: .extraExtraExtraLarge)), named: "FEED_WITH_LOAD_MORE_ERROR_extraExtraExtraLarge") + } + // MARK: - Helpers private func makeSUT() -> ListViewController { @@ -70,6 +89,29 @@ class FeedSnapshotTests: XCTestCase { ] } + private func feedWithLoadMoreIndicator() -> [CellController] { + let loadMore = LoadMoreCellController(callback: {}) + loadMore.display(ResourceLoadingViewModel(isLoading: true)) + return feedWith(loadMore: loadMore) + } + + private func feedWithLoadMoreError() -> [CellController] { + let loadMore = LoadMoreCellController(callback: {}) + loadMore.display(ResourceErrorViewModel(message: "This is a multiline\nerror message")) + return feedWith(loadMore: loadMore) + } + + private func feedWith(loadMore: LoadMoreCellController) -> [CellController] { + let stub = feedWithContent().last! + let cellController = FeedImageCellController(viewModel: stub.viewModel, delegate: stub, selection: {}) + stub.controller = cellController + + return [ + CellController(id: UUID(), cellController), + CellController(id: UUID(), loadMore) + ] + } + } private extension ListViewController { diff --git a/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_LOAD_MORE_ERROR_dark.png b/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_LOAD_MORE_ERROR_dark.png new file mode 100644 index 0000000..2c8242e Binary files /dev/null and b/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_LOAD_MORE_ERROR_dark.png differ diff --git a/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_LOAD_MORE_ERROR_extraExtraExtraLarge.png b/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_LOAD_MORE_ERROR_extraExtraExtraLarge.png new file mode 100644 index 0000000..56552e0 Binary files /dev/null and b/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_LOAD_MORE_ERROR_extraExtraExtraLarge.png differ diff --git a/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_LOAD_MORE_ERROR_light.png b/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_LOAD_MORE_ERROR_light.png new file mode 100644 index 0000000..ef5bc48 Binary files /dev/null and b/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_LOAD_MORE_ERROR_light.png differ diff --git a/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_LOAD_MORE_INDICATOR_dark.png b/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_LOAD_MORE_INDICATOR_dark.png new file mode 100644 index 0000000..7b8da08 Binary files /dev/null and b/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_LOAD_MORE_INDICATOR_dark.png differ diff --git a/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_LOAD_MORE_INDICATOR_light.png b/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_LOAD_MORE_INDICATOR_light.png new file mode 100644 index 0000000..9774c67 Binary files /dev/null and b/EssentialFeed/EssentialFeediOSTests/Feed UI/snapshots/FEED_WITH_LOAD_MORE_INDICATOR_light.png differ